feat: お知らせの確認待機時間・優先順位機能

b7fd6bf33a835fd73c2a86eb007074d3680f6efd によるリワーク

This reverts commit eeef3965b7
This reverts commit 04fefb2056
This reverts commit 576251200f
This commit is contained in:
まっちゃとーにゅ
2023-08-19 04:48:28 +09:00
parent 3c2b83c3ae
commit 6893e5d60b
27 changed files with 832 additions and 264 deletions

View File

@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class Userannouncement1688647797135 {
name = 'Userannouncement1688647797135'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "announcement" ADD COLUMN "userId" character varying(32)`);
await queryRunner.query(`ALTER TABLE "announcement" ADD COLUMN "closeDuration" integer NOT NULL DEFAULT 0`);
await queryRunner.query(`CREATE INDEX "IDX_fd25dfe3da37df1715f11ba6ec" ON "announcement" ("userId") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_fd25dfe3da37df1715f11ba6ec"`);
await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "userId"`);
await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "closeDuration"`);
}
}

View File

@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AnnouncementDisplayOrder1690463372775 {
name = 'AnnouncementDisplayOrder1690463372775'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "announcement" ADD "displayOrder" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`CREATE INDEX "IDX_b64d293ca4bef21e91963054b0" ON "announcement" ("displayOrder") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_b64d293ca4bef21e91963054b0"`);
await queryRunner.query(`ALTER TABLE "announcement" DROP COLUMN "displayOrder"`);
}
}

View File

@@ -4,14 +4,17 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { DI } from '@/di-symbols.js';
import { Brackets, In } from 'typeorm';
import type { AnnouncementReadsRepository, AnnouncementsRepository, UsersRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js';
import type { AnnouncementReadsRepository, AnnouncementsRepository, Announcement, AnnouncementRead } from '@/models/index.js';
import { Announcement, AnnouncementRead } from '@/models/index.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { bindThis } from '@/decorators.js';
import { Packed } from '@/misc/json-schema.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import { Packed } from '@/misc/json-schema.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
@Injectable()
export class AnnouncementService {
@@ -22,63 +25,52 @@ export class AnnouncementService {
@Inject(DI.announcementReadsRepository)
private announcementReadsRepository: AnnouncementReadsRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private idService: IdService,
private userEntityService: UserEntityService,
private announcementEntityService: AnnouncementEntityService,
private globalEventService: GlobalEventService,
) {
}
) {}
@bindThis
public async getReads(userId: User['id']): Promise<AnnouncementRead[]> {
return this.announcementReadsRepository.findBy({
userId: userId,
});
}
public async create(
values: Partial<Announcement>,
): Promise<{ raw: Announcement; packed: Packed<'Announcement'> }> {
const announcement = await this.announcementsRepository
.insert({
id: this.idService.genId(),
createdAt: new Date(),
updatedAt: null,
title: values.title,
text: values.text,
imageUrl: values.imageUrl,
icon: values.icon,
display: values.display,
forExistingUsers: values.forExistingUsers,
needConfirmationToRead: values.needConfirmationToRead,
closeDuration: values.closeDuration,
displayOrder: values.displayOrder,
userId: values.userId,
})
.then((x) =>
this.announcementsRepository.findOneByOrFail(x.identifiers[0]),
);
@bindThis
public async getUnreadAnnouncements(user: User): Promise<Announcement[]> {
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.orWhere('announcement.userId = :userId', { userId: user.id });
qb.orWhere('announcement.userId IS NULL');
}))
.andWhere(new Brackets(qb => {
qb.orWhere('announcement.forExistingUsers = false');
qb.orWhere('announcement.createdAt > :createdAt', { createdAt: user.createdAt });
}))
.andWhere(`announcement.id NOT IN (${ readsQuery.getQuery() })`);
q.setParameters(readsQuery.getParameters());
return q.getMany();
}
@bindThis
public async create(values: Partial<Announcement>): Promise<{ raw: Announcement; packed: Packed<'Announcement'> }> {
const announcement = await this.announcementsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
updatedAt: null,
title: values.title,
text: values.text,
imageUrl: values.imageUrl,
icon: values.icon,
display: values.display,
forExistingUsers: values.forExistingUsers,
needConfirmationToRead: values.needConfirmationToRead,
userId: values.userId,
}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0]));
const packed = (await this.packMany([announcement]))[0];
const packed = await this.announcementEntityService.pack(
announcement,
null,
);
if (values.userId) {
this.globalEventService.publishMainStream(values.userId, 'announcementCreated', {
announcement: packed,
});
this.globalEventService.publishMainStream(
values.userId,
'announcementCreated',
{
announcement: packed,
},
);
} else {
this.globalEventService.publishBroadcastStream('announcementCreated', {
announcement: packed,
@@ -92,44 +84,271 @@ export class AnnouncementService {
}
@bindThis
public async read(user: User, announcementId: Announcement['id']): Promise<void> {
public async list(
userId: User['id'] | null,
limit: number,
offset: number,
moderator: User,
): Promise<(Announcement & { userInfo: Packed<'UserLite'> | null, readCount: number })[]> {
const query = this.announcementsRepository.createQueryBuilder('announcement');
if (userId) {
query.andWhere('announcement."userId" = :userId', { userId: userId });
} else {
query.andWhere('announcement."userId" IS NULL');
}
query.orderBy({
'announcement."displayOrder"': 'DESC',
'announcement."createdAt"': 'DESC',
});
const announcements = await query
.limit(limit)
.offset(offset)
.getMany();
const reads = new Map<Announcement, number>();
for (const announcement of announcements) {
reads.set(announcement, await this.announcementReadsRepository.countBy({
announcementId: announcement.id,
}));
}
const users = await this.usersRepository.findBy({
id: In(announcements.map(a => a.userId).filter(id => id != null)),
});
const packedUsers = await this.userEntityService.packMany(users, moderator, {
detail: false,
});
return announcements.map(announcement => ({
...announcement,
userInfo: packedUsers.find(u => u.id === announcement.userId) ?? null,
readCount: reads.get(announcement) ?? 0,
}));
}
@bindThis
public async update(
announcementId: Announcement['id'],
values: Partial<Announcement>,
): Promise<{ raw: Announcement; packed: Packed<'Announcement'> }> {
const oldAnnouncement = await this.announcementsRepository.findOneByOrFail({
id: announcementId,
});
if (oldAnnouncement.userId && oldAnnouncement.userId !== values.userId) {
await this.announcementReadsRepository.delete({
announcementId: announcementId,
userId: oldAnnouncement.userId,
});
}
const announcement = await this.announcementsRepository
.update(announcementId, {
updatedAt: new Date(),
isActive: values.isActive,
title: values.title,
text: values.text,
imageUrl: values.imageUrl !== '' ? values.imageUrl : null,
icon: values.icon,
display: values.display,
forExistingUsers: values.forExistingUsers,
needConfirmationToRead: values.needConfirmationToRead,
closeDuration: values.closeDuration,
displayOrder: values.displayOrder,
userId: values.userId,
})
.then(() =>
this.announcementsRepository.findOneByOrFail({ id: announcementId }),
);
const packed = await this.announcementEntityService.pack(
announcement,
values.userId ? { id: values.userId } : null,
);
if (values.userId) {
this.globalEventService.publishMainStream(
values.userId,
'announcementCreated',
{
announcement: packed,
},
);
} else {
this.globalEventService.publishBroadcastStream('announcementCreated', {
announcement: packed,
});
}
return {
raw: announcement,
packed: packed,
};
}
@bindThis
public async delete(announcementId: Announcement['id']): Promise<void> {
await this.announcementReadsRepository.delete({
announcementId: announcementId,
});
await this.announcementsRepository.delete({ id: announcementId });
}
@bindThis
public async getAnnouncements(
me: User | null,
limit: number,
offset: number,
isActive?: boolean,
): Promise<Packed<'Announcement'>[]> {
const query = this.announcementsRepository.createQueryBuilder('announcement');
if (me) {
query.leftJoin(
AnnouncementRead,
'read',
'read."announcementId" = announcement.id AND read."userId" = :userId',
{ userId: me.id },
);
query.select([
'announcement.*',
'CASE WHEN read.id IS NULL THEN FALSE ELSE TRUE END as "isRead"',
]);
query
.andWhere(
new Brackets((qb) => {
qb.orWhere('announcement."userId" = :userId', { userId: me.id });
qb.orWhere('announcement."userId" IS NULL');
}),
)
.andWhere(
new Brackets((qb) => {
qb.orWhere('announcement."forExistingUsers" = false');
qb.orWhere('announcement."createdAt" > :createdAt', {
createdAt: me.createdAt,
});
}),
);
} else {
query.select([
'announcement.*',
'NULL as "isRead"',
]);
query.andWhere('announcement."userId" IS NULL');
query.andWhere('announcement."forExistingUsers" = false');
}
if (isActive !== undefined) {
query.andWhere('announcement."isActive" = :isActive', {
isActive: isActive,
});
}
query.orderBy({
'"isRead"': 'ASC',
'announcement."displayOrder"': 'DESC',
'announcement."createdAt"': 'DESC',
});
return this.announcementEntityService.packMany(
await query
.limit(limit)
.offset(offset)
.getRawMany<Announcement & { isRead?: boolean | null }>(),
me,
);
}
@bindThis
public async getUnreadAnnouncements(me: User): Promise<Packed<'Announcement'>[]> {
const query = this.announcementsRepository.createQueryBuilder('announcement');
query.leftJoinAndSelect(
AnnouncementRead,
'read',
'read."announcementId" = announcement.id AND read."userId" = :userId',
{ userId: me.id },
);
query.andWhere('read.id IS NULL');
query.andWhere('announcement."isActive" = true');
query
.andWhere(
new Brackets((qb) => {
qb.orWhere('announcement."userId" = :userId', { userId: me.id });
qb.orWhere('announcement."userId" IS NULL');
}),
)
.andWhere(
new Brackets((qb) => {
qb.orWhere('announcement."forExistingUsers" = false');
qb.orWhere('announcement."createdAt" > :createdAt', {
createdAt: me.createdAt,
});
}),
);
query.orderBy({
'announcement."displayOrder"': 'DESC',
'announcement."createdAt"': 'DESC',
});
return this.announcementEntityService.packMany(
await query.getMany(),
me,
);
}
@bindThis
public async countUnreadAnnouncements(me: User): Promise<number> {
const query = this.announcementsRepository.createQueryBuilder('announcement');
query.leftJoinAndSelect(
AnnouncementRead,
'read',
'read."announcementId" = announcement.id AND read."userId" = :userId',
{ userId: me.id },
);
query.andWhere('read.id IS NULL');
query.andWhere('announcement."isActive" = true');
query
.andWhere(
new Brackets((qb) => {
qb.orWhere('announcement."userId" = :userId', { userId: me.id });
qb.orWhere('announcement."userId" IS NULL');
}),
)
.andWhere(
new Brackets((qb) => {
qb.orWhere('announcement."forExistingUsers" = false');
qb.orWhere('announcement."createdAt" > :createdAt', {
createdAt: me.createdAt,
});
}),
);
return query.getCount();
}
@bindThis
public async markAsRead(
me: User,
announcementId: Announcement['id'],
): Promise<void> {
try {
await this.announcementReadsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
announcementId: announcementId,
userId: user.id,
userId: me.id,
});
} catch (e) {
return;
}
if ((await this.getUnreadAnnouncements(user)).length === 0) {
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
if ((await this.countUnreadAnnouncements(me)) === 0) {
this.globalEventService.publishMainStream(me.id, 'readAllAnnouncements');
}
}
@bindThis
public async packMany(
announcements: Announcement[],
me?: { id: User['id'] } | null | undefined,
options?: {
reads?: AnnouncementRead[];
},
): Promise<Packed<'Announcement'>[]> {
const reads = me ? (options?.reads ?? await this.getReads(me.id)) : [];
return announcements.map(announcement => ({
id: announcement.id,
createdAt: announcement.createdAt.toISOString(),
updatedAt: announcement.updatedAt?.toISOString() ?? null,
text: announcement.text,
title: announcement.title,
imageUrl: announcement.imageUrl,
icon: announcement.icon,
display: announcement.display,
needConfirmationToRead: announcement.needConfirmationToRead,
forYou: announcement.userId === me?.id,
isRead: reads.some(read => read.announcementId === announcement.id),
}));
}
}

View File

@@ -72,6 +72,7 @@ import PerUserDriveChart from './chart/charts/per-user-drive.js';
import ApRequestChart from './chart/charts/ap-request.js';
import { ChartManagementService } from './chart/ChartManagementService.js';
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
import { AnnouncementEntityService } from './entities/AnnouncementEntityService.js';
import { AntennaEntityService } from './entities/AntennaEntityService.js';
import { AppEntityService } from './entities/AppEntityService.js';
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
@@ -198,6 +199,7 @@ const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRe
const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService };
const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService };
const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService };
const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService };
const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService };
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
@@ -324,6 +326,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApRequestChart,
ChartManagementService,
AbuseUserReportEntityService,
AnnouncementEntityService,
AntennaEntityService,
AppEntityService,
AuthSessionEntityService,
@@ -445,6 +448,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApRequestChart,
$ChartManagementService,
$AbuseUserReportEntityService,
$AnnouncementEntityService,
$AntennaEntityService,
$AppEntityService,
$AuthSessionEntityService,
@@ -566,6 +570,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApRequestChart,
ChartManagementService,
AbuseUserReportEntityService,
AnnouncementEntityService,
AntennaEntityService,
AppEntityService,
AuthSessionEntityService,
@@ -686,6 +691,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ApRequestChart,
$ChartManagementService,
$AbuseUserReportEntityService,
$AnnouncementEntityService,
$AntennaEntityService,
$AppEntityService,
$AuthSessionEntityService,

View File

@@ -0,0 +1,71 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type {
AnnouncementReadsRepository,
AnnouncementsRepository,
} from '@/models/index.js';
import type { Packed } from '@/misc/json-schema.js';
import { bindThis } from '@/decorators.js';
import { Announcement, User } from '@/models/index.js';
@Injectable()
export class AnnouncementEntityService {
constructor(
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,
@Inject(DI.announcementReadsRepository)
private announcementReadsRepository: AnnouncementReadsRepository,
) {
}
@bindThis
public async pack(
src: Announcement['id'] | Announcement & { isRead?: boolean | null },
me: { id: User['id'] } | null | undefined,
): Promise<Packed<'Announcement'>> {
const announcement = typeof src === 'object'
? src
: await this.announcementsRepository.findOneByOrFail({
id: src,
}) as Announcement & { isRead?: boolean | null };
if (me && announcement.isRead === undefined) {
announcement.isRead = await this.announcementReadsRepository.countBy({
announcementId: announcement.id,
userId: me.id,
}).then(count => count > 0);
}
return {
id: announcement.id,
createdAt: announcement.createdAt.toISOString(),
updatedAt: announcement.updatedAt?.toISOString() ?? null,
title: announcement.title,
text: announcement.text,
imageUrl: announcement.imageUrl,
icon: announcement.icon,
display: announcement.display,
forYou: announcement.userId === me?.id,
needConfirmationToRead: announcement.needConfirmationToRead,
closeDuration: announcement.closeDuration,
displayOrder: announcement.displayOrder,
isRead: announcement.isRead !== null ? announcement.isRead : undefined,
};
}
@bindThis
public async packMany(
announcements: (Announcement['id'] | Announcement & { isRead?: boolean | null } | Announcement)[],
me: { id: User['id'] } | null | undefined,
) : Promise<Packed<'Announcement'>[]> {
return (await Promise.allSettled(announcements.map(x => this.pack(x, me))))
.filter(result => result.status === 'fulfilled')
.map(result => (result as PromiseFulfilledResult<Packed<'Announcement'>>).value);
}
}

View File

@@ -60,12 +60,26 @@ export class Announcement {
})
public needConfirmationToRead: boolean;
@Column('integer', {
nullable: false,
default: 0,
})
public closeDuration: number;
@Index()
@Column('boolean', {
default: true,
})
public isActive: boolean;
// UIに表示する際の並び順用(大きいほど先頭)
@Index()
@Column('integer', {
nullable: false,
default: 0,
})
public displayOrder: number;
@Index()
@Column('boolean', {
default: false,

View File

@@ -50,6 +50,14 @@ export const packedAnnouncementSchema = {
type: 'boolean',
optional: false, nullable: false,
},
closeDuration: {
type: 'number',
optional: false, nullable: false,
},
displayOrder: {
type: 'number',
optional: false, nullable: false,
},
isRead: {
type: 'boolean',
optional: true, nullable: false,

View File

@@ -45,6 +45,34 @@ export const meta = {
type: 'string',
optional: false, nullable: true,
},
icon: {
type: 'string',
optional: false, nullable: false,
},
display: {
type: 'string',
optional: false, nullable: false,
},
forExistingUsers: {
type: 'boolean',
optional: false, nullable: false,
},
needConfirmationToRead: {
type: 'boolean',
optional: false, nullable: false,
},
closeDuration: {
type: 'number',
optional: false, nullable: false,
},
displayOrder: {
type: 'number',
optional: false, nullable: false,
},
userId: {
type: 'string',
optional: false, nullable: true,
},
},
},
} as const;
@@ -59,6 +87,8 @@ export const paramDef = {
display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' },
forExistingUsers: { type: 'boolean', default: false },
needConfirmationToRead: { type: 'boolean', default: false },
closeDuration: { type: 'number', default: 0 },
displayOrder: { type: 'number', default: 0 },
userId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
},
required: ['title', 'text', 'imageUrl'],
@@ -81,10 +111,26 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
display: ps.display,
forExistingUsers: ps.forExistingUsers,
needConfirmationToRead: ps.needConfirmationToRead,
closeDuration: ps.closeDuration,
displayOrder: ps.displayOrder,
userId: ps.userId,
});
return packed;
return {
id: packed.id,
createdAt: packed.createdAt,
updatedAt: packed.updatedAt,
title: packed.title,
text: packed.text,
imageUrl: packed.imageUrl,
icon: packed.icon,
display: packed.display,
forExistingUsers: raw.forExistingUsers,
needConfirmationToRead: packed.needConfirmationToRead,
closeDuration: packed.closeDuration,
displayOrder: packed.displayOrder,
userId: raw.userId,
};
});
}
}

View File

@@ -5,9 +5,10 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AnnouncementsRepository } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
import type { AnnouncementsRepository } from '@/models/index.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
export const meta = {
tags: ['admin'],
@@ -38,13 +39,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,
private announcementService: AnnouncementService,
) {
super(meta, paramDef, async (ps, me) => {
const announcement = await this.announcementsRepository.findOneBy({ id: ps.id });
if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement);
await this.announcementsRepository.delete(announcement.id);
await this.announcementService.delete(announcement.id);
});
}
}

View File

@@ -3,12 +3,9 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/index.js';
import type { Announcement } from '@/models/entities/Announcement.js';
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
export const meta = {
tags: ['admin'],
@@ -39,19 +36,56 @@ export const meta = {
optional: false, nullable: true,
format: 'date-time',
},
text: {
type: 'string',
isActive: {
type: 'boolean',
optional: false, nullable: false,
},
title: {
type: 'string',
optional: false, nullable: false,
},
text: {
type: 'string',
optional: false, nullable: false,
},
imageUrl: {
type: 'string',
optional: false, nullable: true,
},
reads: {
icon: {
type: 'string',
optional: false, nullable: false,
},
display: {
type: 'string',
optional: false, nullable: false,
},
forExistingUsers: {
type: 'boolean',
optional: false, nullable: false,
},
needConfirmationToRead: {
type: 'boolean',
optional: false, nullable: false,
},
closeDuration: {
type: 'number',
optional: false, nullable: false,
},
displayOrder: {
type: 'number',
optional: false, nullable: false,
},
userId: {
type: 'string',
optional: false, nullable: true,
},
user: {
type: 'object',
optional: false, nullable: true,
ref: 'UserLite',
},
readCount: {
type: 'number',
optional: false, nullable: false,
},
@@ -64,8 +98,7 @@ export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
offset: { type: 'integer', default: 0 },
userId: { type: 'string', format: 'misskey:id', nullable: true },
},
required: [],
@@ -75,46 +108,28 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,
@Inject(DI.announcementReadsRepository)
private announcementReadsRepository: AnnouncementReadsRepository,
private queryService: QueryService,
private announcementService: AnnouncementService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId);
if (ps.userId) {
query.andWhere('announcement.userId = :userId', { userId: ps.userId });
} else {
query.andWhere('announcement.userId IS NULL');
}
const announcements = await query.limit(ps.limit).getMany();
const reads = new Map<Announcement, number>();
for (const announcement of announcements) {
reads.set(announcement, await this.announcementReadsRepository.countBy({
announcementId: announcement.id,
}));
}
const announcements = await this.announcementService.list(ps.userId ?? null, ps.limit, ps.offset, me);
return announcements.map(announcement => ({
id: announcement.id,
createdAt: announcement.createdAt.toISOString(),
updatedAt: announcement.updatedAt?.toISOString() ?? null,
isActive: announcement.isActive,
title: announcement.title,
text: announcement.text,
imageUrl: announcement.imageUrl,
icon: announcement.icon,
display: announcement.display,
isActive: announcement.isActive,
forExistingUsers: announcement.forExistingUsers,
needConfirmationToRead: announcement.needConfirmationToRead,
closeDuration: announcement.closeDuration,
displayOrder: announcement.displayOrder,
userId: announcement.userId,
reads: reads.get(announcement)!,
user: announcement.userInfo,
readCount: announcement.readCount,
}));
});
}

View File

@@ -5,9 +5,10 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AnnouncementsRepository } from '@/models/index.js';
import { ApiError } from '@/server/api/error.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
import type { AnnouncementsRepository } from '@/models/index.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
export const meta = {
tags: ['admin'],
@@ -35,6 +36,8 @@ export const paramDef = {
display: { type: 'string', enum: ['normal', 'banner', 'dialog'] },
forExistingUsers: { type: 'boolean' },
needConfirmationToRead: { type: 'boolean' },
closeDuration: { type: 'number', default: 0 },
displayOrder: { type: 'number', default: 0 },
isActive: { type: 'boolean' },
},
required: ['id'],
@@ -46,24 +49,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.announcementsRepository)
private announcementsRepository: AnnouncementsRepository,
private announcementService: AnnouncementService,
) {
super(meta, paramDef, async (ps, me) => {
const announcement = await this.announcementsRepository.findOneBy({ id: ps.id });
if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement);
await this.announcementsRepository.update(announcement.id, {
updatedAt: new Date(),
title: ps.title,
text: ps.text,
/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- 空の文字列の場合、nullを渡すようにするため */
imageUrl: ps.imageUrl || null,
display: ps.display,
icon: ps.icon,
forExistingUsers: ps.forExistingUsers,
needConfirmationToRead: ps.needConfirmationToRead,
isActive: ps.isActive,
});
await this.announcementService.update(announcement.id, ps);
});
}
}

View File

@@ -31,8 +31,7 @@ export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
offset: { type: 'integer', default: 0 },
isActive: { type: 'boolean', default: true },
},
required: [],
@@ -52,16 +51,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private announcementService: AnnouncementService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId)
.where('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');
}));
const announcements = await query.limit(ps.limit).getMany();
return this.announcementService.packMany(announcements, me);
return this.announcementService.getAnnouncements(me, ps.limit, ps.offset, ps.isActive);
});
}
}

View File

@@ -33,7 +33,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private announcementService: AnnouncementService,
) {
super(meta, paramDef, async (ps, me) => {
await this.announcementService.read(me, ps.announcementId);
await this.announcementService.markAsRead(me, ps.announcementId);
});
}
}

View File

@@ -5,18 +5,19 @@
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { jest } from '@jest/globals';
import type { Announcement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, User } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { genAid } from '@/misc/id/aid.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { GlobalModule } from '@/GlobalModule.js';
import { IdService } from '@/core/IdService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { genAid } from '@/misc/id/aid.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
@@ -60,6 +61,7 @@ describe('AnnouncementService', () => {
GlobalModule,
],
providers: [
AnnouncementEntityService,
AnnouncementService,
CacheService,
IdService,