Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/screenshots/login-bonus-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1821,6 +1821,15 @@ _serverRules:
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"

_serverSettings:
_loginBonus:
title: "ログインボーナス"
enabled: "ログインボーナスを有効にする"
resetTime: "付与日の切り替え時刻(日本時間)"
resetTimeDescription: "毎日この時刻以降の最初のアクセスで、1日1回ポイントを付与します。ページを開いたままの場合は再読み込みしてください。"
minPoints: "付与ポイントの下限"
maxPoints: "付与ポイントの上限"
pointsDescription: "下限から上限までの整数をランダムに付与します。同じ値にすると固定ポイントになります。"
invalidPointsRange: "ポイントは1以上の整数で、下限が上限以下になるように設定してください。"
iconUrl: "アイコン画像のURL"
appIconDescription: "{host}がアプリとして表示される際のアイコンを指定します。"
appIconUsageExample: "例: PWAや、スマートフォンのホーム画面にブックマークとして追加された時など"
Expand Down
20 changes: 20 additions & 0 deletions packages/backend/migration/1791144811075-LoginBonusSettings.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

export class LoginBonusSettings1791144811075 {
name = 'LoginBonusSettings1791144811075'

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "loginBonusResetTime" character varying(5) NOT NULL DEFAULT '00:00'`);
await queryRunner.query(`ALTER TABLE "meta" ADD "loginBonusMinPoints" integer NOT NULL DEFAULT 1`);
await queryRunner.query(`ALTER TABLE "meta" ADD "loginBonusMaxPoints" integer NOT NULL DEFAULT 5`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "loginBonusMaxPoints"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "loginBonusMinPoints"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "loginBonusResetTime"`);
}
}
21 changes: 10 additions & 11 deletions packages/backend/src/core/LoginBonusService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,13 @@ export class LoginBonusService {
* Award login bonus points to a user if they haven't received today's bonus
*/
async awardLoginBonus(userId: string): Promise<LoginBonusAward | null> {
const now = new Date();
const today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`;
const meta = await this.metaService.fetch();
if (!meta.enableLoginBonus) return null;

const [resetHour, resetMinute] = meta.loginBonusResetTime.split(':').map(Number);
// Shift the clock so the configured time in Japan starts a new bonus day.
const bonusDate = new Date(Date.now() + (9 * 60 - resetHour * 60 - resetMinute) * 60_000);
const today = `${bonusDate.getUTCFullYear()}/${bonusDate.getUTCMonth() + 1}/${bonusDate.getUTCDate()}`;

const userProfile = await this.userProfilesRepository.findOne({
where: { userId },
Expand All @@ -57,26 +62,20 @@ export class LoginBonusService {
return null;
}

// Check if login bonus feature is enabled
const meta = await this.metaService.fetch();
if (!meta.enableLoginBonus) {
return null;
}

// Check if user has permission to receive login bonus
const policies = await this.roleService.getUserPolicies(userId);
if (!policies.loginBonusGrantEnabled) {
return null;
}

// Award random points (1-5)
const bonusPoints = randomInt(1, 6);
const bonusPoints = randomInt(meta.loginBonusMinPoints, meta.loginBonusMaxPoints + 1);
const balance = await this.db.transaction(async manager => {
const claimed = await manager.createQueryBuilder()
.update(MiUserProfile)
.set({ lastLoginBonusDate: today })
.where('"userId" = :userId', { userId })
.andWhere('"lastLoginBonusDate" IS DISTINCT FROM :today', { today })
// Changing the reset time must not move the last awarded day backwards.
.andWhere('("lastLoginBonusDate" IS NULL OR "lastLoginBonusDate"::date < CAST(:today AS date))', { today })
.execute();

if (claimed.affected === 0) return null;
Expand Down
16 changes: 16 additions & 0 deletions packages/backend/src/models/Meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,22 @@ export class MiMeta {
})
public enableLoginBonus: boolean;

@Column('varchar', {
length: 5,
default: '00:00',
})
public loginBonusResetTime: string;

@Column('integer', {
default: 1,
})
public loginBonusMinPoints: number;

@Column('integer', {
default: 5,
})
public loginBonusMaxPoints: number;

@Column('boolean', {
default: false,
})
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/server/api/endpoints/admin/meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export const meta = {
nowLocalUsers: { type: 'integer', optional: false, nullable: false },
maxLocalUsers: { type: 'integer', optional: false, nullable: false },
enableLoginBonus: { type: 'boolean', optional: false, nullable: false },
loginBonusResetTime: { type: 'string', optional: false, nullable: false },
loginBonusMinPoints: { type: 'integer', optional: false, nullable: false },
loginBonusMaxPoints: { type: 'integer', optional: false, nullable: false },
cacheRemoteFiles: {
type: 'boolean',
optional: false, nullable: false,
Expand Down Expand Up @@ -839,6 +842,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
remoteNotesCleaningMaxProcessingDurationInMinutes: instance.remoteNotesCleaningMaxProcessingDurationInMinutes,
showRoleBadgesOfRemoteUsers: instance.showRoleBadgesOfRemoteUsers,
enableLoginBonus: instance.enableLoginBonus,
loginBonusResetTime: instance.loginBonusResetTime,
loginBonusMinPoints: instance.loginBonusMinPoints,
loginBonusMaxPoints: instance.loginBonusMaxPoints,
isManaged: envOption.managed,
...(envOption.managed ? {
nowLocalUsers: await this.usersRepository.count({ where: { host: IsNull(), username: Not(In(['instance.actor', 'relay.actor', this.config.adminUserName ?? '', this.config.rootUserName ?? ''])) } }),
Expand Down
28 changes: 28 additions & 0 deletions packages/backend/src/server/api/endpoints/admin/update-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,31 @@ import { ModerationLogService } from '@/core/ModerationLogService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { MetaService } from '@/core/MetaService.js';
import { envOption } from '@/env.js';
import { ApiError } from '@/server/api/error.js';

export const meta = {
tags: ['admin'],

requireCredential: true,
requireAdmin: true,
kind: 'write:admin:meta',

errors: {
invalidLoginBonusPointsRange: {
message: 'Minimum login bonus points must not exceed maximum points.',
code: 'INVALID_LOGIN_BONUS_POINTS_RANGE',
id: '6e2ffe67-587e-41f9-887f-3355e95161e7',
},
},
} as const;

export const paramDef = {
type: 'object',
properties: {
enableLoginBonus: { type: 'boolean' },
loginBonusResetTime: { type: 'string', pattern: '^([01][0-9]|2[0-3]):[0-5][0-9]$' },
loginBonusMinPoints: { type: 'integer', minimum: 1, maximum: 2147483647 },
loginBonusMaxPoints: { type: 'integer', minimum: 1, maximum: 2147483647 },
disableRegistration: { type: 'boolean', nullable: true },
pinnedUsers: {
type: 'array', nullable: true, items: {
Expand Down Expand Up @@ -863,8 +875,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.enableLoginBonus !== undefined) {
set.enableLoginBonus = ps.enableLoginBonus;
}
if (ps.loginBonusResetTime !== undefined) {
set.loginBonusResetTime = ps.loginBonusResetTime;
}
if (ps.loginBonusMinPoints !== undefined) {
set.loginBonusMinPoints = ps.loginBonusMinPoints;
}
if (ps.loginBonusMaxPoints !== undefined) {
set.loginBonusMaxPoints = ps.loginBonusMaxPoints;
}

const before = await this.metaService.fetch(true);
if (ps.loginBonusMinPoints !== undefined || ps.loginBonusMaxPoints !== undefined) {
const minPoints = ps.loginBonusMinPoints ?? before.loginBonusMinPoints;
const maxPoints = ps.loginBonusMaxPoints ?? before.loginBonusMaxPoints;
if (minPoints > maxPoints) {
throw new ApiError(meta.errors.invalidLoginBonusPointsRange);
}
}

await this.metaService.update(set);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { beforeEach, describe, expect, test, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import type { Config } from '@/config.js';
import type { MiLocalUser } from '@/models/User.js';
import { MiMeta } from '@/models/Meta.js';
import { MetaService } from '@/core/MetaService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import UpdateMetaEndpoint from '@/server/api/endpoints/admin/update-meta.js';

describe('admin/update-meta login bonus settings', () => {
const metaService = mock<MetaService>();
const moderationLogService = mock<ModerationLogService>();
const settings = Object.assign(new MiMeta(), {
enableLoginBonus: false,
loginBonusResetTime: '00:00',
loginBonusMinPoints: 1,
loginBonusMaxPoints: 5,
});
const endpoint = new UpdateMetaEndpoint(mock<Config>(), settings, metaService, moderationLogService);
const admin = mock<MiLocalUser>();

beforeEach(() => {
vi.clearAllMocks();
metaService.fetch.mockResolvedValue(settings);
});

test('saves enablement, reset time and a fixed points amount', async () => {
const params = { enableLoginBonus: true, loginBonusResetTime: '07:30', loginBonusMinPoints: 10, loginBonusMaxPoints: 10 };
await endpoint.exec(params, admin, null);
expect(metaService.update).toHaveBeenCalledWith(params);
});

test.each([
{ loginBonusMinPoints: 6 },
{ loginBonusMinPoints: 10, loginBonusMaxPoints: 9 },
])('rejects an inverted range, including partial updates: %j', async params => {
await expect(endpoint.exec(params, admin, null)).rejects.toMatchObject({ code: 'INVALID_LOGIN_BONUS_POINTS_RANGE' });
expect(metaService.update).not.toHaveBeenCalled();
});

test.each([
{ loginBonusResetTime: '24:00' },
{ loginBonusMinPoints: 0 },
{ loginBonusMaxPoints: 1.5 },
])('rejects invalid time or points: %j', async params => {
await expect(endpoint.exec(params, admin, null)).rejects.toMatchObject({ code: 'INVALID_PARAM' });
expect(metaService.update).not.toHaveBeenCalled();
});

test('keeps existing settings when changing an unrelated setting', async () => {
await endpoint.exec({ name: 'Test' }, admin, null);
expect(metaService.update).toHaveBeenCalledWith({ name: 'Test' });
});
});
75 changes: 72 additions & 3 deletions packages/backend/test/unit/server/api/endpoints/i.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { mock } from 'vitest-mock-extended';
import { DataSource } from 'typeorm';
import { loadConfig } from '@/config.js';
Expand All @@ -18,6 +18,7 @@ import { DEFAULT_POLICIES, RoleService } from '@/core/RoleService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import MeEndpoint from '@/server/api/endpoints/i.js';
import { LastLoginBonusDate1791103468369 } from '../../../../../migration/1791103468369-LastLoginBonusDate.js';
import { LoginBonusSettings1791144811075 } from '../../../../../migration/1791144811075-LoginBonusSettings.js';

describe('i login bonus', () => {
const config = loadConfig();
Expand Down Expand Up @@ -57,17 +58,25 @@ describe('i login bonus', () => {

beforeEach(async () => {
vi.clearAllMocks();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-04T03:00:00Z'));
await users.deleteAll();
const now = new Date();
today = `${now.getFullYear()}/${now.getMonth() + 1}/${now.getDate()}`;
await users.insert({ id: 'loginbonususer', username: 'alice', usernameLower: 'alice', points: 20 });
user = await users.findOneByOrFail({ id: 'loginbonususer' }) as MiLocalUser;
await profiles.insert({ userId: user.id, loggedInDates: [today] });
metaService.fetch.mockResolvedValue({ enableLoginBonus: true } as Awaited<ReturnType<MetaService['fetch']>>);
metaService.fetch.mockResolvedValue({
enableLoginBonus: true, loginBonusResetTime: '00:00', loginBonusMinPoints: 1, loginBonusMaxPoints: 5,
} as Awaited<ReturnType<MetaService['fetch']>>);
roleService.getUserPolicies.mockResolvedValue({ ...DEFAULT_POLICIES });
userEntityService.pack.mockImplementation(async packedUser => ({ points: (packedUser as MiUser).points }) as never);
});

afterEach(() => {
vi.useRealTimers();
});

test('awards once after migration even if today is already a logged-in day', async () => {
const result = await endpoint.exec({}, user, null);
expect(result.points).toBeGreaterThanOrEqual(21);
Expand All @@ -85,7 +94,9 @@ describe('i login bonus', () => {
expect(await profiles.findOneByOrFail({ userId: user.id })).toMatchObject({
loggedInDates: [today], lastLoginBonusDate: null,
});
metaService.fetch.mockResolvedValue({ enableLoginBonus: true } as Awaited<ReturnType<MetaService['fetch']>>);
metaService.fetch.mockResolvedValue({
enableLoginBonus: true, loginBonusResetTime: '00:00', loginBonusMinPoints: 1, loginBonusMaxPoints: 5,
} as Awaited<ReturnType<MetaService['fetch']>>);
expect((await endpoint.exec({}, user, null)).points).toBeGreaterThan(20);
});

Expand Down Expand Up @@ -124,6 +135,64 @@ describe('i login bonus', () => {
expect(notificationService.createNotification).toHaveBeenCalledTimes(1);
});

test.each([
['00:00', '2026-10-04T15:00:00Z'],
['07:30', '2026-10-04T22:30:00Z'],
])('starts a new bonus day at %s in Japan', async (resetTime, boundary) => {
metaService.fetch.mockResolvedValue({
enableLoginBonus: true, loginBonusResetTime: resetTime, loginBonusMinPoints: 1, loginBonusMaxPoints: 5,
} as Awaited<ReturnType<MetaService['fetch']>>);
await profiles.update({ userId: user.id }, { lastLoginBonusDate: '2026/10/4' });
vi.setSystemTime(new Date(Date.parse(boundary) - 1000));
expect((await endpoint.exec({}, user, null)).points).toBe(20);
vi.setSystemTime(new Date(boundary));
const awarded = await endpoint.exec({}, user, null);
expect(awarded.points).toBeGreaterThan(20);
expect((await profiles.findOneByOrFail({ userId: user.id })).lastLoginBonusDate).toBe('2026/10/5');
expect((await endpoint.exec({}, user, null)).points).toBe(awarded.points);
expect(notificationService.createNotification).toHaveBeenCalledTimes(1);
});

test.each([[7, 9], [12, 12]])('awards the configured points range %i–%i', async (min, max) => {
metaService.fetch.mockResolvedValue({
enableLoginBonus: true, loginBonusResetTime: '09:00', loginBonusMinPoints: min, loginBonusMaxPoints: max,
} as Awaited<ReturnType<MetaService['fetch']>>);
const result = await service.awardLoginBonus(user.id);
expect(result?.points).toBeGreaterThanOrEqual(min);
expect(result?.points).toBeLessThanOrEqual(max);
expect(result?.balance).toBe(20 + result!.points);
expect(notificationService.createNotification).toHaveBeenCalledWith(user.id, 'loginBonus', { points: result!.points });
});

test('does not award an older bonus day after the reset time changes', async () => {
await profiles.update({ userId: user.id }, { lastLoginBonusDate: today });
metaService.fetch.mockResolvedValue({
enableLoginBonus: true, loginBonusResetTime: '13:00', loginBonusMinPoints: 1, loginBonusMaxPoints: 5,
} as Awaited<ReturnType<MetaService['fetch']>>);
expect((await endpoint.exec({}, user, null)).points).toBe(20);
expect((await profiles.findOneByOrFail({ userId: user.id })).lastLoginBonusDate).toBe(today);
});

test('settings migration preserves enablement and supports rollback and reapplication', async () => {
const runner = db.createQueryRunner();
const migration = new LoginBonusSettings1791144811075();
await runner.startTransaction();
try {
await runner.query(`INSERT INTO "meta" ("id", "enableLoginBonus") VALUES ('x', true)`);
await migration.down(runner);
await migration.up(runner);
const [settings] = await runner.query(`SELECT "enableLoginBonus", "loginBonusResetTime", "loginBonusMinPoints", "loginBonusMaxPoints" FROM "meta" WHERE "id" = 'x'`);
expect(settings).toEqual({
enableLoginBonus: true, loginBonusResetTime: '00:00', loginBonusMinPoints: 1, loginBonusMaxPoints: 5,
});
await migration.down(runner);
await migration.up(runner);
} finally {
await runner.rollbackTransaction();
await runner.release();
}
});

test.each([false, true])('migration preserves history and initializes the guard for enableLoginBonus=%s', async enabled => {
const runner = db.createQueryRunner();
const migration = new LastLoginBonusDate1791103468369();
Expand Down
Loading
Loading