From f3b5c3f447cd34881035397d3cd370488026c25c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=BD=D0=B0=D0=B1?= Date: Sun, 13 Jul 2025 00:37:17 +0200 Subject: [PATCH] Allow restricting announcement to a set of roles. Fix MkRoleSelectDialog only respecting publicOnly half the time Closes #682 --- .../1752352800438-announcement-forRoles.js | 14 ++++++++++ .../backend/src/core/AnnouncementService.ts | 9 ++++++ packages/backend/src/models/Announcement.ts | 7 +++++ .../endpoints/admin/announcements/create.ts | 2 ++ .../api/endpoints/admin/announcements/list.ts | 10 +++++++ .../endpoints/admin/announcements/update.ts | 2 ++ .../src/server/api/endpoints/announcements.ts | 7 +++++ .../backend/test/unit/AnnouncementService.ts | 7 +++++ .../src/components/MkRoleSelectDialog.vue | 2 +- .../src/pages/admin/announcements.vue | 28 +++++++++++++++++++ packages/misskey-js/src/autogen/types.ts | 5 ++++ sharkey-locales/en-US.yml | 4 +++ 12 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 packages/backend/migration/1752352800438-announcement-forRoles.js diff --git a/packages/backend/migration/1752352800438-announcement-forRoles.js b/packages/backend/migration/1752352800438-announcement-forRoles.js new file mode 100644 index 0000000000..a148c87eed --- /dev/null +++ b/packages/backend/migration/1752352800438-announcement-forRoles.js @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: наб and other Sharkey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AnnouncementForRoles1752352800438 { + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" ADD "forRoles" text[] DEFAULT null`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "forRoles"`); + } +} diff --git a/packages/backend/src/core/AnnouncementService.ts b/packages/backend/src/core/AnnouncementService.ts index 95899c2ccc..643f0c7e05 100644 --- a/packages/backend/src/core/AnnouncementService.ts +++ b/packages/backend/src/core/AnnouncementService.ts @@ -14,6 +14,7 @@ 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 { RoleService } from '@/core/RoleService.js'; @Injectable() export class AnnouncementService { @@ -31,6 +32,7 @@ export class AnnouncementService { private globalEventService: GlobalEventService, private moderationLogService: ModerationLogService, private announcementEntityService: AnnouncementEntityService, + private roleService: RoleService, ) { } @@ -46,6 +48,7 @@ export class AnnouncementService { const readsQuery = this.announcementReadsRepository.createQueryBuilder('read') .select('read.announcementId') .where('read.userId = :userId', { userId: user.id }); + const roles = await this.roleService.getUserRoles(user); const q = this.announcementsRepository.createQueryBuilder('announcement') .where('announcement.isActive = true') @@ -58,6 +61,10 @@ export class AnnouncementService { qb.orWhere('announcement.forExistingUsers = false'); qb.orWhere('announcement.id > :userId', { userId: user.id }); })) + .andWhere(new Brackets(qb => { + qb.orWhere('announcement.forRoles && :roles', { roles: roles.map((r) => r.id) }); + qb.orWhere('announcement.forRoles IS NULL'); + })) .andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`); q.setParameters(readsQuery.getParameters()); @@ -76,6 +83,7 @@ export class AnnouncementService { icon: values.icon, display: values.display, forExistingUsers: values.forExistingUsers, + forRoles: values.forRoles, silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, confetti: values.confetti, @@ -129,6 +137,7 @@ export class AnnouncementService { display: values.display, icon: values.icon, forExistingUsers: values.forExistingUsers, + forRoles: values.forRoles, silence: values.silence, needConfirmationToRead: values.needConfirmationToRead, confetti: values.confetti, diff --git a/packages/backend/src/models/Announcement.ts b/packages/backend/src/models/Announcement.ts index 681d743322..2e0fb66137 100644 --- a/packages/backend/src/models/Announcement.ts +++ b/packages/backend/src/models/Announcement.ts @@ -6,6 +6,7 @@ import { Entity, Index, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; import { id } from './util/id.js'; import { MiUser } from './User.js'; +import { MiRole } from './Role.js'; @Entity('announcement') export class MiAnnouncement { @@ -66,6 +67,12 @@ export class MiAnnouncement { }) public forExistingUsers: boolean; + @Column('text', { + array: true, + default: null, nullable: true, + }) + public forRoles: MiRole['id'][] | null; + @Index() @Column('boolean', { default: false, 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 8da39810e9..c37b54b9c9 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -59,6 +59,7 @@ export const paramDef = { icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' }, display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' }, forExistingUsers: { type: 'boolean', default: false }, + forRoles: { type: 'array', nullable: true, default: null, items: { type: 'string', nullable: false, format: 'misskey:id' }, }, silence: { type: 'boolean', default: false }, needConfirmationToRead: { type: 'boolean', default: false }, confetti: { type: 'boolean', default: false }, @@ -82,6 +83,7 @@ export default class extends Endpoint { // eslint- icon: ps.icon, display: ps.display, forExistingUsers: ps.forExistingUsers, + forRoles: ps.forRoles, silence: ps.silence, needConfirmationToRead: ps.needConfirmationToRead, confetti: ps.confetti, 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 2423807518..9fbd46d7b2 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -57,6 +57,15 @@ export const meta = { type: 'number', optional: false, nullable: false, }, + forRoles: { + type: 'array', + optional: false, nullable: true, + items: { + type: 'string', + optional: false, nullable: false, + format: 'misskey:id' + } + }, }, }, }, @@ -122,6 +131,7 @@ export default class extends Endpoint { // eslint- display: announcement.display, isActive: announcement.isActive, forExistingUsers: announcement.forExistingUsers, + forRoles: announcement.forRoles, silence: announcement.silence, needConfirmationToRead: announcement.needConfirmationToRead, confetti: announcement.confetti, 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 e68a0439c1..4132647969 100644 --- a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -36,6 +36,7 @@ export const paramDef = { icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] }, display: { type: 'string', enum: ['normal', 'banner', 'dialog'] }, forExistingUsers: { type: 'boolean' }, + forRoles: { type: 'array', nullable: true, default: null, items: { type: 'string', nullable: false, format: 'misskey:id' }, }, silence: { type: 'boolean' }, needConfirmationToRead: { type: 'boolean' }, confetti: { type: 'boolean' }, @@ -66,6 +67,7 @@ export default class extends Endpoint { // eslint- display: ps.display, icon: ps.icon, forExistingUsers: ps.forExistingUsers, + forRoles: ps.forRoles, silence: ps.silence, needConfirmationToRead: ps.needConfirmationToRead, confetti: ps.confetti, diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts index b2faf675b0..3ac7173d8c 100644 --- a/packages/backend/src/server/api/endpoints/announcements.ts +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; +import { RoleService } from '@/core/RoleService.js'; import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { DI } from '@/di-symbols.js'; import type { AnnouncementsRepository } from '@/models/_.js'; @@ -51,14 +52,20 @@ export default class extends Endpoint { // eslint- private announcementsRepository: AnnouncementsRepository, private queryService: QueryService, + private roleService: RoleService, private announcementEntityService: AnnouncementEntityService, ) { super(meta, paramDef, async (ps, me) => { + const roles = me ? await this.roleService.getUserRoles(me) : []; const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId) .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'); + })) + .andWhere(new Brackets(qb => { + if (me) qb.orWhere('announcement.forRoles && :roles', { roles: roles.map((r) => r.id) }); + qb.orWhere('announcement.forRoles IS NULL'); })); const announcements = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/test/unit/AnnouncementService.ts b/packages/backend/test/unit/AnnouncementService.ts index 32d7df05bf..ab3b6961c0 100644 --- a/packages/backend/test/unit/AnnouncementService.ts +++ b/packages/backend/test/unit/AnnouncementService.ts @@ -27,6 +27,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 { RoleService } from '@/core/RoleService.js'; import { secureRndstr } from '@/misc/secure-rndstr.js'; import type { TestingModule } from '@nestjs/testing'; import type { MockFunctionMetadata } from 'jest-mock'; @@ -41,6 +42,7 @@ describe('AnnouncementService', () => { let announcementReadsRepository: AnnouncementReadsRepository; let globalEventService: jest.Mocked; let moderationLogService: jest.Mocked; + let roleService: jest.Mocked; function createUser(data: Partial = {}) { const un = secureRndstr(16); @@ -77,6 +79,7 @@ describe('AnnouncementService', () => { InternalEventService, GlobalEventService, ModerationLogService, + RoleService, ], }) .useMocker((token) => { @@ -93,6 +96,9 @@ describe('AnnouncementService', () => { .overrideProvider(ModerationLogService).useValue({ log: jest.fn(), }) + .overrideProvider(RoleService).useValue({ + getUserRoles: jest.fn((_) => []), + }) .overrideProvider(InternalEventService).useClass(FakeInternalEventService) .overrideProvider(CacheService).useClass(NoOpCacheService) .compile(); @@ -105,6 +111,7 @@ describe('AnnouncementService', () => { announcementReadsRepository = app.get(DI.announcementReadsRepository); globalEventService = app.get(GlobalEventService) as jest.Mocked; moderationLogService = app.get(ModerationLogService) as jest.Mocked; + roleService = app.get(RoleService) as jest.Mocked; }); afterEach(async () => { diff --git a/packages/frontend/src/components/MkRoleSelectDialog.vue b/packages/frontend/src/components/MkRoleSelectDialog.vue index 6888824437..4141839fe3 100644 --- a/packages/frontend/src/components/MkRoleSelectDialog.vue +++ b/packages/frontend/src/components/MkRoleSelectDialog.vue @@ -100,7 +100,7 @@ async function fetchRoles() { async function addRole() { const items = roles.value - .filter(r => r.isPublic) + .filter(r => publicOnly.value ? r.isPublic : true) .filter(r => !selectedRoleIds.value.includes(r.id)) .map(r => ({ text: r.name, value: r })); diff --git a/packages/frontend/src/pages/admin/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index 7e32bf4d97..3dd4ee8fb4 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -75,6 +75,13 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._announcement.confetti }} +
+ {{ i18n.tsx._announcement.onlyForRolesRestricted({roles: announcement.forRoles.length}) }} + {{ i18n.ts._announcement.onlyForRolesUnrestricted }} + + {{ i18n.ts._announcement.onlyForRolesChange }} + +

{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}

@@ -134,9 +141,21 @@ function add() { silence: false, needConfirmationToRead: false, confetti: false, + forRoles: null, }); } +async function changeRoles(announcement) { + const result = await os.selectRole({ + initialRoleIds: announcement.forRoles, + title: i18n.ts._announcement.onlyForRoles, + publicOnly: false, + }); + if (result.canceled) return; + + announcement.forRoles = result.result.length !== 0 ? result.result.map((r) => r.id) : null; +} + function del(announcement) { os.confirm({ type: 'warning', @@ -209,3 +228,12 @@ definePage(() => ({ icon: 'ti ti-speakerphone', })); + + diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index c2a886a18c..19cbe30fde 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -6694,6 +6694,8 @@ export type operations = { display?: 'normal' | 'banner' | 'dialog'; /** @default false */ forExistingUsers?: boolean; + /** @default null */ + forRoles?: string[] | null; /** @default false */ silence?: boolean; /** @default false */ @@ -6856,6 +6858,7 @@ export type operations = { title: string; imageUrl: string | null; reads: number; + forRoles: string[] | null; })[]; }; }; @@ -6911,6 +6914,8 @@ export type operations = { /** @enum {string} */ display?: 'normal' | 'banner' | 'dialog'; forExistingUsers?: boolean; + /** @default null */ + forRoles?: string[] | null; silence?: boolean; needConfirmationToRead?: boolean; confetti?: boolean; diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index d8602a1489..4ae753d973 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -479,6 +479,10 @@ _auth: allowed: "Allowed" _announcement: new: "New" + onlyForRoles: "Restrict to roles" + onlyForRolesChange: "Change role restrictions" + onlyForRolesUnrestricted: "Shown to everyone" + onlyForRolesRestricted: "Shown to members of {roles} roles" confetti: "Throw confetti" confettiDescription: "If enabled, the announcement will display a confetti effect when viewed." _deck: