diff --git a/CHANGELOG.md b/CHANGELOG.md index e3dfd4cd175..f4aaa29fa3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ ## Unreleased ### General -- +- Feat: お知らせに自動アーカイブ日時を設定できるように ### Client - Enhance: 画像ビューワーで、ピクセルアートの拡大表示に適したモードを追加(画像ビューワー起動時に画面上の詳細メニューから有効化できます) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a492078400e..50b31353f7d 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1654,6 +1654,9 @@ _announcement: forExistingUsersDescription: "有効にすると、このお知らせ作成時点で存在するユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。" needConfirmationToRead: "既読にするのに確認が必要" needConfirmationToReadDescription: "有効にすると、このお知らせを既読にする際に確認ダイアログが表示されます。また、一括既読操作の対象になりません。" + autoArchiveAt: "自動アーカイブ日時" + autoArchiveAtDescription: "指定した日時を過ぎると、このお知らせは自動的にアーカイブされます。空欄の場合は自動的にアーカイブされません。" + autoArchiveAtMustBeInFuture: "自動アーカイブ日時には現在より後の日時を指定してください。" end: "お知らせを終了" tooManyActiveAnnouncementDescription: "アクティブなお知らせが多いため、UXが低下する可能性があります。終了したお知らせはアーカイブすることを検討してください。" readConfirmTitle: "既読にしますか?" diff --git a/packages/backend/migration/1783913268595-AutoArchiveAnnouncements.js b/packages/backend/migration/1783913268595-AutoArchiveAnnouncements.js new file mode 100644 index 00000000000..352e6fbb9da --- /dev/null +++ b/packages/backend/migration/1783913268595-AutoArchiveAnnouncements.js @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AutoArchiveAnnouncements1783913268595 { + name = 'AutoArchiveAnnouncements1783913268595'; + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD "autoArchiveAt" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query(`COMMENT ON COLUMN "announcement"."autoArchiveAt" IS 'The date after which the Announcement is automatically archived.'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "announcement"."autoArchiveAt" IS 'The date after which the Announcement is automatically archived.'`); + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "autoArchiveAt"`); + } +} diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index b97383b9940..0f84e5f5aff 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -3,8 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { Inject, Injectable } from '@nestjs/common'; -import { Brackets, EntityNotFoundError } from 'typeorm'; +import { Inject, Injectable, type OnModuleInit } from '@nestjs/common'; +import { Brackets, EntityNotFoundError, IsNull, Not } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js'; @@ -14,9 +14,10 @@ import { IdService } from '@/core/IdService.js'; import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QueueService } from '@/core/QueueService.js'; @Injectable() -export class AnnouncementService { +export class AnnouncementService implements OnModuleInit { constructor( @Inject(DI.announcementsRepository) private announcementsRepository: AnnouncementsRepository, @@ -31,9 +32,23 @@ export class AnnouncementService { private globalEventService: GlobalEventService, private moderationLogService: ModerationLogService, private announcementEntityService: AnnouncementEntityService, + private queueService: QueueService, ) { } + @bindThis + public async onModuleInit(): Promise { + // アップデート前に作成されたお知らせやRedisの再構築後にも予約を復元する + const announcements = await this.announcementsRepository.findBy({ + isActive: true, + autoArchiveAt: Not(IsNull()), + }); + + await Promise.all(announcements.map(announcement => + this.queueService.scheduleAnnouncementArchive(announcement.id, announcement.autoArchiveAt!), + )); + } + @bindThis public async getReads(userId: MiUser['id']): Promise { return this.announcementReadsRepository.findBy({ @@ -43,12 +58,17 @@ export class AnnouncementService { @bindThis public async getUnreadAnnouncements(user: MiUser): Promise { + const now = new Date(); const readsQuery = this.announcementReadsRepository.createQueryBuilder('read') .select('read.announcementId') .where('read.userId = :userId', { userId: user.id }); const q = this.announcementsRepository.createQueryBuilder('announcement') .where('announcement.isActive = true') + .andWhere(new Brackets(qb => { + qb.where('announcement.autoArchiveAt IS NULL'); + qb.orWhere('announcement.autoArchiveAt > :now', { now }); + })) .andWhere('announcement.silence = false') .andWhere(new Brackets(qb => { qb.orWhere('announcement.userId = :userId', { userId: user.id }); @@ -79,14 +99,23 @@ export class AnnouncementService { silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, userId: values.userId, + autoArchiveAt: values.autoArchiveAt ?? null, + isActive: values.isActive ?? (values.autoArchiveAt == null || values.autoArchiveAt > new Date()), }); const packed = await this.announcementEntityService.pack(announcement); + if (announcement.isActive && announcement.autoArchiveAt != null) { + await this.queueService.scheduleAnnouncementArchive(announcement.id, announcement.autoArchiveAt); + } + + // 作成処理中にautoArchiveAtを過ぎる可能性があるため、insert完了時点で非アクティブなお知らせはイベント配信しない if (values.userId) { - this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { - announcement: packed, - }); + if (announcement.isActive) { + this.globalEventService.publishMainStream(values.userId, 'announcementCreated', { + announcement: packed, + }); + } if (moderator) { const user = await this.usersRepository.findOneByOrFail({ id: values.userId }); @@ -99,9 +128,11 @@ export class AnnouncementService { }); } } else { - this.globalEventService.publishBroadcastStream('announcementCreated', { - announcement: packed, - }); + if (announcement.isActive) { + this.globalEventService.publishBroadcastStream('announcementCreated', { + announcement: packed, + }); + } if (moderator) { this.moderationLogService.log(moderator, 'createGlobalAnnouncement', { @@ -131,10 +162,21 @@ export class AnnouncementService { silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, isActive: values.isActive, + autoArchiveAt: values.autoArchiveAt, }); const after = await this.announcementsRepository.findOneByOrFail({ id: announcement.id }); + if (announcement.autoArchiveAt?.getTime() !== after.autoArchiveAt?.getTime() || announcement.isActive !== after.isActive) { + if (announcement.autoArchiveAt != null) { + await this.queueService.clearAnnouncementArchive(announcement.id, announcement.autoArchiveAt); + } + + if (after.isActive && after.autoArchiveAt != null) { + await this.queueService.scheduleAnnouncementArchive(after.id, after.autoArchiveAt); + } + } + if (moderator) { if (announcement.userId) { const user = await this.usersRepository.findOneByOrFail({ id: announcement.userId }); @@ -156,10 +198,34 @@ export class AnnouncementService { } } + @bindThis + public async archiveAnnouncement(announcementId: MiAnnouncement['id'], autoArchiveAt: Date): Promise { + const now = new Date(); + + // 日時も照合し、日時変更前に予約された古いジョブがお知らせをアーカイブしないようにする + const result = await this.announcementsRepository.createQueryBuilder() + .update() + .set({ + isActive: false, + updatedAt: now, + }) + .where('id = :announcementId', { announcementId }) + .andWhere('isActive = true') + .andWhere('autoArchiveAt = :autoArchiveAt', { autoArchiveAt }) + .andWhere('autoArchiveAt <= :now', { now }) + .execute(); + + return (result.affected ?? 0) > 0; + } + @bindThis public async delete(announcement: MiAnnouncement, moderator?: MiUser): Promise { await this.announcementsRepository.delete(announcement.id); + if (announcement.autoArchiveAt != null) { + await this.queueService.clearAnnouncementArchive(announcement.id, announcement.autoArchiveAt); + } + if (moderator) { if (announcement.userId) { const user = await this.usersRepository.findOneByOrFail({ id: announcement.userId }); diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 2df0ab6edad..a3ce9bb5413 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -8,6 +8,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { MetricsTime, type JobType } from 'bullmq'; import type { IActivity } from '@/core/activitypub/type.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiAnnouncement } from '@/models/Announcement.js'; import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js'; import type { MiSystemWebhook, SystemWebhookEventType } from '@/models/SystemWebhook.js'; import type { Config } from '@/config.js'; @@ -20,6 +21,7 @@ import type { Packed } from '@/misc/json-schema.js'; import { type UserWebhookPayload } from './UserWebhookService.js'; import type { DbJobData, + ArchiveAnnouncementJobData, DeliverJobData, RelationshipJobData, SystemWebhookDeliverJobData, @@ -247,6 +249,44 @@ export class QueueService { }); } + private announcementArchiveJobId(announcementId: MiAnnouncement['id'], autoArchiveAt: Date): string { + return `archiveAnnouncement-${announcementId}-${autoArchiveAt.getTime()}`; + } + + @bindThis + public async scheduleAnnouncementArchive(announcementId: MiAnnouncement['id'], autoArchiveAt: Date): Promise { + const jobId = this.announcementArchiveJobId(announcementId, autoArchiveAt); + const existingJob = await this.systemQueue.getJob(jobId); + if (existingJob != null) { + const state = await existingJob.getState(); + if (state !== 'completed' && state !== 'failed') return; + await this.systemQueue.remove(jobId); + } + + const data: ArchiveAnnouncementJobData = { + announcementId, + autoArchiveAt: autoArchiveAt.getTime(), + }; + + await this.systemQueue.add('archiveAnnouncement', data, { + jobId, + delay: Math.max(0, autoArchiveAt.getTime() - Date.now()), + 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 clearAnnouncementArchive(announcementId: MiAnnouncement['id'], autoArchiveAt: Date): Promise { + await this.systemQueue.remove(this.announcementArchiveJobId(announcementId, autoArchiveAt)); + } + @bindThis public createDeleteDriveFilesJob(user: ThinUser) { return this.dbQueue.add('deleteDriveFiles', { diff --git a/packages/backend/src/models/Announcement.ts b/packages/backend/src/models/Announcement.ts index f664c752627..4155161cfc2 100644 --- a/packages/backend/src/models/Announcement.ts +++ b/packages/backend/src/models/Announcement.ts @@ -60,6 +60,12 @@ export class MiAnnouncement { }) public isActive: boolean; + @Column('timestamp with time zone', { + comment: 'The date after which the Announcement is automatically archived.', + nullable: true, + }) + public autoArchiveAt: Date | null; + @Index() @Column('boolean', { default: false, diff --git a/packages/backend/src/queue/QueueProcessorModule.ts b/packages/backend/src/queue/QueueProcessorModule.ts index e64882c4dfe..00317f3344e 100644 --- a/packages/backend/src/queue/QueueProcessorModule.ts +++ b/packages/backend/src/queue/QueueProcessorModule.ts @@ -15,6 +15,7 @@ import { InboxProcessorService } from './processors/InboxProcessorService.js'; import { UserWebhookDeliverProcessorService } from './processors/UserWebhookDeliverProcessorService.js'; import { SystemWebhookDeliverProcessorService } from './processors/SystemWebhookDeliverProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { ArchiveAnnouncementProcessorService } from './processors/ArchiveAnnouncementProcessorService.js'; import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; @@ -55,6 +56,7 @@ import { RelationshipProcessorService } from './processors/RelationshipProcessor ResyncChartsProcessorService, CleanChartsProcessorService, CheckExpiredMutingsProcessorService, + ArchiveAnnouncementProcessorService, BakeBufferedReactionsProcessorService, CleanProcessorService, DeleteDriveFilesProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index 925a8e3cbb0..e7af23a79d1 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -42,6 +42,8 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { ArchiveAnnouncementProcessorService } from './processors/ArchiveAnnouncementProcessorService.js'; +import type { ArchiveAnnouncementJobData } from './types.js'; import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; @@ -126,6 +128,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private cleanChartsProcessorService: CleanChartsProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, + private archiveAnnouncementProcessorService: ArchiveAnnouncementProcessorService, private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService, private cleanProcessorService: CleanProcessorService, @@ -169,6 +172,7 @@ export class QueueProcessorService implements OnApplicationShutdown { case 'cleanCharts': return this.cleanChartsProcessorService.process(); case 'aggregateRetention': return this.aggregateRetentionProcessorService.process(); case 'checkExpiredMutings': return this.checkExpiredMutingsProcessorService.process(); + case 'archiveAnnouncement': return this.archiveAnnouncementProcessorService.process(job as Bull.Job); case 'bakeBufferedReactions': return this.bakeBufferedReactionsProcessorService.process(); case 'checkModeratorsActivity': return this.checkModeratorsActivityProcessorService.process(); case 'clean': return this.cleanProcessorService.process(); diff --git a/packages/backend/src/queue/processors/ArchiveAnnouncementProcessorService.ts b/packages/backend/src/queue/processors/ArchiveAnnouncementProcessorService.ts new file mode 100644 index 00000000000..e60537425e6 --- /dev/null +++ b/packages/backend/src/queue/processors/ArchiveAnnouncementProcessorService.ts @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import type * as Bull from 'bullmq'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import type { ArchiveAnnouncementJobData } from '../types.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; + +@Injectable() +export class ArchiveAnnouncementProcessorService { + private logger: Logger; + + constructor( + private announcementService: AnnouncementService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('archive-announcement'); + } + + @bindThis + public async process(job: Bull.Job): Promise { + const archived = await this.announcementService.archiveAnnouncement( + job.data.announcementId, + new Date(job.data.autoArchiveAt), + ); + + if (archived) { + this.logger.succ(`Archived announcement ${job.data.announcementId}.`); + } else { + this.logger.debug(`Announcement ${job.data.announcementId} no longer matches the scheduled archive job.`); + } + } +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts index 1cb2b93918e..0b74f18b13c 100644 --- a/packages/backend/src/queue/types.ts +++ b/packages/backend/src/queue/types.ts @@ -5,6 +5,7 @@ import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js'; import type { MiDriveFile } from '@/models/DriveFile.js'; +import type { MiAnnouncement } from '@/models/Announcement.js'; import type { MiNote } from '@/models/Note.js'; import type { SystemWebhookEventType } from '@/models/SystemWebhook.js'; import type { MiUser } from '@/models/User.js'; @@ -113,6 +114,11 @@ export type PostScheduledNoteJobData = { noteDraftId: string; }; +export type ArchiveAnnouncementJobData = { + announcementId: MiAnnouncement['id']; + autoArchiveAt: number; +}; + export type SystemWebhookDeliverJobData = { type: T; content: SystemWebhookPayload; diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts index 74462b302a4..3494faaedae 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -6,6 +6,7 @@ import { Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { ApiError } from '../../../error.js'; export const meta = { tags: ['admin'], @@ -14,6 +15,14 @@ export const meta = { requireModerator: true, kind: 'write:admin:announcements', + errors: { + invalidAutoArchiveAt: { + message: 'Invalid auto archive date.', + code: 'INVALID_AUTO_ARCHIVE_AT', + id: '2a892bd5-487d-46a2-a5fe-3d85ad51defe', + }, + }, + res: { type: 'object', optional: false, nullable: false, @@ -62,6 +71,7 @@ export const paramDef = { silence: { type: 'boolean', default: false }, needConfirmationToRead: { type: 'boolean', default: false }, userId: { type: 'string', format: 'misskey:id', nullable: true, default: null }, + autoArchiveAt: { type: 'integer', nullable: true, default: null }, }, required: ['title', 'text', 'imageUrl'], } as const; @@ -72,6 +82,11 @@ export default class extends Endpoint { // eslint- private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { + const autoArchiveAt = ps.autoArchiveAt != null ? new Date(ps.autoArchiveAt) : null; + if (ps.autoArchiveAt != null && (ps.autoArchiveAt <= Date.now() || Number.isNaN(autoArchiveAt?.getTime()))) { + throw new ApiError(meta.errors.invalidAutoArchiveAt); + } + const { packed } = await this.announcementService.create({ updatedAt: null, title: ps.title, @@ -84,6 +99,7 @@ export default class extends Endpoint { // eslint- silence: ps.silence, needConfirmationToRead: ps.needConfirmationToRead, userId: ps.userId, + autoArchiveAt, }, me); return packed; diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts index aeebceed5a1..5f6dc1c3215 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -4,6 +4,7 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/_.js'; import type { MiAnnouncement } from '@/models/Announcement.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; @@ -63,6 +64,11 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + autoArchiveAt: { + type: 'string', + optional: false, nullable: true, + format: 'date-time', + }, forExistingUsers: { type: 'boolean', optional: false, nullable: false, @@ -119,12 +125,20 @@ export default class extends Endpoint { // eslint- private idService: IdService, ) { super(meta, paramDef, async (ps, me) => { + const now = new Date(); const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate); if (ps.status === 'archived') { - query.andWhere('announcement.isActive = false'); + query.andWhere(new Brackets(qb => { + qb.where('announcement.isActive = false'); + qb.orWhere('announcement.autoArchiveAt <= :now', { now }); + })); } else if (ps.status === 'active') { query.andWhere('announcement.isActive = true'); + query.andWhere(new Brackets(qb => { + qb.where('announcement.autoArchiveAt IS NULL'); + qb.orWhere('announcement.autoArchiveAt > :now', { now }); + })); } if (ps.userId) { @@ -152,7 +166,8 @@ export default class extends Endpoint { // eslint- imageUrl: announcement.imageUrl, icon: announcement.icon, display: announcement.display, - isActive: announcement.isActive, + isActive: announcement.isActive && (announcement.autoArchiveAt == null || announcement.autoArchiveAt > now), + autoArchiveAt: announcement.autoArchiveAt?.toISOString() ?? null, forExistingUsers: announcement.forExistingUsers, silence: announcement.silence, needConfirmationToRead: announcement.needConfirmationToRead, diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts index 6fce6e4e0a4..d9cc6cd97c1 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -23,6 +23,11 @@ export const meta = { code: 'NO_SUCH_ANNOUNCEMENT', id: 'd3aae5a7-6372-4cb4-b61c-f511ffc2d7cc', }, + invalidAutoArchiveAt: { + message: 'Invalid auto archive date.', + code: 'INVALID_AUTO_ARCHIVE_AT', + id: '01b83d7b-2fd5-4d7c-86c4-d03144d16355', + }, }, } as const; @@ -39,6 +44,7 @@ export const paramDef = { silence: { type: 'boolean' }, needConfirmationToRead: { type: 'boolean' }, isActive: { type: 'boolean' }, + autoArchiveAt: { type: 'integer', nullable: true }, }, required: ['id'], } as const; @@ -52,6 +58,11 @@ export default class extends Endpoint { // eslint- private announcementService: AnnouncementService, ) { super(meta, paramDef, async (ps, me) => { + const autoArchiveAt = ps.autoArchiveAt != null ? new Date(ps.autoArchiveAt) : ps.autoArchiveAt; + if (ps.autoArchiveAt != null && (ps.autoArchiveAt < 0 || Number.isNaN(autoArchiveAt?.getTime()))) { + throw new ApiError(meta.errors.invalidAutoArchiveAt); + } + const announcement = await this.announcementsRepository.findOneBy({ id: ps.id }); if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); @@ -68,6 +79,7 @@ export default class extends Endpoint { // eslint- silence: ps.silence, needConfirmationToRead: ps.needConfirmationToRead, isActive: ps.isActive, + autoArchiveAt, }, me); }); } diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index 2ad1702f72a..284e0931023 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -50,13 +50,26 @@ export default class extends Endpoint { // eslint- private announcementEntityService: AnnouncementEntityService, ) { super(meta, paramDef, async (ps, me) => { + const now = new Date(); const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate) - .andWhere('announcement.isActive = :isActive', { isActive: ps.isActive }) .andWhere(new Brackets(qb => { if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id }); qb.orWhere('announcement.userId IS NULL'); })); + if (ps.isActive) { + query.andWhere('announcement.isActive = true'); + query.andWhere(new Brackets(qb => { + qb.where('announcement.autoArchiveAt IS NULL'); + qb.orWhere('announcement.autoArchiveAt > :now', { now }); + })); + } else { + query.andWhere(new Brackets(qb => { + qb.where('announcement.isActive = false'); + qb.orWhere('announcement.autoArchiveAt <= :now', { now }); + })); + } + const announcements = await query.limit(ps.limit).getMany(); return this.announcementEntityService.packMany(announcements, me); diff --git a/packages/backend/test/e2e/announcements.ts b/packages/backend/test/e2e/announcements.ts new file mode 100644 index 00000000000..d9b8592f7d2 --- /dev/null +++ b/packages/backend/test/e2e/announcements.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import * as assert from 'node:assert'; +import { beforeAll, describe, test } from 'vitest'; +import { api, castAsError, signup } from '../utils.js'; +import type * as misskey from 'misskey-js'; + +describe('Announcements', () => { + let admin: misskey.entities.SignupResponse; + + beforeAll(async () => { + admin = await signup({ username: 'admin' }); + }); + + test('自動アーカイブ日時を過ぎるとアクティブ一覧と公開一覧から除外される', async () => { + const create = await api('admin/announcements/create', { + title: 'Scheduled announcement', + text: 'Text', + imageUrl: null, + autoArchiveAt: Date.now() + 60_000, + }, admin); + + assert.strictEqual(create.status, 200); + const id = create.body.id; + + const activeBeforeExpiry = await api('admin/announcements/list', { + status: 'active', + }, admin); + const listedAnnouncement = activeBeforeExpiry.body.find(announcement => announcement.id === id); + assert.ok(listedAnnouncement); + assert.strictEqual(typeof listedAnnouncement.autoArchiveAt, 'string'); + + const clear = await api('admin/announcements/update', { + id, + autoArchiveAt: null, + }, admin); + assert.strictEqual(clear.status, 204); + + const activeAfterClearing = await api('admin/announcements/list', { + status: 'active', + }, admin); + const clearedAnnouncement = activeAfterClearing.body.find(announcement => announcement.id === id); + assert.ok(clearedAnnouncement); + assert.strictEqual(clearedAnnouncement.autoArchiveAt, null); + + const update = await api('admin/announcements/update', { + id, + autoArchiveAt: Date.now() - 1_000, + }, admin); + assert.strictEqual(update.status, 204); + + const activeAfterExpiry = await api('admin/announcements/list', { + status: 'active', + }, admin); + assert.strictEqual(activeAfterExpiry.body.some(announcement => announcement.id === id), false); + + const archived = await api('admin/announcements/list', { + status: 'archived', + }, admin); + const archivedAnnouncement = archived.body.find(announcement => announcement.id === id); + assert.ok(archivedAnnouncement); + assert.strictEqual(archivedAnnouncement.isActive, false); + + const publicAnnouncements = await api('announcements', {}); + assert.strictEqual(publicAnnouncements.status, 200); + assert.strictEqual(publicAnnouncements.body.some(announcement => announcement.id === id), false); + }); + + test('有効範囲外の自動アーカイブ日時はcreateとupdateで拒否される', async () => { + const create = await api('admin/announcements/create', { + title: 'Invalid scheduled announcement', + text: 'Text', + imageUrl: null, + autoArchiveAt: -8_640_000_000_000_000, + }, admin); + + assert.strictEqual(create.status, 400); + assert.strictEqual(castAsError(create.body as any).error.code, 'INVALID_AUTO_ARCHIVE_AT'); + + const expiredCreate = await api('admin/announcements/create', { + title: 'Expired scheduled announcement', + text: 'Text', + imageUrl: null, + autoArchiveAt: Date.now() - 60_000, + }, admin); + + assert.strictEqual(expiredCreate.status, 400); + assert.strictEqual(castAsError(expiredCreate.body as any).error.code, 'INVALID_AUTO_ARCHIVE_AT'); + + const scheduled = await api('admin/announcements/create', { + title: 'Valid scheduled announcement', + text: 'Text', + imageUrl: null, + autoArchiveAt: Date.now() + 60_000, + }, admin); + assert.strictEqual(scheduled.status, 200); + + const update = await api('admin/announcements/update', { + id: scheduled.body.id, + autoArchiveAt: -8_640_000_000_000_000, + }, admin); + assert.strictEqual(update.status, 400); + assert.strictEqual(castAsError(update.body as any).error.code, 'INVALID_AUTO_ARCHIVE_AT'); + }); +}); diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index ea0a1abc175..02c4d9f9274 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -25,6 +25,7 @@ import { CacheService } from '@/core/CacheService.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js'; +import { QueueService } from '@/core/QueueService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import type { TestingModule } from '@nestjs/testing'; @@ -36,6 +37,7 @@ describe('AnnouncementService', () => { let announcementReadsRepository: AnnouncementReadsRepository; let globalEventService: Mocked; let moderationLogService: Mocked; + let queueService: Mocked; function createUser(data: Partial = {}) { const un = secureRndstr(16); @@ -95,6 +97,7 @@ describe('AnnouncementService', () => { announcementReadsRepository = app.get(DI.announcementReadsRepository); globalEventService = app.get(GlobalEventService) as Mocked; moderationLogService = app.get(ModerationLogService) as Mocked; + queueService = app.get(QueueService) as Mocked; }); afterEach(async () => { @@ -132,6 +135,24 @@ describe('AnnouncementService', () => { expect(result.length).toBe(0); }); + test('自動アーカイブ日時を過ぎたお知らせは除外', async () => { + const user = await createUser(); + await Promise.all([ + createAnnouncement({ + title: 'expired', + autoArchiveAt: new Date(Date.now() - 1000), + }), + createAnnouncement({ + title: 'scheduled', + autoArchiveAt: new Date(Date.now() + 1000), + }), + ]); + + const result = await announcementService.getUnreadAnnouncements(user); + + expect(result.map(announcement => announcement.title)).toEqual(['scheduled']); + }); + test('forExistingUsers', async () => { const user = await createUser(); const [announcementAfter, announcementBefore, announcementBefore2] = await Promise.all([ @@ -197,10 +218,94 @@ describe('AnnouncementService', () => { expect((globalEventService.publishMainStream.mock.lastCall![2] as any).announcement).toBe(result.packed); expect(moderationLogService.log).toHaveBeenCalled(); }); + + test('過去の自動アーカイブ日時を指定するとアーカイブ済みで作成される', async () => { + const me = await createUser(); + const result = await announcementService.create({ + title: 'Title', + text: 'Text', + autoArchiveAt: new Date(Date.now() - 1000), + }, me); + + expect(result.raw.isActive).toBe(false); + expect(globalEventService.publishBroadcastStream).not.toHaveBeenCalled(); + expect(queueService.scheduleAnnouncementArchive).not.toHaveBeenCalled(); + }); + + test('自動アーカイブ日時を指定するとアーカイブジョブを予約する', async () => { + const autoArchiveAt = new Date(Date.now() + 60_000); + const result = await announcementService.create({ + title: 'Title', + text: 'Text', + autoArchiveAt, + }); + + expect(queueService.scheduleAnnouncementArchive).toHaveBeenCalledWith(result.raw.id, autoArchiveAt); + }); + }); + + describe('update', () => { + test('自動アーカイブ日時を変更するとジョブを予約し直す', async () => { + const autoArchiveAt = new Date(Date.now() + 60_000); + const updatedAutoArchiveAt = new Date(Date.now() + 120_000); + const announcement = await createAnnouncement({ autoArchiveAt }); + + await announcementService.update(announcement, { autoArchiveAt: updatedAutoArchiveAt }); + + expect(queueService.clearAnnouncementArchive).toHaveBeenCalledWith(announcement.id, autoArchiveAt); + expect(queueService.scheduleAnnouncementArchive).toHaveBeenCalledWith(announcement.id, updatedAutoArchiveAt); + }); + }); + + describe('onModuleInit', () => { + test('既存のアクティブなお知らせのアーカイブジョブを予約する', async () => { + const autoArchiveAt = new Date(Date.now() + 60_000); + const [active] = await Promise.all([ + createAnnouncement({ autoArchiveAt }), + createAnnouncement({ isActive: false, autoArchiveAt }), + ]); + + await announcementService.onModuleInit(); + + expect(queueService.scheduleAnnouncementArchive).toHaveBeenCalledOnce(); + expect(queueService.scheduleAnnouncementArchive).toHaveBeenCalledWith(active.id, autoArchiveAt); + }); + }); + + describe('archiveAnnouncement', () => { + test('ジョブと期限が一致する期限切れのお知らせだけをアーカイブする', async () => { + const autoArchiveAt = new Date(Date.now() - 1000); + const announcement = await createAnnouncement({ autoArchiveAt }); + + expect(await announcementService.archiveAnnouncement(announcement.id, new Date(autoArchiveAt.getTime() - 1000))).toBe(false); + expect((await announcementsRepository.findOneByOrFail({ id: announcement.id })).isActive).toBe(true); + + expect(await announcementService.archiveAnnouncement(announcement.id, autoArchiveAt)).toBe(true); + expect((await announcementsRepository.findOneByOrFail({ id: announcement.id })).isActive).toBe(false); + expect(await announcementService.archiveAnnouncement(announcement.id, autoArchiveAt)).toBe(false); + }); + + test('期限前にはアーカイブしない', async () => { + const autoArchiveAt = new Date(Date.now() + 60_000); + const announcement = await createAnnouncement({ autoArchiveAt }); + + expect(await announcementService.archiveAnnouncement(announcement.id, autoArchiveAt)).toBe(false); + expect((await announcementsRepository.findOneByOrFail({ id: announcement.id })).isActive).toBe(true); + }); + }); + + describe('delete', () => { + test('自動アーカイブジョブを削除する', async () => { + const autoArchiveAt = new Date(Date.now() + 60_000); + const announcement = await createAnnouncement({ autoArchiveAt }); + + await announcementService.delete(announcement); + + expect(queueService.clearAnnouncementArchive).toHaveBeenCalledWith(announcement.id, autoArchiveAt); + }); }); describe.todo('read', () => { // TODO }); }); - diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index 5e16460104b..11f848bfb55 100644 --- a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue +++ b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue @@ -43,6 +43,10 @@ SPDX-License-Identifier: AGPL-3.0-only > + + + + {{ i18n.ts._announcement.needConfirmationToRead }} @@ -60,6 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only + + diff --git a/packages/i18n/src/autogen/locale.ts b/packages/i18n/src/autogen/locale.ts index a314e52a843..202185ce78d 100644 --- a/packages/i18n/src/autogen/locale.ts +++ b/packages/i18n/src/autogen/locale.ts @@ -6528,6 +6528,18 @@ export interface Locale extends ILocale { * 有効にすると、このお知らせを既読にする際に確認ダイアログが表示されます。また、一括既読操作の対象になりません。 */ "needConfirmationToReadDescription": string; + /** + * 自動アーカイブ日時 + */ + "autoArchiveAt": string; + /** + * 指定した日時を過ぎると、このお知らせは自動的にアーカイブされます。空欄の場合は自動的にアーカイブされません。 + */ + "autoArchiveAtDescription": string; + /** + * 自動アーカイブ日時には現在より後の日時を指定してください。 + */ + "autoArchiveAtMustBeInFuture": string; /** * お知らせを終了 */ diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 5d7687652c5..ce7a62496d9 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -6690,6 +6690,8 @@ export interface operations { * @default null */ userId?: string | null; + /** @default null */ + autoArchiveAt?: number | null; }; }; }; @@ -6872,6 +6874,8 @@ export interface operations { /** @enum {string} */ display: 'normal' | 'banner' | 'dialog'; isActive: boolean; + /** Format: date-time */ + autoArchiveAt: string | null; forExistingUsers: boolean; silence: boolean; needConfirmationToRead: boolean; @@ -6945,6 +6949,7 @@ export interface operations { silence?: boolean; needConfirmationToRead?: boolean; isActive?: boolean; + autoArchiveAt?: number | null; }; }; };