feat: 個人宛てお知らせ機能 (#107)
* feat: 個人宛てお知らせ機能 * Remove unused import * Update packages/backend/src/server/api/endpoints/admin/announcements/create.ts Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com> * Update packages/frontend/src/pages/announcements.vue Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com> * Restore breakline * 一般向けAPIにはuserオブジェクトを提供しない * fix * Fix * Update packages/misskey-js/src/entities.ts Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com> * Fix * Update misskey-js.api.md * Fix lint * 他のテーブルに合わせて character varying(32) にした * count クエリを1つにまとめた * user を pack するようにした * いろいろ修正 * 個人宛てのお知らせの表示を改善 * Update misskey-js.api.md * Merge migration scripts * Fix * Update packages/backend/migration/1688647797135-userannouncement.js Co-authored-by: riku6460 <17585784+riku6460@users.noreply.github.com> * Update packages/backend/src/models/entities/Announcement.ts Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com> * Fix * Update migration script --------- Co-authored-by: CyberRex <hspwinx86@gmail.com> Co-authored-by: まっちゃとーにゅ <17376330+u1-liquid@users.noreply.github.com>
This commit is contained in:
15
packages/backend/migration/1688647797135-userannouncement.js
Normal file
15
packages/backend/migration/1688647797135-userannouncement.js
Normal file
@@ -0,0 +1,15 @@
|
||||
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"`);
|
||||
}
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, Not } from 'typeorm';
|
||||
import { In, IsNull, Not } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import _Ajv from 'ajv';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
@@ -218,9 +218,11 @@ export class UserEntityService implements OnModuleInit {
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
const count = await this.announcementsRepository.countBy(reads.length > 0 ? {
|
||||
id: Not(In(reads.map(read => read.announcementId))),
|
||||
} : {});
|
||||
const id = reads.length > 0 ? Not(In(reads.map(read => read.announcementId))) : undefined;
|
||||
const count = await this.announcementsRepository.countBy([
|
||||
{ id, userId: IsNull() },
|
||||
{ id, userId: userId },
|
||||
]);
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
@@ -33,6 +33,19 @@ export class Announcement {
|
||||
})
|
||||
public imageUrl: string | null;
|
||||
|
||||
@Index()
|
||||
@Column('varchar', {
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public userId: string | null;
|
||||
|
||||
@Column('integer', {
|
||||
nullable: false,
|
||||
default: 0,
|
||||
})
|
||||
public closeDuration: number;
|
||||
|
||||
constructor(data: Partial<Announcement>) {
|
||||
if (data == null) return;
|
||||
|
||||
|
@@ -42,6 +42,14 @@ export const meta = {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
closeDuration: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
@@ -52,6 +60,8 @@ export const paramDef = {
|
||||
title: { type: 'string', minLength: 1 },
|
||||
text: { type: 'string', minLength: 1 },
|
||||
imageUrl: { type: 'string', nullable: true, minLength: 1 },
|
||||
userId: { type: 'string', nullable: true, format: 'misskey:id' },
|
||||
closeDuration: { type: 'number', nullable: false },
|
||||
},
|
||||
required: ['title', 'text', 'imageUrl'],
|
||||
} as const;
|
||||
@@ -73,6 +83,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
title: ps.title,
|
||||
text: ps.text,
|
||||
imageUrl: ps.imageUrl,
|
||||
userId: ps.userId ?? null,
|
||||
closeDuration: ps.closeDuration,
|
||||
}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
return Object.assign({}, announcement, { createdAt: announcement.createdAt.toISOString(), updatedAt: null });
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { AnnouncementsRepository, AnnouncementReadsRepository } from '@/models/index.js';
|
||||
import { In } from 'typeorm';
|
||||
import type { AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { Announcement } from '@/models/entities/Announcement.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -46,10 +48,23 @@ export const meta = {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
userId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
user: {
|
||||
type: 'object',
|
||||
optional: true, nullable: false,
|
||||
ref: 'UserLite',
|
||||
},
|
||||
reads: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
closeDuration: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -61,6 +76,7 @@ export const paramDef = {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
@@ -75,10 +91,21 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
@Inject(DI.announcementReadsRepository)
|
||||
private announcementReadsRepository: AnnouncementReadsRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private queryService: QueryService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId);
|
||||
const builder = this.announcementsRepository.createQueryBuilder('announcement');
|
||||
if (ps.userId) {
|
||||
builder.where('"userId" = :userId', { userId: ps.userId });
|
||||
} else {
|
||||
builder.where('"userId" IS NULL');
|
||||
}
|
||||
|
||||
const query = this.queryService.makePaginationQuery(builder, ps.sinceId, ps.untilId);
|
||||
|
||||
const announcements = await query.limit(ps.limit).getMany();
|
||||
|
||||
@@ -90,6 +117,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
}));
|
||||
}
|
||||
|
||||
const users = await this.usersRepository.findBy({
|
||||
id: In(announcements.map(a => a.userId).filter(id => id != null)),
|
||||
});
|
||||
const packedUsers = await this.userEntityService.packMany(users, me, {
|
||||
detail: false,
|
||||
});
|
||||
|
||||
return announcements.map(announcement => ({
|
||||
id: announcement.id,
|
||||
createdAt: announcement.createdAt.toISOString(),
|
||||
@@ -97,7 +131,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
title: announcement.title,
|
||||
text: announcement.text,
|
||||
imageUrl: announcement.imageUrl,
|
||||
userId: announcement.userId,
|
||||
user: packedUsers.find(user => user.id === announcement.userId),
|
||||
reads: reads.get(announcement)!,
|
||||
closeDuration: announcement.closeDuration,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { AnnouncementsRepository } from '@/models/index.js';
|
||||
import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../../error.js';
|
||||
|
||||
@@ -26,8 +26,10 @@ export const paramDef = {
|
||||
title: { type: 'string', minLength: 1 },
|
||||
text: { type: 'string', minLength: 1 },
|
||||
imageUrl: { type: 'string', nullable: true, minLength: 0 },
|
||||
userId: { type: 'string', nullable: true, format: 'misskey:id' },
|
||||
closeDuration: { type: 'number', nullable: false },
|
||||
},
|
||||
required: ['id', 'title', 'text', 'imageUrl'],
|
||||
required: ['id', 'title', 'text', 'imageUrl', 'closeDuration'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@@ -36,18 +38,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.announcementsRepository)
|
||||
private announcementsRepository: AnnouncementsRepository,
|
||||
|
||||
@Inject(DI.announcementReadsRepository)
|
||||
private announcementsReadsRepository: AnnouncementReadsRepository,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const announcement = await this.announcementsRepository.findOneBy({ id: ps.id });
|
||||
|
||||
if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement);
|
||||
|
||||
if (announcement.userId && announcement.userId !== ps.userId) {
|
||||
await this.announcementsReadsRepository.delete({ id: announcement.id, userId: announcement.userId });
|
||||
}
|
||||
|
||||
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,
|
||||
userId: ps.userId ?? null,
|
||||
closeDuration: ps.closeDuration,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -48,6 +48,14 @@ export const meta = {
|
||||
type: 'boolean',
|
||||
optional: true, nullable: false,
|
||||
},
|
||||
isPrivate: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
closeDuration: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -60,6 +68,7 @@ export const paramDef = {
|
||||
withUnreads: { type: 'boolean', default: false },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
privateOnly: { type: 'boolean', default: false },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
@@ -77,8 +86,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId);
|
||||
const builder = this.announcementsRepository.createQueryBuilder('announcement');
|
||||
if (me) {
|
||||
if (ps.privateOnly) {
|
||||
builder.where('"userId" = :userId', { userId: me.id });
|
||||
} else {
|
||||
builder.where('"userId" IS NULL');
|
||||
builder.orWhere('"userId" = :userId', { userId: me.id });
|
||||
}
|
||||
} else {
|
||||
builder.where('"userId" IS NULL');
|
||||
}
|
||||
|
||||
const query = this.queryService.makePaginationQuery(builder, ps.sinceId, ps.untilId);
|
||||
const announcements = await query.limit(ps.limit).getMany();
|
||||
|
||||
if (me) {
|
||||
@@ -95,6 +115,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
...a,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
updatedAt: a.updatedAt?.toISOString() ?? null,
|
||||
isPrivate: !!a.userId,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/index.js';
|
||||
@@ -47,7 +48,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Check if announcement exists
|
||||
const announcementExist = await this.announcementsRepository.exist({ where: { id: ps.announcementId } });
|
||||
const announcementExist = await this.announcementsRepository.exist({
|
||||
where: [
|
||||
{
|
||||
id: ps.announcementId,
|
||||
userId: IsNull(),
|
||||
},
|
||||
{
|
||||
id: ps.announcementId,
|
||||
userId: me.id,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!announcementExist) {
|
||||
throw new ApiError(meta.errors.noSuchAnnouncement);
|
||||
|
Reference in New Issue
Block a user