summaryrefslogtreecommitdiff
path: root/packages/backend
diff options
context:
space:
mode:
authoryukineko <27853966+hideki0403@users.noreply.github.com>2023-07-15 09:57:58 +0900
committerGitHub <noreply@github.com>2023-07-15 09:57:58 +0900
commit02957a1b5daaaf821ce21c11cc47cf169c4fc535 (patch)
treeecaa8fee0d547bb3733551ccebf85281357b9feb /packages/backend
parentfix(build): d.ts生成時にexport defaultを生成するように (#11280) (diff)
downloadsharkey-02957a1b5daaaf821ce21c11cc47cf169c4fc535.tar.gz
sharkey-02957a1b5daaaf821ce21c11cc47cf169c4fc535.tar.bz2
sharkey-02957a1b5daaaf821ce21c11cc47cf169c4fc535.zip
enhance: 招待機能の改善 (#11195)
* refactor(backend): 招待機能を改修 * feat(backend): 招待コードのcreate/delete/listエンドポイントを追加 * add(misskey-js): エンドポイントと型を追加 * change(backend): metaでinvite関連の情報も返すように * add(misskey-js): エンドポイントと型を追加 * add(backend): `/endpoints/invite/limit`を追加 * fix: createdByがnullableではなかったのを修正 * fix: relationが取得できていなかった問題を修正 * fix: パラメータを間違えていたのを修正 * feat(client): 招待ページを実装 * change(client): インスタンスメニューの「招待」押した場合に招待ページに飛ぶように変更 * feat: 招待コードをコピーできるように * change(backend): metaに招待コード発行に関する情報を持たせるのをやめる * feat: ロールごとに招待コードの発行上限数などを設定できるように * change(client): 招待コードをコピーしたときにダイアログを出すように * add: 招待に関する管理者用のエンドポイントを追加 * change(backend): モデレーターであれば作成者以外でも招待コードを削除できるように * change(backend): admin/invite/listはオフセットでページネーションするように * feat(client): 招待コードの管理ページを追加 * feat(client): 招待コードのリストをソートできるように * change: `admin/invite/create`のレスポンスを修正 * fix(client): 有効期限を指定できていなかった問題を修正 * refactor: 必要のない箇所を削除 * perf(backend): use limit() instead of take() * change(client): 作成ボタンを見た目を変更 * refactor: 招待コードの生成部分を共通化し、コード内に"01OI"のいずれかの文字を含まないように * fix(client): paginationの仕様が変わっていたので修正 * change(backend): expiresAtパラメータのnullを許容 * change(client): 有効期限を設けないときは日付の入力欄を非表示に * fix: 自身のポリシーよりもインスタンス側のポリシーが優先表示される問題を修正 * fix: n時間のときに「n時間間」となってしまうのを修正 * fix(backend): ポリシーが途中で変更されたときに作成可能数がマイナス表記になってしまうのを修正 * change(client): 招待コードのユーザー名が不明な理由を表示するように * update: CHANGELOG.md * lint * refactor * refactor * tweak ui * :art: * :art: * add(backend): indexを追加 * change(backend): indexの追加に伴う変更 * change(client): インスタンスメニューの「招待」の場所を変更 * add(frontend): MkInviteCode用のstorybookを追加 * Update misskey-js.api.md * fix(misskey-js): InviteのcreatedByの型が間違っていたのを修正 --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> Co-authored-by: tamaina <tamaina@hotmail.co.jp>
Diffstat (limited to 'packages/backend')
-rw-r--r--packages/backend/migration/1688720440658-refactor-invite-system.js25
-rw-r--r--packages/backend/migration/1688880985544-add-index-to-relations.js13
-rw-r--r--packages/backend/src/core/CoreModule.ts6
-rw-r--r--packages/backend/src/core/RoleService.ts9
-rw-r--r--packages/backend/src/core/entities/InviteCodeEntityService.ts52
-rw-r--r--packages/backend/src/misc/generate-invite-code.ts20
-rw-r--r--packages/backend/src/misc/json-schema.ts2
-rw-r--r--packages/backend/src/models/entities/RegistrationTicket.ts51
-rw-r--r--packages/backend/src/models/json-schema/invite-code.ts45
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts28
-rw-r--r--packages/backend/src/server/api/SignupApiService.ts44
-rw-r--r--packages/backend/src/server/api/endpoints.ts14
-rw-r--r--packages/backend/src/server/api/endpoints/admin/invite/create.ts80
-rw-r--r--packages/backend/src/server/api/endpoints/admin/invite/list.ts70
-rw-r--r--packages/backend/src/server/api/endpoints/invite/create.ts82
-rw-r--r--packages/backend/src/server/api/endpoints/invite/delete.ts71
-rw-r--r--packages/backend/src/server/api/endpoints/invite/limit.ts (renamed from packages/backend/src/server/api/endpoints/invite.ts)30
-rw-r--r--packages/backend/src/server/api/endpoints/invite/list.ts58
18 files changed, 667 insertions, 33 deletions
diff --git a/packages/backend/migration/1688720440658-refactor-invite-system.js b/packages/backend/migration/1688720440658-refactor-invite-system.js
new file mode 100644
index 0000000000..0dd49f7027
--- /dev/null
+++ b/packages/backend/migration/1688720440658-refactor-invite-system.js
@@ -0,0 +1,25 @@
+export class RefactorInviteSystem1688720440658 {
+ name = 'RefactorInviteSystem1688720440658'
+
+ async up(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "usedAt" TIMESTAMP WITH TIME ZONE`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "pendingUserId" character varying(32)`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "createdById" character varying(32)`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "usedById" character varying(32)`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "UQ_b6f93f2f30bdbb9a5ebdc7c7189" UNIQUE ("usedById")`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "FK_beba993576db0261a15364ea96e" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "FK_b6f93f2f30bdbb9a5ebdc7c7189" FOREIGN KEY ("usedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "FK_b6f93f2f30bdbb9a5ebdc7c7189"`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "FK_beba993576db0261a15364ea96e"`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "UQ_b6f93f2f30bdbb9a5ebdc7c7189"`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "usedById"`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "createdById"`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "pendingUserId"`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "usedAt"`);
+ await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "expiresAt"`);
+ }
+}
diff --git a/packages/backend/migration/1688880985544-add-index-to-relations.js b/packages/backend/migration/1688880985544-add-index-to-relations.js
new file mode 100644
index 0000000000..d6b5c57f55
--- /dev/null
+++ b/packages/backend/migration/1688880985544-add-index-to-relations.js
@@ -0,0 +1,13 @@
+export class AddIndexToRelations1688880985544 {
+ name = 'AddIndexToRelations1688880985544'
+
+ async up(queryRunner) {
+ await queryRunner.query(`CREATE INDEX "IDX_beba993576db0261a15364ea96" ON "registration_ticket" ("createdById") `);
+ await queryRunner.query(`CREATE INDEX "IDX_b6f93f2f30bdbb9a5ebdc7c718" ON "registration_ticket" ("usedById") `);
+ }
+
+ async down(queryRunner) {
+ await queryRunner.query(`DROP INDEX "public"."IDX_b6f93f2f30bdbb9a5ebdc7c718"`);
+ await queryRunner.query(`DROP INDEX "public"."IDX_beba993576db0261a15364ea96"`);
+ }
+}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index d3a1b1b024..c7c98b3bdd 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -81,6 +81,7 @@ import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js
import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js';
import { HashtagEntityService } from './entities/HashtagEntityService.js';
import { InstanceEntityService } from './entities/InstanceEntityService.js';
+import { InviteCodeEntityService } from './entities/InviteCodeEntityService.js';
import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js';
import { MutingEntityService } from './entities/MutingEntityService.js';
import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js';
@@ -205,6 +206,7 @@ const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService
const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useExisting: GalleryPostEntityService };
const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useExisting: HashtagEntityService };
const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService };
+const $InviteCodeEntityService: Provider = { provide: 'InviteCodeEntityService', useExisting: InviteCodeEntityService };
const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService };
const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService };
const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService };
@@ -329,6 +331,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
GalleryPostEntityService,
HashtagEntityService,
InstanceEntityService,
+ InviteCodeEntityService,
ModerationLogEntityService,
MutingEntityService,
RenoteMutingEntityService,
@@ -448,6 +451,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$GalleryPostEntityService,
$HashtagEntityService,
$InstanceEntityService,
+ $InviteCodeEntityService,
$ModerationLogEntityService,
$MutingEntityService,
$RenoteMutingEntityService,
@@ -567,6 +571,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
GalleryPostEntityService,
HashtagEntityService,
InstanceEntityService,
+ InviteCodeEntityService,
ModerationLogEntityService,
MutingEntityService,
RenoteMutingEntityService,
@@ -685,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$GalleryPostEntityService,
$HashtagEntityService,
$InstanceEntityService,
+ $InviteCodeEntityService,
$ModerationLogEntityService,
$MutingEntityService,
$RenoteMutingEntityService,
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index b0bfb44dc2..3b501cf8d7 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -21,6 +21,9 @@ export type RolePolicies = {
ltlAvailable: boolean;
canPublicNote: boolean;
canInvite: boolean;
+ inviteLimit: number;
+ inviteLimitCycle: number;
+ inviteExpirationTime: number;
canManageCustomEmojis: boolean;
canSearchNotes: boolean;
canHideAds: boolean;
@@ -42,6 +45,9 @@ export const DEFAULT_POLICIES: RolePolicies = {
ltlAvailable: true,
canPublicNote: true,
canInvite: false,
+ inviteLimit: 0,
+ inviteLimitCycle: 60 * 24 * 7,
+ inviteExpirationTime: 0,
canManageCustomEmojis: false,
canSearchNotes: false,
canHideAds: false,
@@ -277,6 +283,9 @@ export class RoleService implements OnApplicationShutdown {
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
+ inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
+ inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
+ inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
diff --git a/packages/backend/src/core/entities/InviteCodeEntityService.ts b/packages/backend/src/core/entities/InviteCodeEntityService.ts
new file mode 100644
index 0000000000..2d8e7a4681
--- /dev/null
+++ b/packages/backend/src/core/entities/InviteCodeEntityService.ts
@@ -0,0 +1,52 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { Packed } from '@/misc/json-schema.js';
+import type { User } from '@/models/entities/User.js';
+import type { RegistrationTicket } from '@/models/entities/RegistrationTicket.js';
+import { bindThis } from '@/decorators.js';
+import { UserEntityService } from './UserEntityService.js';
+
+@Injectable()
+export class InviteCodeEntityService {
+ constructor(
+ @Inject(DI.registrationTicketsRepository)
+ private registrationTicketsRepository: RegistrationTicketsRepository,
+
+ private userEntityService: UserEntityService,
+ ) {
+ }
+
+ @bindThis
+ public async pack(
+ src: RegistrationTicket['id'] | RegistrationTicket,
+ me?: { id: User['id'] } | null | undefined,
+ ): Promise<Packed<'InviteCode'>> {
+ const target = typeof src === 'object' ? src : await this.registrationTicketsRepository.findOneOrFail({
+ where: {
+ id: src,
+ },
+ relations: ['createdBy', 'usedBy'],
+ });
+
+ return await awaitAll({
+ id: target.id,
+ code: target.code,
+ expiresAt: target.expiresAt ? target.expiresAt.toISOString() : null,
+ createdAt: target.createdAt.toISOString(),
+ createdBy: target.createdBy ? await this.userEntityService.pack(target.createdBy, me) : null,
+ usedBy: target.usedBy ? await this.userEntityService.pack(target.usedBy, me) : null,
+ usedAt: target.usedAt ? target.usedAt.toISOString() : null,
+ used: !!target.usedAt,
+ });
+ }
+
+ @bindThis
+ public packMany(
+ targets: any[],
+ me: { id: User['id'] },
+ ) {
+ return Promise.all(targets.map(x => this.pack(x, me)));
+ }
+}
diff --git a/packages/backend/src/misc/generate-invite-code.ts b/packages/backend/src/misc/generate-invite-code.ts
new file mode 100644
index 0000000000..617b27361d
--- /dev/null
+++ b/packages/backend/src/misc/generate-invite-code.ts
@@ -0,0 +1,20 @@
+import { secureRndstr } from './secure-rndstr.js';
+
+const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // [0-9A-Z] w/o [01IO] (32 patterns)
+
+export function generateInviteCode(): string {
+ const code = secureRndstr(8, {
+ chars: CHARS,
+ });
+
+ const uniqueId = [];
+ let n = Math.floor(Date.now() / 1000 / 60);
+ while (true) {
+ uniqueId.push(CHARS[n % CHARS.length]);
+ const t = Math.floor(n / CHARS.length);
+ if (!t) break;
+ n = t;
+ }
+
+ return code + uniqueId.reverse().join('');
+}
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 7579040c68..ec6bc4a5fb 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -19,6 +19,7 @@ import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'
import { packedBlockingSchema } from '@/models/json-schema/blocking.js';
import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js';
import { packedHashtagSchema } from '@/models/json-schema/hashtag.js';
+import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.js';
import { packedPageSchema } from '@/models/json-schema/page.js';
import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js';
import { packedChannelSchema } from '@/models/json-schema/channel.js';
@@ -52,6 +53,7 @@ export const refs = {
RenoteMuting: packedRenoteMutingSchema,
Blocking: packedBlockingSchema,
Hashtag: packedHashtagSchema,
+ InviteCode: packedInviteCodeSchema,
Page: packedPageSchema,
Channel: packedChannelSchema,
QueueCount: packedQueueCountSchema,
diff --git a/packages/backend/src/models/entities/RegistrationTicket.ts b/packages/backend/src/models/entities/RegistrationTicket.ts
index 139e40f85e..4c42b20be8 100644
--- a/packages/backend/src/models/entities/RegistrationTicket.ts
+++ b/packages/backend/src/models/entities/RegistrationTicket.ts
@@ -1,17 +1,60 @@
-import { PrimaryColumn, Entity, Index, Column } from 'typeorm';
+import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn, OneToOne } from 'typeorm';
import { id } from '../id.js';
+import { User } from './User.js';
@Entity()
export class RegistrationTicket {
@PrimaryColumn(id())
public id: string;
- @Column('timestamp with time zone')
- public createdAt: Date;
-
@Index({ unique: true })
@Column('varchar', {
length: 64,
})
public code: string;
+
+ @Column('timestamp with time zone', {
+ nullable: true,
+ })
+ public expiresAt: Date | null;
+
+ @Column('timestamp with time zone')
+ public createdAt: Date;
+
+ @ManyToOne(type => User, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public createdBy: User | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ nullable: true,
+ })
+ public createdById: User['id'] | null;
+
+ @OneToOne(type => User, {
+ onDelete: 'CASCADE',
+ })
+ @JoinColumn()
+ public usedBy: User | null;
+
+ @Index()
+ @Column({
+ ...id(),
+ nullable: true,
+ })
+ public usedById: User['id'] | null;
+
+ @Column('timestamp with time zone', {
+ nullable: true,
+ })
+ public usedAt: Date | null;
+
+ @Column('varchar', {
+ length: 32,
+ nullable: true,
+ })
+ public pendingUserId: string | null;
}
diff --git a/packages/backend/src/models/json-schema/invite-code.ts b/packages/backend/src/models/json-schema/invite-code.ts
new file mode 100644
index 0000000000..b70a779f29
--- /dev/null
+++ b/packages/backend/src/models/json-schema/invite-code.ts
@@ -0,0 +1,45 @@
+export const packedInviteCodeSchema = {
+ type: 'object',
+ properties: {
+ id: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'id',
+ example: 'xxxxxxxxxx',
+ },
+ code: {
+ type: 'string',
+ optional: false, nullable: false,
+ example: 'GR6S02ERUA5VR',
+ },
+ expiresAt: {
+ type: 'string',
+ optional: false, nullable: true,
+ format: 'date-time',
+ },
+ createdAt: {
+ type: 'string',
+ optional: false, nullable: false,
+ format: 'date-time',
+ },
+ createdBy: {
+ type: 'object',
+ optional: false, nullable: true,
+ ref: 'UserLite',
+ },
+ usedBy: {
+ type: 'object',
+ optional: false, nullable: true,
+ ref: 'UserLite',
+ },
+ usedAt: {
+ type: 'string',
+ optional: false, nullable: true,
+ format: 'date-time',
+ },
+ used: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
+ },
+} as const;
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index d1ff3fe925..4e6bc46e67 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -38,7 +38,8 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat
import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js';
import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js';
import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js';
-import * as ep___invite from './endpoints/invite.js';
+import * as ep___admin_invite_create from './endpoints/admin/invite/create.js';
+import * as ep___admin_invite_list from './endpoints/admin/invite/list.js';
import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
@@ -230,6 +231,10 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
+import * as ep___invite_create from './endpoints/invite/create.js';
+import * as ep___invite_delete from './endpoints/invite/delete.js';
+import * as ep___invite_list from './endpoints/invite/list.js';
+import * as ep___invite_limit from './endpoints/invite/limit.js';
import * as ep___meta from './endpoints/meta.js';
import * as ep___emojis from './endpoints/emojis.js';
import * as ep___emoji from './endpoints/emoji.js';
@@ -378,7 +383,8 @@ const $admin_federation_updateInstance: Provider = { provide: 'ep:admin/federati
const $admin_getIndexStats: Provider = { provide: 'ep:admin/get-index-stats', useClass: ep___admin_getIndexStats.default };
const $admin_getTableStats: Provider = { provide: 'ep:admin/get-table-stats', useClass: ep___admin_getTableStats.default };
const $admin_getUserIps: Provider = { provide: 'ep:admin/get-user-ips', useClass: ep___admin_getUserIps.default };
-const $invite: Provider = { provide: 'ep:invite', useClass: ep___invite.default };
+const $admin_invite_create: Provider = { provide: 'ep:admin/invite/create', useClass: ep___admin_invite_create.default };
+const $admin_invite_list: Provider = { provide: 'ep:admin/invite/list', useClass: ep___admin_invite_list.default };
const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useClass: ep___admin_promo_create.default };
const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default };
const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default };
@@ -570,6 +576,10 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default };
const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default };
+const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default };
+const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default };
+const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default };
+const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default };
const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default };
const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default };
const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default };
@@ -722,7 +732,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_getIndexStats,
$admin_getTableStats,
$admin_getUserIps,
- $invite,
+ $admin_invite_create,
+ $admin_invite_list,
$admin_promo_create,
$admin_queue_clear,
$admin_queue_deliverDelayed,
@@ -914,6 +925,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_webhooks_show,
$i_webhooks_update,
$i_webhooks_delete,
+ $invite_create,
+ $invite_delete,
+ $invite_list,
+ $invite_limit,
$meta,
$emojis,
$emoji,
@@ -1060,7 +1075,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_getIndexStats,
$admin_getTableStats,
$admin_getUserIps,
- $invite,
+ $admin_invite_create,
+ $admin_invite_list,
$admin_promo_create,
$admin_queue_clear,
$admin_queue_deliverDelayed,
@@ -1252,6 +1268,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_webhooks_show,
$i_webhooks_update,
$i_webhooks_delete,
+ $invite_create,
+ $invite_delete,
+ $invite_list,
+ $invite_limit,
$meta,
$emojis,
$emoji,
diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts
index 5e18dcbe08..d681bf8e21 100644
--- a/packages/backend/src/server/api/SignupApiService.ts
+++ b/packages/backend/src/server/api/SignupApiService.ts
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
-import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
+import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, RegistrationTicket } from '@/models/index.js';
import type { Config } from '@/config.js';
import { MetaService } from '@/core/MetaService.js';
import { CaptchaService } from '@/core/CaptchaService.js';
@@ -109,13 +109,15 @@ export class SignupApiService {
}
}
+ let ticket: RegistrationTicket | null = null;
+
if (instance.disableRegistration) {
if (invitationCode == null || typeof invitationCode !== 'string') {
reply.code(400);
return;
}
- const ticket = await this.registrationTicketsRepository.findOneBy({
+ ticket = await this.registrationTicketsRepository.findOneBy({
code: invitationCode,
});
@@ -124,7 +126,15 @@ export class SignupApiService {
return;
}
- this.registrationTicketsRepository.delete(ticket.id);
+ if (ticket.expiresAt && ticket.expiresAt < new Date()) {
+ reply.code(400);
+ return;
+ }
+
+ if (ticket.usedAt) {
+ reply.code(400);
+ return;
+ }
}
if (instance.emailRequiredForSignup) {
@@ -148,14 +158,14 @@ export class SignupApiService {
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
- await this.userPendingsRepository.insert({
+ const pendingUser = await this.userPendingsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
code,
email: emailAddress!,
username: username,
password: hash,
- });
+ }).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0]));
const link = `${this.config.url}/signup-complete/${code}`;
@@ -163,6 +173,13 @@ export class SignupApiService {
`To complete signup, please click this link:<br><a href="${link}">${link}</a>`,
`To complete signup, please click this link: ${link}`);
+ if (ticket) {
+ await this.registrationTicketsRepository.update(ticket.id, {
+ usedAt: new Date(),
+ pendingUserId: pendingUser.id,
+ });
+ }
+
reply.code(204);
return;
} else {
@@ -176,6 +193,14 @@ export class SignupApiService {
includeSecrets: true,
});
+ if (ticket) {
+ await this.registrationTicketsRepository.update(ticket.id, {
+ usedAt: new Date(),
+ usedBy: account,
+ usedById: account.id,
+ });
+ }
+
return {
...res,
token: secret,
@@ -212,6 +237,15 @@ export class SignupApiService {
emailVerifyCode: null,
});
+ const ticket = await this.registrationTicketsRepository.findOneBy({ pendingUserId: pendingUser.id });
+ if (ticket) {
+ await this.registrationTicketsRepository.update(ticket.id, {
+ usedBy: account,
+ usedById: account.id,
+ pendingUserId: null,
+ });
+ }
+
return this.signinService.signin(request, reply, account as LocalUser);
} catch (err) {
throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString());
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 94206ef870..41c3a29eec 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -38,7 +38,8 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat
import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js';
import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js';
import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js';
-import * as ep___invite from './endpoints/invite.js';
+import * as ep___admin_invite_create from './endpoints/admin/invite/create.js';
+import * as ep___admin_invite_list from './endpoints/admin/invite/list.js';
import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
@@ -230,6 +231,10 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
+import * as ep___invite_create from './endpoints/invite/create.js';
+import * as ep___invite_delete from './endpoints/invite/delete.js';
+import * as ep___invite_list from './endpoints/invite/list.js';
+import * as ep___invite_limit from './endpoints/invite/limit.js';
import * as ep___meta from './endpoints/meta.js';
import * as ep___emojis from './endpoints/emojis.js';
import * as ep___emoji from './endpoints/emoji.js';
@@ -376,7 +381,8 @@ const eps = [
['admin/get-index-stats', ep___admin_getIndexStats],
['admin/get-table-stats', ep___admin_getTableStats],
['admin/get-user-ips', ep___admin_getUserIps],
- ['invite', ep___invite],
+ ['admin/invite/create', ep___admin_invite_create],
+ ['admin/invite/list', ep___admin_invite_list],
['admin/promo/create', ep___admin_promo_create],
['admin/queue/clear', ep___admin_queue_clear],
['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed],
@@ -568,6 +574,10 @@ const eps = [
['i/webhooks/show', ep___i_webhooks_show],
['i/webhooks/update', ep___i_webhooks_update],
['i/webhooks/delete', ep___i_webhooks_delete],
+ ['invite/create', ep___invite_create],
+ ['invite/delete', ep___invite_delete],
+ ['invite/list', ep___invite_list],
+ ['invite/limit', ep___invite_limit],
['meta', ep___meta],
['emojis', ep___emojis],
['emoji', ep___emoji],
diff --git a/packages/backend/src/server/api/endpoints/admin/invite/create.ts b/packages/backend/src/server/api/endpoints/admin/invite/create.ts
new file mode 100644
index 0000000000..664b4d819f
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/invite/create.ts
@@ -0,0 +1,80 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
+import { IdService } from '@/core/IdService.js';
+import { DI } from '@/di-symbols.js';
+import { generateInviteCode } from '@/misc/generate-invite-code.js';
+import { ApiError } from '../../../error.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+
+ errors: {
+ invalidDateTime: {
+ message: 'Invalid date-time format',
+ code: 'INVALID_DATE_TIME',
+ id: 'f1380b15-3760-4c6c-a1db-5c3aaf1cbd49',
+ },
+ },
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ code: {
+ type: 'string',
+ optional: false, nullable: false,
+ example: 'GR6S02ERUA5VR',
+ },
+ },
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ count: { type: 'integer', minimum: 1, maximum: 100, default: 1 },
+ expiresAt: { type: 'string', nullable: true },
+ },
+ required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ @Inject(DI.registrationTicketsRepository)
+ private registrationTicketsRepository: RegistrationTicketsRepository,
+
+ private inviteCodeEntityService: InviteCodeEntityService,
+ private idService: IdService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ if (ps.expiresAt && isNaN(Date.parse(ps.expiresAt))) {
+ throw new ApiError(meta.errors.invalidDateTime);
+ }
+
+ const ticketsPromises = [];
+
+ for (let i = 0; i < ps.count; i++) {
+ ticketsPromises.push(this.registrationTicketsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
+ code: generateInviteCode(),
+ }).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0])));
+ }
+
+ const tickets = await Promise.all(ticketsPromises);
+ return await this.inviteCodeEntityService.packMany(tickets, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/invite/list.ts b/packages/backend/src/server/api/endpoints/admin/invite/list.ts
new file mode 100644
index 0000000000..5d7a7f632c
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/invite/list.ts
@@ -0,0 +1,70 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+ tags: ['admin'],
+
+ requireCredential: true,
+ requireModerator: true,
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ offset: { type: 'integer', default: 0 },
+ type: { type: 'string', enum: ['unused', 'used', 'expired', 'all'], default: 'all' },
+ sort: { type: 'string', enum: ['+createdAt', '-createdAt', '+usedAt', '-usedAt'] },
+ },
+ required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ @Inject(DI.registrationTicketsRepository)
+ private registrationTicketsRepository: RegistrationTicketsRepository,
+
+ private inviteCodeEntityService: InviteCodeEntityService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const query = this.registrationTicketsRepository.createQueryBuilder('ticket')
+ .leftJoinAndSelect('ticket.createdBy', 'createdBy')
+ .leftJoinAndSelect('ticket.usedBy', 'usedBy');
+
+ switch (ps.type) {
+ case 'unused': query.andWhere('ticket.usedBy IS NULL'); break;
+ case 'used': query.andWhere('ticket.usedBy IS NOT NULL'); break;
+ case 'expired': query.andWhere('ticket.expiresAt < :now', { now: new Date() }); break;
+ }
+
+ switch (ps.sort) {
+ case '+createdAt': query.orderBy('ticket.createdAt', 'DESC'); break;
+ case '-createdAt': query.orderBy('ticket.createdAt', 'ASC'); break;
+ case '+usedAt': query.orderBy('ticket.usedAt', 'DESC', 'NULLS LAST'); break;
+ case '-usedAt': query.orderBy('ticket.usedAt', 'ASC', 'NULLS FIRST'); break;
+ default: query.orderBy('ticket.id', 'DESC'); break;
+ }
+
+ query.limit(ps.limit);
+ query.skip(ps.offset);
+
+ const tickets = await query.getMany();
+
+ return await this.inviteCodeEntityService.packMany(tickets, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/invite/create.ts b/packages/backend/src/server/api/endpoints/invite/create.ts
new file mode 100644
index 0000000000..a64184be10
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/invite/create.ts
@@ -0,0 +1,82 @@
+import { MoreThan } from 'typeorm';
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
+import { IdService } from '@/core/IdService.js';
+import { RoleService } from '@/core/RoleService.js';
+import { DI } from '@/di-symbols.js';
+import { generateInviteCode } from '@/misc/generate-invite-code.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ tags: ['meta'],
+
+ requireCredential: true,
+ requireRolePolicy: 'canInvite',
+
+ errors: {
+ exceededCreateLimit: {
+ message: 'You have exceeded the limit for creating an invitation code.',
+ code: 'EXCEEDED_LIMIT_OF_CREATE_INVITE_CODE',
+ id: '8b165dd3-6f37-4557-8db1-73175d63c641',
+ },
+ },
+
+ res: {
+ type: 'object',
+ optional: false, nullable: false,
+ properties: {
+ code: {
+ type: 'string',
+ optional: false, nullable: false,
+ example: 'GR6S02ERUA5VR',
+ },
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {},
+ required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ @Inject(DI.registrationTicketsRepository)
+ private registrationTicketsRepository: RegistrationTicketsRepository,
+
+ private inviteCodeEntityService: InviteCodeEntityService,
+ private idService: IdService,
+ private roleService: RoleService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const policies = await this.roleService.getUserPolicies(me.id);
+
+ if (policies.inviteLimit) {
+ const count = await this.registrationTicketsRepository.countBy({
+ createdAt: MoreThan(new Date(Date.now() - (policies.inviteLimitCycle * 1000 * 60))),
+ createdById: me.id,
+ });
+
+ if (count >= policies.inviteLimit) {
+ throw new ApiError(meta.errors.exceededCreateLimit);
+ }
+ }
+
+ const ticket = await this.registrationTicketsRepository.insert({
+ id: this.idService.genId(),
+ createdAt: new Date(),
+ createdBy: me,
+ createdById: me.id,
+ expiresAt: policies.inviteExpirationTime ? new Date(Date.now() + (policies.inviteExpirationTime * 1000 * 60)) : null,
+ code: generateInviteCode(),
+ }).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0]));
+
+ return await this.inviteCodeEntityService.pack(ticket, me);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/invite/delete.ts b/packages/backend/src/server/api/endpoints/invite/delete.ts
new file mode 100644
index 0000000000..afca44954d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/invite/delete.ts
@@ -0,0 +1,71 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { RoleService } from '@/core/RoleService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ tags: ['meta'],
+
+ requireCredential: true,
+ requireRolePolicy: 'canInvite',
+
+ errors: {
+ noSuchCode: {
+ message: 'No such invite code.',
+ code: 'NO_SUCH_INVITE_CODE',
+ id: 'cd4f9ae4-7854-4e3e-8df9-c296f051e634',
+ },
+
+ cantDelete: {
+ message: 'You can\'t delete this invite code.',
+ code: 'CAN_NOT_DELETE_INVITE_CODE',
+ id: 'ff17af39-000c-4d4e-abdf-848fa30fc1ce',
+ },
+
+ accessDenied: {
+ message: 'Access denied.',
+ code: 'ACCESS_DENIED',
+ id: '5eb8d909-2540-4970-90b8-dd6f86088121',
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ inviteId: { type: 'string', format: 'misskey:id' },
+ },
+ required: ['inviteId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ @Inject(DI.registrationTicketsRepository)
+ private registrationTicketsRepository: RegistrationTicketsRepository,
+
+ private roleService: RoleService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const ticket = await this.registrationTicketsRepository.findOneBy({ id: ps.inviteId });
+ const isModerator = await this.roleService.isModerator(me);
+
+ if (ticket == null) {
+ throw new ApiError(meta.errors.noSuchCode);
+ }
+
+ if (ticket.createdById !== me.id && !isModerator) {
+ throw new ApiError(meta.errors.accessDenied);
+ }
+
+ if (ticket.usedAt && !isModerator) {
+ throw new ApiError(meta.errors.cantDelete);
+ }
+
+ await this.registrationTicketsRepository.delete(ticket.id);
+ });
+ }
+}
diff --git a/packages/backend/src/server/api/endpoints/invite.ts b/packages/backend/src/server/api/endpoints/invite/limit.ts
index 276adcb07f..9a213b7b25 100644
--- a/packages/backend/src/server/api/endpoints/invite.ts
+++ b/packages/backend/src/server/api/endpoints/invite/limit.ts
@@ -1,9 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
+import { MoreThan } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
-import { IdService } from '@/core/IdService.js';
+import { RoleService } from '@/core/RoleService.js';
import { DI } from '@/di-symbols.js';
-import { secureRndstr } from '@/misc/secure-rndstr.js';
export const meta = {
tags: ['meta'],
@@ -15,12 +15,9 @@ export const meta = {
type: 'object',
optional: false, nullable: false,
properties: {
- code: {
- type: 'string',
- optional: false, nullable: false,
- example: '2ERUA5VR',
- maxLength: 8,
- minLength: 8,
+ remaining: {
+ type: 'integer',
+ optional: false, nullable: true,
},
},
},
@@ -39,21 +36,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
- private idService: IdService,
+ private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
- const code = secureRndstr(8, {
- chars: '23456789ABCDEFGHJKLMNPQRSTUVWXYZ', // [0-9A-Z] w/o [01IO] (32 patterns)
- });
+ const policies = await this.roleService.getUserPolicies(me.id);
- await this.registrationTicketsRepository.insert({
- id: this.idService.genId(),
- createdAt: new Date(),
- code,
- });
+ const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({
+ createdAt: MoreThan(new Date(Date.now() - (policies.inviteExpirationTime * 60 * 1000))),
+ createdById: me.id,
+ }) : null;
return {
- code,
+ remaining: count !== null ? Math.max(0, policies.inviteLimit - count) : null,
};
});
}
diff --git a/packages/backend/src/server/api/endpoints/invite/list.ts b/packages/backend/src/server/api/endpoints/invite/list.ts
new file mode 100644
index 0000000000..e047790261
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/invite/list.ts
@@ -0,0 +1,58 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
+import { QueryService } from '@/core/QueryService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+ tags: ['meta'],
+
+ requireCredential: true,
+ requireRolePolicy: 'canInvite',
+
+ res: {
+ type: 'array',
+ optional: false, nullable: false,
+ items: {
+ type: 'object',
+ optional: false, nullable: false,
+ },
+ },
+} as const;
+
+export const paramDef = {
+ type: 'object',
+ properties: {
+ limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+ sinceId: { type: 'string', format: 'misskey:id' },
+ untilId: { type: 'string', format: 'misskey:id' },
+ },
+ required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+ constructor(
+ @Inject(DI.registrationTicketsRepository)
+ private registrationTicketsRepository: RegistrationTicketsRepository,
+
+ private inviteCodeEntityService: InviteCodeEntityService,
+ private queryService: QueryService,
+ ) {
+ super(meta, paramDef, async (ps, me) => {
+ const query = this.queryService.makePaginationQuery(this.registrationTicketsRepository.createQueryBuilder('ticket'), ps.sinceId, ps.untilId)
+ .andWhere('ticket.createdById = :meId', { meId: me.id })
+ .leftJoinAndSelect('ticket.createdBy', 'createdBy')
+ .leftJoinAndSelect('ticket.usedBy', 'usedBy');
+
+ const tickets = await query
+ .limit(ps.limit)
+ .getMany();
+
+ return await this.inviteCodeEntityService.packMany(tickets, me);
+ });
+ }
+}