From d1446d195abb52c560c7c97177d08103a175acf7 Mon Sep 17 00:00:00 2001 From: syuilo <4439005+syuilo@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:29:52 +0900 Subject: feat: scheduled post (#16577) * Update NoteDraft.ts * Update NoteDraft.ts * wip * Update CHANGELOG.md * wip * Update PostScheduledNoteProcessorService.ts * Update PostScheduledNoteProcessorService.ts * Update Notification.ts * wip * Update NoteDraftService.ts * Update NoteDraftService.ts * Update NoteDraftService.ts * wip * Create 1758677617888-scheduled-post.js * Update index.d.ts * Update stats.ts * wip * wip * wip * wip * wip * Update MkNotification.vue * wip * wip * wip * Update NoteDraftService.ts * Update NoteDraftService.ts * wip * wip * Update NoteDraftEntityService.ts * wip * Update index.d.ts * Update MkPostForm.vue * wip * wip * wip * Update NoteCreateService.ts * wip * wip * wip * Update NoteDraftEntityService.ts * Update NoteCreateService.ts * Update NoteDraftService.ts * wip * Update NoteDraftService.ts * wip * wip * Update MkPostForm.vue * wip * Update MkPostForm.vue * Update os.ts * wip * Update MkNoteDraftsDialog.vue --- packages/backend/src/core/NoteDraftService.ts | 168 ++++++++++++++------------ 1 file changed, 92 insertions(+), 76 deletions(-) (limited to 'packages/backend/src/core/NoteDraftService.ts') diff --git a/packages/backend/src/core/NoteDraftService.ts b/packages/backend/src/core/NoteDraftService.ts index c43be96efa..7666407c1e 100644 --- a/packages/backend/src/core/NoteDraftService.ts +++ b/packages/backend/src/core/NoteDraftService.ts @@ -5,32 +5,18 @@ import { Inject, Injectable } from '@nestjs/common'; import { In } from 'typeorm'; -import type { noteVisibilities, noteReactionAcceptances } from '@/types.js'; import { DI } from '@/di-symbols.js'; import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { IdService } from '@/core/IdService.js'; import type { MiLocalUser, MiUser } from '@/models/User.js'; -import { IPoll } from '@/models/Poll.js'; import { IdentifiableError } from '@/misc/identifiable-error.js'; import { isRenote, isQuote } from '@/misc/is-renote.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { QueueService } from '@/core/QueueService.js'; -export type NoteDraftOptions = { - replyId?: MiNote['id'] | null; - renoteId?: MiNote['id'] | null; - text?: string | null; - cw?: string | null; - localOnly?: boolean | null; - reactionAcceptance?: typeof noteReactionAcceptances[number]; - visibility?: typeof noteVisibilities[number]; - fileIds?: MiDriveFile['id'][]; - visibleUserIds?: MiUser['id'][]; - hashtag?: string; - channelId?: MiChannel['id'] | null; - poll?: (IPoll & { expiredAfter?: number | null }) | null; -}; +export type NoteDraftOptions = Omit; @Injectable() export class NoteDraftService { @@ -56,6 +42,7 @@ export class NoteDraftService { private roleService: RoleService, private idService: IdService, private noteEntityService: NoteEntityService, + private queueService: QueueService, ) { } @@ -72,36 +59,43 @@ export class NoteDraftService { @bindThis public async create(me: MiLocalUser, data: NoteDraftOptions): Promise { //#region check draft limit + const policies = await this.roleService.getUserPolicies(me.id); const currentCount = await this.noteDraftsRepository.countBy({ userId: me.id, }); - if (currentCount >= (await this.roleService.getUserPolicies(me.id)).noteDraftLimit) { + if (currentCount >= policies.noteDraftLimit) { throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts'); } - //#endregion - if (data.poll) { - if (typeof data.poll.expiresAt === 'number') { - if (data.poll.expiresAt < Date.now()) { - throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); - } - } else if (typeof data.poll.expiredAfter === 'number') { - data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter); + if (data.isActuallyScheduled) { + const currentScheduledCount = await this.noteDraftsRepository.countBy({ + userId: me.id, + isActuallyScheduled: true, + }); + if (currentScheduledCount >= policies.scheduledNoteLimit) { + throw new IdentifiableError('c3275f19-4558-4c59-83e1-4f684b5fab66', 'Too many scheduled notes'); } } + //#endregion - const appliedDraft = await this.checkAndSetDraftNoteOptions(me, this.noteDraftsRepository.create(), data); + await this.validate(me, data); - appliedDraft.id = this.idService.gen(); - appliedDraft.userId = me.id; - const draft = this.noteDraftsRepository.save(appliedDraft); + const draft = await this.noteDraftsRepository.insertOne({ + ...data, + id: this.idService.gen(), + userId: me.id, + }); + + if (draft.scheduledAt && draft.isActuallyScheduled) { + this.schedule(draft); + } return draft; } @bindThis - public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: NoteDraftOptions): Promise { + public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: Partial): Promise { const draft = await this.noteDraftsRepository.findOneBy({ id: draftId, userId: me.id, @@ -111,19 +105,36 @@ export class NoteDraftService { throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft'); } - if (data.poll) { - if (typeof data.poll.expiresAt === 'number') { - if (data.poll.expiresAt < Date.now()) { - throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); - } - } else if (typeof data.poll.expiredAfter === 'number') { - data.poll.expiresAt = new Date(Date.now() + data.poll.expiredAfter); + //#region check draft limit + const policies = await this.roleService.getUserPolicies(me.id); + + if (!draft.isActuallyScheduled && data.isActuallyScheduled) { + const currentScheduledCount = await this.noteDraftsRepository.countBy({ + userId: me.id, + isActuallyScheduled: true, + }); + if (currentScheduledCount >= policies.scheduledNoteLimit) { + throw new IdentifiableError('bacdf856-5c51-4159-b88a-804fa5103be5', 'Too many scheduled notes'); } } + //#endregion + + await this.validate(me, data); - const appliedDraft = await this.checkAndSetDraftNoteOptions(me, draft, data); + const updatedDraft = await this.noteDraftsRepository.createQueryBuilder().update() + .set(data) + .where('id = :id', { id: draftId }) + .returning('*') + .execute() + .then((response) => response.raw[0]); - return await this.noteDraftsRepository.save(appliedDraft); + this.clearSchedule(draftId).then(() => { + if (updatedDraft.scheduledAt != null && updatedDraft.isActuallyScheduled) { + this.schedule(updatedDraft); + } + }); + + return updatedDraft; } @bindThis @@ -138,6 +149,8 @@ export class NoteDraftService { } await this.noteDraftsRepository.delete(draft.id); + + this.clearSchedule(draftId); } @bindThis @@ -154,27 +167,20 @@ export class NoteDraftService { return draft; } - // 関連エンティティを取得し紐づける部分を共通化する @bindThis - public async checkAndSetDraftNoteOptions( + public async validate( me: MiLocalUser, - draft: MiNoteDraft, - data: NoteDraftOptions, - ): Promise { - data.visibility ??= 'public'; - data.localOnly ??= false; - if (data.reactionAcceptance === undefined) data.reactionAcceptance = null; - if (data.channelId != null) { - data.visibility = 'public'; - data.visibleUserIds = []; - data.localOnly = true; + data: Partial, + ): Promise { + if (data.pollExpiresAt != null) { + if (data.pollExpiresAt.getTime() < Date.now()) { + throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll'); + } } - let appliedDraft = draft; - //#region visibleUsers let visibleUsers: MiUser[] = []; - if (data.visibleUserIds != null) { + if (data.visibleUserIds != null && data.visibleUserIds.length > 0) { visibleUsers = await this.usersRepository.findBy({ id: In(data.visibleUserIds), }); @@ -184,7 +190,7 @@ export class NoteDraftService { //#region files let files: MiDriveFile[] = []; const fileIds = data.fileIds ?? null; - if (fileIds != null) { + if (fileIds != null && fileIds.length > 0) { files = await this.driveFilesRepository.createQueryBuilder('file') .where('file.userId = :userId AND file.id IN (:...fileIds)', { userId: me.id, @@ -288,27 +294,37 @@ export class NoteDraftService { } } //#endregion + } - appliedDraft = { - ...appliedDraft, - visibility: data.visibility, - cw: data.cw ?? null, - fileIds: fileIds ?? [], - replyId: data.replyId ?? null, - renoteId: data.renoteId ?? null, - channelId: data.channelId ?? null, - text: data.text ?? null, - hashtag: data.hashtag ?? null, - hasPoll: data.poll != null, - pollChoices: data.poll ? data.poll.choices : [], - pollMultiple: data.poll ? data.poll.multiple : false, - pollExpiresAt: data.poll ? data.poll.expiresAt : null, - pollExpiredAfter: data.poll ? data.poll.expiredAfter ?? null : null, - visibleUserIds: data.visibleUserIds ?? [], - localOnly: data.localOnly, - reactionAcceptance: data.reactionAcceptance, - } satisfies MiNoteDraft; - - return appliedDraft; + @bindThis + public async schedule(draft: MiNoteDraft): Promise { + if (!draft.isActuallyScheduled) return; + if (draft.scheduledAt == null) return; + if (draft.scheduledAt.getTime() <= Date.now()) return; + + const delay = draft.scheduledAt.getTime() - Date.now(); + this.queueService.postScheduledNoteQueue.add(draft.id, { + noteDraftId: draft.id, + }, { + delay, + removeOnComplete: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 30, + }, + removeOnFail: { + age: 3600 * 24 * 7, // keep up to 7 days + count: 100, + }, + }); + } + + @bindThis + public async clearSchedule(draftId: MiNoteDraft['id']): Promise { + const jobs = await this.queueService.postScheduledNoteQueue.getJobs(['delayed', 'waiting', 'active']); + for (const job of jobs) { + if (job.data.noteDraftId === draftId) { + await job.remove(); + } + } } } -- cgit v1.2.3-freya