diff --git a/.node-version b/.node-version index bb8c76c68e2..517f38666b4 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v22.11.0 +v22.14.0 diff --git a/locales/index.d.ts b/locales/index.d.ts index 76fe3b79b91..6742a88febe 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -478,6 +478,28 @@ export interface Locale extends ILocale { * ポイント */ "point": string; + /** + * {pointName}を送る + */ + "sendPoints": ParameterizedString<"pointName">; + /** + * {name}に{pointName}を送る + */ + "sendPointsTo": ParameterizedString<"name" | "pointName">; + /** + * {name}に{points}{pointName}を送信します。 + * よろしいですか? + * ※送信後は取り消すことができません。 + */ + "sendPointsConfirm": ParameterizedString<"name" | "points" | "pointName">; + /** + * {pointName}が足りません + */ + "notEnoughPoints": ParameterizedString<"pointName">; + /** + * {pointName}は数字で入力してください + */ + "pointsMustBeNumber": ParameterizedString<"pointName">; /** * フォローされています */ @@ -7397,6 +7419,10 @@ export interface Locale extends ILocale { * ログインボーナスの付与 */ "loginBonusGrantEnabled": string; + /** + * ポイントの送信 + */ + "canSendPoints": string; /** * 絵文字ピッカーのプロファイルの上限数(最大5) */ @@ -10042,6 +10068,10 @@ export interface Locale extends ILocale { * ログインボーナス */ "loginbonus": string; + /** + * {sender}から{point}{pointName}をもらいました + */ + "acceptPoints": ParameterizedString<"sender" | "point" | "pointName">; /** * 通知テスト */ @@ -10151,6 +10181,10 @@ export interface Locale extends ILocale { * ログインボーナス */ "loginBonus": string; + /** + * {pointName}獲得 + */ + "acceptPoints": ParameterizedString<"pointName">; /** * エクスポートが完了した */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 7fd62ea9e6e..83dc1bd6a31 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -115,6 +115,11 @@ notes: "ノート" following: "フォロー" followers: "フォロワー" point: "ポイント" +sendPoints: "{pointName}を送る" +sendPointsTo: "{name}に{pointName}を送る" +sendPointsConfirm: "{name}に{points}{pointName}を送信します。\nよろしいですか?\n※送信後は取り消すことができません。" +notEnoughPoints: "{pointName}が足りません" +pointsMustBeNumber: "{pointName}は数字で入力してください" followsYou: "フォローされています" createList: "リスト作成" manageLists: "リストの管理" @@ -1910,6 +1915,7 @@ _role: _options: gtlAvailable: "グローバルタイムラインの閲覧" loginBonusGrantEnabled: "ログインボーナスの付与" + canSendPoints: "ポイントの送信" emojiPickerProfileLimit: "絵文字ピッカーのプロファイルの上限数(最大5)" ltlAvailable: "ローカルタイムラインの閲覧" canPublicNote: "パブリック投稿の許可" @@ -2647,6 +2653,7 @@ _notification: emptyPushNotificationMessage: "プッシュ通知の更新をしました" achievementEarned: "実績を獲得" loginbonus: "ログインボーナス" + acceptPoints: "{sender}から{point}{pointName}をもらいました" testNotification: "通知テスト" checkNotificationBehavior: "通知の表示を確かめる" sendTestNotification: "テスト通知を送信する" @@ -2676,6 +2683,7 @@ _notification: roleAssigned: "ロールが付与された" achievementEarned: "実績の獲得" loginBonus: "ログインボーナス" + acceptPoints: "{pointName}獲得" exportCompleted: "エクスポートが完了した" login: "ログイン" test: "通知のテスト" diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index f553fa744c0..f2d8581924c 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -65,6 +65,7 @@ export type RolePolicies = { listPinnedLimit: number; localTimelineAnyLimit: number; loginBonusGrantEnabled: boolean; + canSendPoints: boolean; canImportAntennas: boolean; canImportBlocking: boolean; canImportFollowing: boolean; @@ -111,6 +112,7 @@ export const DEFAULT_POLICIES: RolePolicies = { listPinnedLimit: 2, localTimelineAnyLimit: 3, loginBonusGrantEnabled: true, + canSendPoints: false, }; @Injectable() @@ -423,6 +425,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { listPinnedLimit: calc('listPinnedLimit', vs => Math.max(...vs)), localTimelineAnyLimit: calc('localTimelineAnyLimit', vs => Math.max(...vs)), loginBonusGrantEnabled: calc('loginBonusGrantEnabled', vs => vs.some(v => v === true)), + canSendPoints: calc('canSendPoints', vs => vs.some(v => v === true)), }; } diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 0fe505054ef..74cea9b42f1 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -171,6 +171,9 @@ export class NotificationEntityService implements OnModuleInit { ...(notification.type === 'loginBonus' ? { loginBonus: notification.loginBonus, } : {}), + ...(notification.type === 'acceptPoints' ? { + getPoint: notification.getPoint, + } : {}), ...(notification.type === 'app' ? { body: notification.customBody, header: notification.customHeader, diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index 9974ad964b6..7033a545d77 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -58,6 +58,12 @@ export type MiNotification = { id: string; createdAt: string; loginBonus: number; +} | { + type: 'acceptPoints'; + id: string; + createdAt: string; + getPoint: number; + notifierId: MiUser['id']; } | { type: 'pollEnded'; id: string; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index b3b47c667fd..b20c2302638 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -422,6 +422,30 @@ export const packedNotificationSchema = { }, }, }, + }, { + type: 'object', + properties: { + ...baseSchema.properties, + type: { + type: 'string', + optional: false, nullable: false, + enum: ['acceptPoints'], + }, + getPoint: { + type: 'number', + optional: false, nullable: false, + }, + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, }, { type: 'object', properties: { diff --git a/packages/backend/src/models/json-schema/role.ts b/packages/backend/src/models/json-schema/role.ts index f92c982d88b..39afba2f7de 100644 --- a/packages/backend/src/models/json-schema/role.ts +++ b/packages/backend/src/models/json-schema/role.ts @@ -304,6 +304,10 @@ export const packedRolePoliciesSchema = { type: 'boolean', optional: false, nullable: false, }, + canSendPoints: { + type: 'boolean', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index 93c692bafd7..ac887015ef8 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -416,3 +416,4 @@ export * as 'admin/inbox-rule/delete' from './endpoints/admin/inbox-rule/delete. export * as 'admin/inbox-rule/list' from './endpoints/admin/inbox-rule/list.js'; export * as 'users/lists/list-favorite' from './endpoints/users/lists/list-favorite.js'; export * as 'notes/home-local-timeline' from './endpoints/notes/home-local-timeline.js' +export * as 'point/send' from './endpoints/point/send.js'; diff --git a/packages/backend/src/server/api/endpoints/point/send.ts b/packages/backend/src/server/api/endpoints/point/send.ts new file mode 100644 index 00000000000..98e580a8bd7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/point/send.ts @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { RoleService } from '@/core/RoleService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tag: ['point'], + requireCredential: true, + kind: 'write:points', + secure: true, + errors: { + userIsNotFound: { + message: 'user is not found.', + code: 'USER_IS_NOT_FOUND', + id: '87472165-2e39-fcb9-352b-98c24a6d825e', + }, + notEnoughPoints: { + message: 'not enough points.', + code: 'NOT_ENOUGH_POINTS', + id: '10e9f46d-9f1f-7a4b-801b-5fe88e4bb1aa', + }, + cannotSendPoints: { + message: 'cannot send points.', + code: 'CANNOT_SEND_POINTS', + id: 'f1cf2616-db7b-3f97-5a14-06a0a0005f8f', + }, + recipientIsYou: { + message: 'recipient is you.', + code: 'RECIPIENT_IS_ME', + id: '24095319-69ee-8033-b0a2-9ad6928bbcb8', + }, + }, + +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + points: { type: 'number' }, + }, + required: ['userId', 'points'], +} as const; + +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + private notificationService: NotificationService, + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const sender = await this.usersRepository.findOneBy({ id: me.id }); + const user = await this.usersRepository.findOneBy({ id: ps.userId }); + + if (sender == null || user == null) { + throw new ApiError(meta.errors.userIsNotFound); + } + + if ((await this.roleService.getUserPolicies(sender.id)).canSendPoints === false) { + throw new ApiError(meta.errors.cannotSendPoints); + } + //送れるかどうかチェック + if (sender.getPoints < ps.points) { + throw new ApiError(meta.errors.notEnoughPoints); + } + + // 受信者が自分ならエラー + if (sender.id === user.id) { + throw new ApiError(meta.errors.recipientIsYou); + } + + // 送るポイントが0以下の場合はエラー + if (ps.points <= 0) { + throw new ApiError(meta.errors.cannotSendPoints); + } + + //ポイントを送る + await this.usersRepository.update(sender.id, { + getPoints: sender.getPoints - ps.points, + }); + await this.usersRepository.update(user.id, { + getPoints: user.getPoints + ps.points, + }); + + this.notificationService.createNotification(user.id, 'acceptPoints', { + getPoint: ps.points, + }, sender.id); + + return {}; + }); + } +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 6fbfe3d6ed0..83f4632f607 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -50,6 +50,7 @@ export const notificationTypes = [ 'app', 'test', 'loginBonus', + 'acceptPoints', ] as const; export const groupedNotificationTypes = [ diff --git a/packages/frontend-shared/js/const.ts b/packages/frontend-shared/js/const.ts index 2c7b362b635..f39eabde45e 100644 --- a/packages/frontend-shared/js/const.ts +++ b/packages/frontend-shared/js/const.ts @@ -110,6 +110,7 @@ export const ROLE_POLICIES = [ 'listPinnedLimit', 'localTimelineAnyLimit', 'loginBonusGrantEnabled', + 'canSendPoints', 'canImportAntennas', 'canImportBlocking', 'canImportFollowing', diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index 75b7a0752be..aa2c3e7905c 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -55,9 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only [$style.t_achievementEarned]: notification.type === 'achievementEarned' || notification.type === 'loginBonus', [$style.t_exportCompleted]: notification.type === 'exportCompleted', - [$style.t_login]: notification.type === 'login', - [$style.t_createToken]: notification.type === 'createToken', - [$style.t_roleAssigned]: + [$style.t_login]: notification.type === 'login', + [$style.t_acceptPoints]: notification.type === 'acceptPoints', + [$style.t_createToken]: notification.type === 'createToken', + [$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null, }, @@ -91,6 +92,10 @@ SPDX-License-Identifier: AGPL-3.0-only v-else-if="notification.type === 'loginBonus'" class="ti ti-medal" > + @@ -143,7 +148,8 @@ SPDX-License-Identifier: AGPL-3.0-only notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || - notification.type === 'followRequestAccepted' + notification.type === 'followRequestAccepted' || + notification.type === 'acceptPoints' " v-user-preview="notification.user.id" :class="$style.headerName" @@ -301,6 +307,18 @@ SPDX-License-Identifier: AGPL-3.0-only }) }} +
+ {{ + i18n.tsx._notification.acceptPoints({ + point: notification.getPoint, + pointName: instance?.pointName ?? i18n.ts.point, + sender: notification.user.username, + }) + }} +
- +