From 41bead3284d6266649ef1879288213025a7ca645 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:15:34 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E3=81=8A=E7=9F=A5=E3=82=89?= =?UTF-8?q?=E3=81=9B=E3=82=92=E7=89=B9=E5=AE=9A=E6=97=A5=E6=99=82=E3=81=A7?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E3=82=A2=E3=83=BC=E3=82=AB=E3=82=A4=E3=83=96?= =?UTF-8?q?=E3=81=A7=E3=81=8D=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + locales/ja-JP.yml | 2 + ...783898601448-auto-archive-announcements.js | 16 +++ .../backend/src/core/AnnouncementService.ts | 42 ++++++-- packages/backend/src/core/QueueService.ts | 3 + packages/backend/src/models/Announcement.ts | 6 ++ .../backend/src/queue/QueueProcessorModule.ts | 2 + .../src/queue/QueueProcessorService.ts | 3 + ...eckExpiredAnnouncementsProcessorService.ts | 33 ++++++ .../endpoints/admin/announcements/create.ts | 16 +++ .../api/endpoints/admin/announcements/list.ts | 19 +++- .../endpoints/admin/announcements/update.ts | 12 +++ .../src/server/api/endpoints/announcements.ts | 15 ++- packages/backend/test/e2e/announcements.ts | 100 ++++++++++++++++++ .../backend/test/unit/AnnouncementService.ts | 56 +++++++++- .../MkUserAnnouncementEditDialog.vue | 16 ++- .../src/pages/admin/announcements.vue | 54 +++++++--- packages/i18n/src/autogen/locale.ts | 8 ++ packages/misskey-js/src/autogen/types.ts | 5 + 19 files changed, 382 insertions(+), 27 deletions(-) create mode 100644 packages/backend/migration/1783898601448-auto-archive-announcements.js create mode 100644 packages/backend/src/queue/processors/CheckExpiredAnnouncementsProcessorService.ts create mode 100644 packages/backend/test/e2e/announcements.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cca052f1024..9e54ef88127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Feat: コントロールパネルから二要素認証を解除できるように - Feat: 条件に一致したURLプレビューのサムネイルを隠すことができるように (Based on https://github.com/MisskeyIO/misskey/pull/214) +- Feat: お知らせに自動アーカイブ日時を設定できるように ### Client - 2025.4.0 以前の設定情報の移行処理が削除されました diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 2f6c9120441..6bbc1fc9dfc 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1653,6 +1653,8 @@ _announcement: forExistingUsersDescription: "有効にすると、このお知らせ作成時点で存在するユーザーにのみお知らせが表示されます。無効にすると、このお知らせ作成後にアカウントを作成したユーザーにもお知らせが表示されます。" needConfirmationToRead: "既読にするのに確認が必要" needConfirmationToReadDescription: "有効にすると、このお知らせを既読にする際に確認ダイアログが表示されます。また、一括既読操作の対象になりません。" + autoArchiveAt: "自動アーカイブ日時" + autoArchiveAtDescription: "指定した日時を過ぎると、このお知らせは自動的にアーカイブされます。空欄の場合は自動的にアーカイブされません。" end: "お知らせを終了" tooManyActiveAnnouncementDescription: "アクティブなお知らせが多いため、UXが低下する可能性があります。終了したお知らせはアーカイブすることを検討してください。" readConfirmTitle: "既読にしますか?" diff --git a/packages/backend/migration/1783898601448-auto-archive-announcements.js b/packages/backend/migration/1783898601448-auto-archive-announcements.js new file mode 100644 index 00000000000..392e659a171 --- /dev/null +++ b/packages/backend/migration/1783898601448-auto-archive-announcements.js @@ -0,0 +1,16 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AutoArchiveAnnouncements1783898601448 { + name = 'AutoArchiveAnnouncements1783898601448'; + + async up(queryRunner) { + await queryRunner.query('ALTER TABLE "announcement" ADD "autoArchiveAt" TIMESTAMP WITH TIME ZONE'); + } + + async down(queryRunner) { + 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..f801e848895 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -43,12 +43,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 +84,18 @@ 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 (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 +108,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,6 +142,7 @@ export class AnnouncementService { silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, isActive: values.isActive, + autoArchiveAt: values.autoArchiveAt, }); const after = await this.announcementsRepository.findOneByOrFail({ id: announcement.id }); @@ -156,6 +168,24 @@ export class AnnouncementService { } } + @bindThis + public async archiveExpiredAnnouncements(): Promise { + const now = new Date(); + + const result = await this.announcementsRepository.createQueryBuilder() + .update() + .set({ + isActive: false, + updatedAt: now, + }) + .where('isActive = true') + .andWhere('autoArchiveAt IS NOT NULL') + .andWhere('autoArchiveAt <= :now', { now }) + .execute(); + + return result.affected ?? 0; + } + @bindThis public async delete(announcement: MiAnnouncement, moderator?: MiUser): Promise { await this.announcementsRepository.delete(announcement.id); diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts index 2df0ab6edad..a5cc87cc586 100644 --- a/packages/backend/src/core/QueueService.ts +++ b/packages/backend/src/core/QueueService.ts @@ -72,6 +72,9 @@ const REPEATABLE_SYSTEM_JOB_DEF = [{ }, { name: 'checkExpiredMutings', pattern: '*/5 * * * *', +}, { + name: 'checkExpiredAnnouncements', + pattern: '* * * * *', }, { name: 'bakeBufferedReactions', pattern: '0 0 * * *', 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..18f5ece5aad 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 { CheckExpiredAnnouncementsProcessorService } from './processors/CheckExpiredAnnouncementsProcessorService.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, + CheckExpiredAnnouncementsProcessorService, BakeBufferedReactionsProcessorService, CleanProcessorService, DeleteDriveFilesProcessorService, diff --git a/packages/backend/src/queue/QueueProcessorService.ts b/packages/backend/src/queue/QueueProcessorService.ts index cd1e752fd5c..c86c8a98aca 100644 --- a/packages/backend/src/queue/QueueProcessorService.ts +++ b/packages/backend/src/queue/QueueProcessorService.ts @@ -41,6 +41,7 @@ import { TickChartsProcessorService } from './processors/TickChartsProcessorServ import { ResyncChartsProcessorService } from './processors/ResyncChartsProcessorService.js'; import { CleanChartsProcessorService } from './processors/CleanChartsProcessorService.js'; import { CheckExpiredMutingsProcessorService } from './processors/CheckExpiredMutingsProcessorService.js'; +import { CheckExpiredAnnouncementsProcessorService } from './processors/CheckExpiredAnnouncementsProcessorService.js'; import { BakeBufferedReactionsProcessorService } from './processors/BakeBufferedReactionsProcessorService.js'; import { CleanProcessorService } from './processors/CleanProcessorService.js'; import { AggregateRetentionProcessorService } from './processors/AggregateRetentionProcessorService.js'; @@ -125,6 +126,7 @@ export class QueueProcessorService implements OnApplicationShutdown { private cleanChartsProcessorService: CleanChartsProcessorService, private aggregateRetentionProcessorService: AggregateRetentionProcessorService, private checkExpiredMutingsProcessorService: CheckExpiredMutingsProcessorService, + private checkExpiredAnnouncementsProcessorService: CheckExpiredAnnouncementsProcessorService, private bakeBufferedReactionsProcessorService: BakeBufferedReactionsProcessorService, private checkModeratorsActivityProcessorService: CheckModeratorsActivityProcessorService, private cleanProcessorService: CleanProcessorService, @@ -167,6 +169,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 'checkExpiredAnnouncements': return this.checkExpiredAnnouncementsProcessorService.process(); 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/CheckExpiredAnnouncementsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredAnnouncementsProcessorService.ts new file mode 100644 index 00000000000..27b2cb09159 --- /dev/null +++ b/packages/backend/src/queue/processors/CheckExpiredAnnouncementsProcessorService.ts @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import type Logger from '@/logger.js'; +import { bindThis } from '@/decorators.js'; +import { AnnouncementService } from '@/core/AnnouncementService.js'; +import { QueueLoggerService } from '../QueueLoggerService.js'; + +@Injectable() +export class CheckExpiredAnnouncementsProcessorService { + private logger: Logger; + + constructor( + private announcementService: AnnouncementService, + private queueLoggerService: QueueLoggerService, + ) { + this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-announcements'); + } + + @bindThis + public async process(): Promise { + const archivedCount = await this.announcementService.archiveExpiredAnnouncements(); + + if (archivedCount > 0) { + this.logger.succ(`Archived ${archivedCount} expired announcements.`); + } else { + this.logger.debug('No expired announcements found.'); + } + } +} 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..b1a859e2fd3 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 < 0 || 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..86c2d3f3818 --- /dev/null +++ b/packages/backend/test/e2e/announcements.ts @@ -0,0 +1,100 @@ +/* + * 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 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..92e737d7a3a 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -132,6 +132,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 +215,46 @@ 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(); + }); + }); + + describe('archiveExpiredAnnouncements', () => { + test('期限切れのアクティブなお知らせだけをアーカイブ', async () => { + const [expired, scheduled, inactive] = await Promise.all([ + createAnnouncement({ + autoArchiveAt: new Date(Date.now() - 1000), + }), + createAnnouncement({ + autoArchiveAt: new Date(Date.now() + 1000), + }), + createAnnouncement({ + isActive: false, + autoArchiveAt: new Date(Date.now() - 1000), + }), + ]); + + const archivedCount = await announcementService.archiveExpiredAnnouncements(); + + expect(archivedCount).toBe(1); + expect((await announcementsRepository.findOneByOrFail({ id: expired.id })).isActive).toBe(false); + expect((await announcementsRepository.findOneByOrFail({ id: scheduled.id })).isActive).toBe(true); + expect((await announcementsRepository.findOneByOrFail({ id: inactive.id })).isActive).toBe(false); + expect(await announcementService.archiveExpiredAnnouncements()).toBe(0); + }); }); describe.todo('read', () => { // TODO }); }); - diff --git a/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue b/packages/frontend/src/components/MkUserAnnouncementEditDialog.vue index 5e16460104b..fc27b83a64e 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 }} @@ -69,16 +73,18 @@ import { i18n } from '@/i18n.js'; import MkTextarea from '@/components/MkTextarea.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; +import { formatDateTimeString } from '@/utility/format-time-string.js'; -type AdminAnnouncementType = Misskey.entities.AdminAnnouncementsCreateRequest & { id: string; }; +type AdminAnnouncementType = Misskey.entities.AdminAnnouncementsListResponse[number]; +type AdminAnnouncementUpdate = Misskey.entities.AdminAnnouncementsCreateRequest & { id: string; }; const props = defineProps<{ user: Misskey.entities.User, - announcement?: Required, + announcement?: AdminAnnouncementType, }>(); const emit = defineEmits<{ - (ev: 'done', v: { deleted?: boolean; updated?: AdminAnnouncementType; created?: AdminAnnouncementType; }): void, + (ev: 'done', v: { deleted?: boolean; updated?: AdminAnnouncementUpdate; created?: Misskey.entities.AdminAnnouncementsCreateResponse; }): void, (ev: 'closed'): void }>(); @@ -88,6 +94,7 @@ const text = ref(props.announcement ? props.announcement.text : ''); const icon = ref(props.announcement ? props.announcement.icon : 'info'); const display = ref(props.announcement ? props.announcement.display : 'dialog'); const needConfirmationToRead = ref(props.announcement ? props.announcement.needConfirmationToRead : false); +const autoArchiveAt = ref(props.announcement?.autoArchiveAt ? formatDateTimeString(new Date(props.announcement.autoArchiveAt), 'yyyy-MM-ddTHH:mm') : ''); async function done() { const params = { @@ -98,6 +105,7 @@ async function done() { display: display.value, needConfirmationToRead: needConfirmationToRead.value, userId: props.user.id, + autoArchiveAt: autoArchiveAt.value === '' ? null : new Date(autoArchiveAt.value).getTime(), } satisfies Misskey.entities.AdminAnnouncementsCreateRequest; if (props.announcement) { @@ -118,7 +126,7 @@ async function done() { const created = await os.apiWithDialog('admin/announcements/create', params); emit('done', { - created: created, + created, }); dialog.value?.close(); diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index 87fc6e70f4a..b6d29f81563 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -45,6 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + Date: Mon, 13 Jul 2026 13:05:44 +0900 Subject: [PATCH 2/5] regenerate --- ...1783898601448-auto-archive-announcements.js | 16 ---------------- .../1783913268595-AutoArchiveAnnouncements.js | 18 ++++++++++++++++++ 2 files changed, 18 insertions(+), 16 deletions(-) delete mode 100644 packages/backend/migration/1783898601448-auto-archive-announcements.js create mode 100644 packages/backend/migration/1783913268595-AutoArchiveAnnouncements.js diff --git a/packages/backend/migration/1783898601448-auto-archive-announcements.js b/packages/backend/migration/1783898601448-auto-archive-announcements.js deleted file mode 100644 index 392e659a171..00000000000 --- a/packages/backend/migration/1783898601448-auto-archive-announcements.js +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SPDX-FileCopyrightText: syuilo and misskey-project - * SPDX-License-Identifier: AGPL-3.0-only - */ - -export class AutoArchiveAnnouncements1783898601448 { - name = 'AutoArchiveAnnouncements1783898601448'; - - async up(queryRunner) { - await queryRunner.query('ALTER TABLE "announcement" ADD "autoArchiveAt" TIMESTAMP WITH TIME ZONE'); - } - - async down(queryRunner) { - await queryRunner.query('ALTER TABLE "announcement" DROP COLUMN "autoArchiveAt"'); - } -} 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"`); + } +} From cb9eddae9fe1ccfcf1e0b527bff8c6e80b96d570 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:16:31 +0900 Subject: [PATCH 3/5] fix ui --- .../src/pages/admin/announcements.vue | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index b6d29f81563..c13fd1cbd03 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -25,7 +25,14 @@ SPDX-License-Identifier: AGPL-3.0-only - + +