Skip to content
Open
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3259,6 +3259,8 @@ _notification:
flushNotification: "通知の履歴をリセットする"
exportOfXCompleted: "{x}のエクスポートが完了しました"
login: "ログインがありました"
pointReceived: "{points}{pointName}を受け取りました"
pointReceivedFrom: "{name}さんから{points}{pointName}を受け取りました"
loginBonus: "ログインボーナス"
createToken: "アクセストークンが作成されました"
createTokenDescription: "心当たりがない場合は「{text}」を通じてアクセストークンを削除してください。"
Expand All @@ -3282,6 +3284,7 @@ _notification:
achievementEarned: "実績の獲得"
exportCompleted: "エクスポートが完了した"
login: "ログイン"
pointReceived: "ポイントを受け取った"
loginBonus: "ログインボーナス"
createToken: "アクセストークンの作成"
test: "通知のテスト"
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/core/PointService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@ import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';

@Injectable()
export class PointService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,

private notificationService: NotificationService,
) {
}

Expand Down Expand Up @@ -62,6 +65,10 @@ export class PointService {
points: newRecipientBalance,
});

this.notificationService.createNotification(recipientId, 'pointReceived', {
points: amount,
}, senderId);

return {
success: true,
senderBalance: newSenderBalance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ export class NotificationEntityService implements OnModuleInit {
header: notification.customHeader,
icon: notification.customIcon,
} : {}),
...(notification.type === 'loginBonus' ? {
...(notification.type === 'loginBonus' || notification.type === 'pointReceived' ? {
points: notification.points,
} : {}),
});
Expand Down
6 changes: 6 additions & 0 deletions packages/backend/src/models/Notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ export type MiNotification = {
type: 'test';
id: string;
createdAt: string;
} | {
type: 'pointReceived';
id: string;
createdAt: string;
notifierId: MiUser['id'];
points: number;
} | {
type: 'loginBonus';
id: string;
Expand Down
24 changes: 24 additions & 0 deletions packages/backend/src/models/json-schema/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,30 @@ export const packedNotificationSchema = {
optional: false, nullable: false,
},
},
}, {
type: 'object',
properties: {
...baseSchema.properties,
type: {
type: 'string',
optional: false, nullable: false,
enum: ['pointReceived'],
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
points: {
type: 'number',
optional: false, nullable: false,
},
},
}, {
type: 'object',
properties: {
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/models/json-schema/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,7 @@ export const packedMeDetailedOnlySchema = {
test: { optional: true, ...notificationRecieveConfig },
login: { optional: true, ...notificationRecieveConfig },
createToken: { optional: true, ...notificationRecieveConfig },
pointReceived: { optional: true, ...notificationRecieveConfig },
exportCompleted: { optional: true, ...notificationRecieveConfig },
},
},
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/server/api/endpoints/i/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export const paramDef = {
roleAssigned: notificationRecieveConfig,
chatRoomInvitationReceived: notificationRecieveConfig,
achievementEarned: notificationRecieveConfig,
pointReceived: notificationRecieveConfig,
app: notificationRecieveConfig,
test: notificationRecieveConfig,
},
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const notificationTypes = [
'exportCompleted',
'login',
'createToken',
'pointReceived',
'app',
'test',
'loginBonus',
Expand Down
49 changes: 49 additions & 0 deletions packages/backend/test/unit/PointService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { beforeEach, describe, expect, test } from 'vitest';
import { mockDeep } from 'vitest-mock-extended';
import type { UsersRepository, MiUser } from '@/models/_.js';
import { PointService } from '@/core/PointService.js';
import { NotificationService } from '@/core/NotificationService.js';

describe('PointService', () => {
const usersRepository = mockDeep<UsersRepository>();
const notificationService = mockDeep<NotificationService>();
const service = new PointService(usersRepository, notificationService);

beforeEach(() => {
usersRepository.findOneByOrFail.mockReset();
usersRepository.update.mockReset();
notificationService.createNotification.mockReset();
usersRepository.findOneByOrFail.mockResolvedValueOnce({ id: 'sender', points: 100 } as MiUser);
usersRepository.findOneByOrFail.mockResolvedValueOnce({ id: 'recipient', points: 20 } as MiUser);
});

test('notifies the recipient of the sender and amount after transferring points', async () => {
const result = await service.sendPoints('sender', 'recipient', 30);

expect(result).toEqual({ success: true, senderBalance: 70, recipientBalance: 50 });
expect(usersRepository.update).toHaveBeenCalledWith('sender', { points: 70 });
expect(usersRepository.update).toHaveBeenCalledWith('recipient', { points: 50 });
expect(notificationService.createNotification).toHaveBeenCalledExactlyOnceWith(
'recipient', 'pointReceived', { points: 30 }, 'sender',
);
expect(notificationService.createNotification.mock.invocationCallOrder[0]).toBeGreaterThan(
usersRepository.update.mock.invocationCallOrder[1],
);
});

test('does not notify when the sender has insufficient points', async () => {
await expect(service.sendPoints('sender', 'recipient', 101)).rejects.toThrow('Insufficient points');
expect(notificationService.createNotification).not.toHaveBeenCalled();
});

test('does not notify when the balance update fails', async () => {
usersRepository.update.mockRejectedValueOnce(new Error('update failed'));
await expect(service.sendPoints('sender', 'recipient', 30)).rejects.toThrow('update failed');
expect(notificationService.createNotification).not.toHaveBeenCalled();
});
});
9 changes: 6 additions & 3 deletions packages/frontend/src/components/MkNotification.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
[$style.t_scheduledNotePosted]: notification.type === 'scheduledNotePosted',
[$style.t_scheduledNotePostFailed]: notification.type === 'scheduledNotePostFailed',
[$style.t_achievementEarned]: notification.type === 'achievementEarned',
[$style.t_loginBonus]: notification.type === 'loginBonus',
[$style.t_loginBonus]: notification.type === 'loginBonus' || notification.type === 'pointReceived',
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
[$style.t_login]: notification.type === 'login',
[$style.t_createToken]: notification.type === 'createToken',
Expand All @@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i v-else-if="notification.type === 'scheduledNotePosted'" class="ti ti-send"></i>
<i v-else-if="notification.type === 'scheduledNotePostFailed'" class="ti ti-alert-triangle"></i>
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
<i v-else-if="notification.type === 'loginBonus'" class="ti ti-gift"></i>
<i v-else-if="notification.type === 'loginBonus' || notification.type === 'pointReceived'" class="ti ti-gift"></i>
<i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i>
<i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i>
<i v-else-if="notification.type === 'createToken'" class="ti ti-key"></i>
Expand Down Expand Up @@ -77,7 +77,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="notification.type === 'createToken'">{{ i18n.ts._notification.createToken }}</span>
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
<span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</span>
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<MkA v-else-if="notification.type === 'pointReceived' || notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
<span v-else-if="notification.type === 'renote:grouped'">{{ i18n.tsx._notification.renotedBySomeUsers({ n: notification.users.length }) }}</span>
Expand Down Expand Up @@ -126,6 +126,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements">
{{ i18n.ts._achievements._types[`_${notification.achievement}`].title }}
</MkA>
<div v-else-if="notification.type === 'pointReceived'" :class="$style.text">
{{ i18n.tsx._notification.pointReceived({ points: notification.points, pointName: instance.pointName ?? i18n.ts.point }) }}
</div>
<div v-else-if="notification.type === 'loginBonus'" :class="$style.text">
+{{ notification.points }} {{ instance.pointName ?? i18n.ts.point }}
</div>
Expand Down
42 changes: 42 additions & 0 deletions packages/frontend/test/unit/point-received-notification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { afterEach, expect, test, vi } from 'vitest';
import { cleanup, render } from '@testing-library/vue';
import type * as Misskey from 'misskey-js';
import MkNotification from '@/components/MkNotification.vue';
import { instance } from '@/instance.js';
import { i18n } from '@/i18n.js';

vi.mock('@/i.js', () => ({ ensureSignin: () => ({ id: 'recipient' }) }));
vi.mock('@/instance.js', () => ({ instance: { pointName: 'コイン' } }));
vi.mock('@/utility/misskey-api.js', () => ({ misskeyApi: vi.fn() }));
vi.mock('@/components/MkReactionIcon.vue', () => ({ default: { template: '<span/>' } }));
vi.mock('@/components/MkButton.vue', () => ({ default: { template: '<button><slot/></button>' } }));

afterEach(cleanup);

test.each(['コイン', null])('shows the sender profile and received amount with point name %s', (pointName) => {
instance.pointName = pointName;
const notification = {
id: 'gift', createdAt: '2026-10-06T00:00:00.000Z', type: 'pointReceived', points: 30,
userId: 'sender', user: { id: 'sender', name: '贈り主', username: 'sender', host: null },
} as Misskey.entities.Notification;
const view = render(MkNotification, {
props: { notification },
global: {
stubs: {
MkA: { props: ['to'], template: '<a :href="to"><slot/></a>' },
MkUserName: { props: ['user'], template: '<span>{{ user.name }}</span>' },
MkAvatar: { props: ['user'], template: '<span :data-avatar-user="user.id"/>' },
},
directives: { 'user-preview': {} },
},
});

expect(view.getByRole('link', { name: '贈り主' }).getAttribute('href')).toBe('/@sender');
expect(view.container.querySelector('[data-avatar-user="sender"]')).not.toBeNull();
expect(view.getByText(i18n.tsx._notification.pointReceived({ points: 30, pointName: pointName ?? i18n.ts.point }))).toBeTruthy();
});
12 changes: 12 additions & 0 deletions packages/i18n/src/autogen/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12397,6 +12397,14 @@ export interface Locale extends ILocale {
* ログインがありました
*/
"login": string;
/**
* {points}{pointName}を受け取りました
*/
"pointReceived": ParameterizedString<"points" | "pointName">;
/**
* {name}さんから{points}{pointName}を受け取りました
*/
"pointReceivedFrom": ParameterizedString<"name" | "points" | "pointName">;
/**
* ログインボーナス
*/
Expand Down Expand Up @@ -12482,6 +12490,10 @@ export interface Locale extends ILocale {
* ログイン
*/
"login": string;
/**
* ポイントを受け取った
*/
"pointReceived": string;
/**
* ログインボーナス
*/
Expand Down
2 changes: 1 addition & 1 deletion packages/misskey-js/etc/misskey-js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3825,7 +3825,7 @@ type Notification_2 = components['schemas']['Notification'];
type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json'];

// @public (undocumented)
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollEnded", "scheduledNotePosted", "scheduledNotePostFailed", "receiveFollowRequest", "followRequestAccepted", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken"];
export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollEnded", "scheduledNotePosted", "scheduledNotePostFailed", "receiveFollowRequest", "followRequestAccepted", "app", "roleAssigned", "chatRoomInvitationReceived", "achievementEarned", "exportCompleted", "test", "login", "createToken", "pointReceived"];

// @public (undocumented)
export function nyaize(text: string): string;
Expand Down
37 changes: 33 additions & 4 deletions packages/misskey-js/src/autogen/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5011,6 +5011,15 @@ export type components = {
/** Format: misskey:id */
userListId: string;
};
pointReceived?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
} | {
/** @enum {string} */
type: 'list';
/** Format: misskey:id */
userListId: string;
};
exportCompleted?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
Expand Down Expand Up @@ -5376,6 +5385,17 @@ export type components = {
/** @enum {string} */
type: 'scheduledNotePostFailed';
noteDraft: components['schemas']['NoteDraft'];
} | {
/** Format: id */
id: string;
/** Format: date-time */
createdAt: string;
/** @enum {string} */
type: 'pointReceived';
user: components['schemas']['UserLite'];
/** Format: id */
userId: string;
points: number;
} | {
/** Format: id */
id: string;
Expand Down Expand Up @@ -32500,8 +32520,8 @@ export interface operations {
untilDate?: number;
/** @default true */
markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[];
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'pointReceived' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'pointReceived' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[];
};
};
};
Expand Down Expand Up @@ -32585,8 +32605,8 @@ export interface operations {
untilDate?: number;
/** @default true */
markAsRead?: boolean;
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[];
includeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'pointReceived' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[];
excludeTypes?: ('note' | 'follow' | 'mention' | 'reply' | 'renote' | 'quote' | 'reaction' | 'pollEnded' | 'scheduledNotePosted' | 'scheduledNotePostFailed' | 'receiveFollowRequest' | 'followRequestAccepted' | 'roleAssigned' | 'chatRoomInvitationReceived' | 'achievementEarned' | 'exportCompleted' | 'login' | 'createToken' | 'pointReceived' | 'app' | 'test' | 'loginBonus' | 'pollVote' | 'groupInvited')[];
};
};
};
Expand Down Expand Up @@ -33925,6 +33945,15 @@ export interface operations {
/** Format: misskey:id */
userListId: string;
};
pointReceived?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
} | {
/** @enum {string} */
type: 'list';
/** Format: misskey:id */
userListId: string;
};
app?: {
/** @enum {string} */
type: 'all' | 'following' | 'follower' | 'mutualFollow' | 'followingOrFollower' | 'never';
Expand Down
1 change: 1 addition & 0 deletions packages/misskey-js/src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const notificationTypes = [
'test',
'login',
'createToken',
'pointReceived',
] as const;

export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
Expand Down
Loading
Loading