Allow restricting announcement to a set of roles. Fix MkRoleSelectDialog only respecting publicOnly half the time
Closes #682
This commit is contained in:
parent
5dd32123a3
commit
f3b5c3f447
12 changed files with 96 additions and 1 deletions
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof meta, typeof paramDef> { // eslint-
|
|||
icon: ps.icon,
|
||||
display: ps.display,
|
||||
forExistingUsers: ps.forExistingUsers,
|
||||
forRoles: ps.forRoles,
|
||||
silence: ps.silence,
|
||||
needConfirmationToRead: ps.needConfirmationToRead,
|
||||
confetti: ps.confetti,
|
||||
|
|
|
|||
|
|
@ -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<typeof meta, typeof paramDef> { // eslint-
|
|||
display: announcement.display,
|
||||
isActive: announcement.isActive,
|
||||
forExistingUsers: announcement.forExistingUsers,
|
||||
forRoles: announcement.forRoles,
|
||||
silence: announcement.silence,
|
||||
needConfirmationToRead: announcement.needConfirmationToRead,
|
||||
confetti: announcement.confetti,
|
||||
|
|
|
|||
|
|
@ -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<typeof meta, typeof paramDef> { // eslint-
|
|||
display: ps.display,
|
||||
icon: ps.icon,
|
||||
forExistingUsers: ps.forExistingUsers,
|
||||
forRoles: ps.forRoles,
|
||||
silence: ps.silence,
|
||||
needConfirmationToRead: ps.needConfirmationToRead,
|
||||
confetti: ps.confetti,
|
||||
|
|
|
|||
|
|
@ -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<typeof meta, typeof paramDef> { // 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();
|
||||
|
|
|
|||
|
|
@ -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<GlobalEventService>;
|
||||
let moderationLogService: jest.Mocked<ModerationLogService>;
|
||||
let roleService: jest.Mocked<RoleService>;
|
||||
|
||||
function createUser(data: Partial<MiUser> = {}) {
|
||||
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<AnnouncementReadsRepository>(DI.announcementReadsRepository);
|
||||
globalEventService = app.get<GlobalEventService>(GlobalEventService) as jest.Mocked<GlobalEventService>;
|
||||
moderationLogService = app.get<ModerationLogService>(ModerationLogService) as jest.Mocked<ModerationLogService>;
|
||||
roleService = app.get<RoleService>(RoleService) as jest.Mocked<RoleService>;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
{{ i18n.ts._announcement.confetti }}
|
||||
</MkSwitch>
|
||||
</MkDisableSection>
|
||||
<div :class="$style.forRoles">
|
||||
<MkInfo v-if="announcement.forRoles" :class="$style.forRolesLabel">{{ i18n.tsx._announcement.onlyForRolesRestricted({roles: announcement.forRoles.length}) }}</MkInfo>
|
||||
<MkInfo v-else :class="$style.forRolesLabel">{{ i18n.ts._announcement.onlyForRolesUnrestricted }}</MkInfo>
|
||||
<MkButton primary @click="() => changeRoles(announcement)">
|
||||
{{ i18n.ts._announcement.onlyForRolesChange }}
|
||||
</MkButton>
|
||||
</div>
|
||||
<p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
|
@ -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',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.forRoles {
|
||||
display: flex;
|
||||
}
|
||||
.forRolesLabel {
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue