feat(v2.7): 增加通知与评论协作模块
This commit is contained in:
@@ -10,9 +10,11 @@ import { DataModule } from './modules/data/data.module';
|
|||||||
import { MigrationModule } from './modules/migration/migration.module';
|
import { MigrationModule } from './modules/migration/migration.module';
|
||||||
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
import { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
|
import { NotificationModule } from './modules/notification/notification.module';
|
||||||
|
import { CommentModule } from './modules/comment/comment.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule],
|
imports: [PrismaModule, ProductModule, RequirementModule, ConfigModule, DataModule, MigrationModule, V22QueryModule, HealthModule, AiModule, NotificationModule, CommentModule],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
|||||||
9
apps/server/src/common/common-domain.module.ts
Normal file
9
apps/server/src/common/common-domain.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuditService } from './audit/audit.service';
|
||||||
|
import { RbacService } from './rbac/rbac.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [AuditService, RbacService],
|
||||||
|
exports: [AuditService, RbacService],
|
||||||
|
})
|
||||||
|
export class CommonDomainModule {}
|
||||||
24
apps/server/src/modules/comment/comment.controller.ts
Normal file
24
apps/server/src/modules/comment/comment.controller.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { CommentService, type CommentEntityType } from './comment.service';
|
||||||
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
|
import { DeleteCommentDto } from './dto/delete-comment.dto';
|
||||||
|
|
||||||
|
@Controller('comments')
|
||||||
|
export class CommentController {
|
||||||
|
constructor(private readonly commentService: CommentService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query('entityType') entityType: CommentEntityType, @Query('entityId') entityId: string) {
|
||||||
|
return this.commentService.list(entityType, entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateCommentDto) {
|
||||||
|
return this.commentService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string, @Body() dto: DeleteCommentDto) {
|
||||||
|
return this.commentService.remove(id, dto.actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/server/src/modules/comment/comment.module.ts
Normal file
13
apps/server/src/modules/comment/comment.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CommonDomainModule } from '../../common/common-domain.module';
|
||||||
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
|
import { CommentController } from './comment.controller';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [CommonDomainModule, NotificationModule],
|
||||||
|
controllers: [CommentController],
|
||||||
|
providers: [CommentService],
|
||||||
|
exports: [CommentService],
|
||||||
|
})
|
||||||
|
export class CommentModule {}
|
||||||
112
apps/server/src/modules/comment/comment.service.spec.ts
Normal file
112
apps/server/src/modules/comment/comment.service.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { AuditService } from '../../common/audit/audit.service';
|
||||||
|
import { NotificationService } from '../notification/notification.service';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
|
||||||
|
describe('CommentService', () => {
|
||||||
|
const makeService = () => {
|
||||||
|
const prisma = {
|
||||||
|
comment: {
|
||||||
|
create: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
projectMember: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const notifications = {
|
||||||
|
createMany: jest.fn(),
|
||||||
|
} as unknown as NotificationService;
|
||||||
|
const audit = {
|
||||||
|
record: jest.fn(),
|
||||||
|
} as unknown as AuditService;
|
||||||
|
return {
|
||||||
|
prisma,
|
||||||
|
notifications,
|
||||||
|
audit,
|
||||||
|
service: new CommentService(prisma as any, notifications, audit),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('creates a polymorphic comment, extracts @mentions, notifies mentioned members, and writes audit', async () => {
|
||||||
|
const { prisma, notifications, audit, service } = makeService();
|
||||||
|
prisma.projectMember.findMany.mockResolvedValue([
|
||||||
|
{ userId: 'm-alice', user: { id: 'm-alice', name: 'Alice' } },
|
||||||
|
{ userId: 'm-bob', user: { id: 'm-bob', name: 'Bob' } },
|
||||||
|
]);
|
||||||
|
prisma.comment.create.mockResolvedValue({
|
||||||
|
id: 'comment-1',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityId: 'task-1',
|
||||||
|
mentionedMemberIds: ['m-alice', 'm-bob'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.create({
|
||||||
|
actorId: 'm-author',
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityId: 'task-1',
|
||||||
|
entityVersionId: 'ver-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
versionId: 'ver-1',
|
||||||
|
content: '请 @Alice 看一下接口,Bob 也同步一下',
|
||||||
|
mentionMemberIds: ['m-bob'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.id).toBe('comment-1');
|
||||||
|
expect(prisma.comment.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
entityType: 'dev_task',
|
||||||
|
entityId: 'task-1',
|
||||||
|
authorId: 'm-author',
|
||||||
|
mentionedMemberIds: ['m-alice', 'm-bob'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect((notifications.createMany as jest.Mock)).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({ recipientId: 'm-alice', type: 'mention', resourceType: 'comment', resourceId: 'comment-1' }),
|
||||||
|
expect.objectContaining({ recipientId: 'm-bob', type: 'mention', resourceType: 'comment', resourceId: 'comment-1' }),
|
||||||
|
]);
|
||||||
|
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
actorId: 'm-author',
|
||||||
|
action: 'comment.created',
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: 'comment-1',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('soft deletes a comment and writes audit', async () => {
|
||||||
|
const { prisma, audit, service } = makeService();
|
||||||
|
prisma.comment.findUnique.mockResolvedValue({
|
||||||
|
id: 'comment-1',
|
||||||
|
authorId: 'm-author',
|
||||||
|
entityType: 'bug',
|
||||||
|
entityId: 'bug-1',
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
prisma.comment.update.mockResolvedValue({ id: 'comment-1', deletedAt: new Date('2026-07-08T08:00:00.000Z') });
|
||||||
|
|
||||||
|
await service.remove('comment-1', 'm-author');
|
||||||
|
|
||||||
|
expect(prisma.comment.update).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'comment-1' },
|
||||||
|
data: { deletedAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
expect((audit.record as jest.Mock)).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
actorId: 'm-author',
|
||||||
|
action: 'comment.deleted',
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: 'comment-1',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unsupported comment entity types', async () => {
|
||||||
|
const { service } = makeService();
|
||||||
|
|
||||||
|
await expect(service.create({
|
||||||
|
actorId: 'm-author',
|
||||||
|
entityType: 'task',
|
||||||
|
entityId: 'task-1',
|
||||||
|
content: 'legacy task comment',
|
||||||
|
} as any)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
157
apps/server/src/modules/comment/comment.service.ts
Normal file
157
apps/server/src/modules/comment/comment.service.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AuditService } from '../../common/audit/audit.service';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { NotificationService } from '../notification/notification.service';
|
||||||
|
|
||||||
|
export const COMMENT_ENTITY_TYPES = ['dev_task', 'test_case', 'bug', 'requirement', 'version_plan'] as const;
|
||||||
|
export type CommentEntityType = (typeof COMMENT_ENTITY_TYPES)[number];
|
||||||
|
|
||||||
|
export interface CommentCreateInput {
|
||||||
|
actorId: string;
|
||||||
|
entityType: CommentEntityType;
|
||||||
|
entityId: string;
|
||||||
|
entityVersionId?: string | null;
|
||||||
|
productId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
versionId?: string | null;
|
||||||
|
content: string;
|
||||||
|
mentionMemberIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
private readonly auditService: AuditService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list(entityType: CommentEntityType, entityId: string) {
|
||||||
|
assertCommentEntityType(entityType);
|
||||||
|
return this.prisma.comment.findMany({
|
||||||
|
where: { entityType, entityId: requireText(entityId, 'entityId'), deletedAt: null },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CommentCreateInput) {
|
||||||
|
assertCommentEntityType(input.entityType);
|
||||||
|
const actorId = requireText(input.actorId, 'actorId');
|
||||||
|
const content = requireText(input.content, 'content');
|
||||||
|
const mentionedMemberIds = await this.resolveMentionMemberIds(content, input.projectId, input.mentionMemberIds);
|
||||||
|
const comment = await this.prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: requireText(input.entityId, 'entityId'),
|
||||||
|
entityVersionId: input.entityVersionId ?? null,
|
||||||
|
productId: input.productId ?? null,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
versionId: input.versionId ?? null,
|
||||||
|
authorId: actorId,
|
||||||
|
content,
|
||||||
|
mentionedMemberIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.notificationService.createMany(mentionedMemberIds
|
||||||
|
.filter((recipientId) => recipientId !== actorId)
|
||||||
|
.map((recipientId) => ({
|
||||||
|
recipientId,
|
||||||
|
actorId,
|
||||||
|
type: 'mention',
|
||||||
|
title: '你被提及了',
|
||||||
|
body: content,
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: comment.id,
|
||||||
|
resourceVersionId: input.entityVersionId ?? null,
|
||||||
|
productId: input.productId ?? null,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
versionId: input.versionId ?? null,
|
||||||
|
metadata: {
|
||||||
|
entityType: input.entityType,
|
||||||
|
entityId: input.entityId,
|
||||||
|
},
|
||||||
|
})));
|
||||||
|
|
||||||
|
await this.auditService.record({
|
||||||
|
actorId,
|
||||||
|
action: 'comment.created',
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: comment.id,
|
||||||
|
productId: input.productId ?? null,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
versionId: input.versionId ?? null,
|
||||||
|
after: comment,
|
||||||
|
});
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string, actorId: string) {
|
||||||
|
const comment = await this.prisma.comment.findUnique({ where: { id: requireText(id, 'id') } });
|
||||||
|
if (!comment || comment.deletedAt) throw new NotFoundException('Comment not found');
|
||||||
|
|
||||||
|
const removed = await this.prisma.comment.update({
|
||||||
|
where: { id: comment.id },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
await this.auditService.record({
|
||||||
|
actorId: requireText(actorId, 'actorId'),
|
||||||
|
action: 'comment.deleted',
|
||||||
|
resourceType: 'comment',
|
||||||
|
resourceId: comment.id,
|
||||||
|
productId: comment.productId,
|
||||||
|
projectId: comment.projectId,
|
||||||
|
versionId: comment.versionId,
|
||||||
|
before: comment,
|
||||||
|
after: removed,
|
||||||
|
});
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveMentionMemberIds(content: string, projectId?: string | null, explicitIds: string[] = []): Promise<string[]> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const mentionNames = extractMentionNames(content);
|
||||||
|
if (mentionNames.length > 0 && projectId?.trim()) {
|
||||||
|
const projectMembers = await this.prisma.projectMember.findMany({
|
||||||
|
where: { projectId: projectId.trim() },
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
const wanted = new Set(mentionNames.map(normalizeMentionName));
|
||||||
|
for (const member of projectMembers as Array<{ userId: string; user?: { id?: string; name?: string } }>) {
|
||||||
|
const name = normalizeMentionName(member.user?.name);
|
||||||
|
if (name && wanted.has(name)) ids.add(member.user?.id ?? member.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const explicitId of explicitIds) {
|
||||||
|
const id = explicitId.trim();
|
||||||
|
if (id) ids.add(id);
|
||||||
|
}
|
||||||
|
return Array.from(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractMentionNames(content: string): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
const pattern = /@([\p{L}\p{N}_\-.]+)/gu;
|
||||||
|
for (const match of content.matchAll(pattern)) {
|
||||||
|
if (match[1]) names.push(match[1]);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCommentEntityType(entityType: string): asserts entityType is CommentEntityType {
|
||||||
|
if (!COMMENT_ENTITY_TYPES.includes(entityType as CommentEntityType)) {
|
||||||
|
throw new BadRequestException(`Unsupported comment entity type: ${entityType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMentionName(name?: string | null): string {
|
||||||
|
return name?.trim().toLowerCase() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireText(value: string | undefined | null, field: string): string {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
if (!normalized) throw new BadRequestException(`${field} is required`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
37
apps/server/src/modules/comment/dto/create-comment.dto.ts
Normal file
37
apps/server/src/modules/comment/dto/create-comment.dto.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { IsArray, IsIn, IsOptional, IsString } from 'class-validator';
|
||||||
|
import { COMMENT_ENTITY_TYPES, type CommentEntityType } from '../comment.service';
|
||||||
|
|
||||||
|
export class CreateCommentDto {
|
||||||
|
@IsString()
|
||||||
|
actorId!: string;
|
||||||
|
|
||||||
|
@IsIn(COMMENT_ENTITY_TYPES)
|
||||||
|
entityType!: CommentEntityType;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
entityId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
entityVersionId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
versionId?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
content!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
mentionMemberIds?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class DeleteCommentDto {
|
||||||
|
@IsString()
|
||||||
|
actorId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
export class MarkNotificationReadDto {
|
||||||
|
@IsString()
|
||||||
|
recipientId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
|
||||||
|
import { MarkNotificationReadDto } from './dto/mark-notification-read.dto';
|
||||||
|
import { NotificationService } from './notification.service';
|
||||||
|
|
||||||
|
@Controller('notifications')
|
||||||
|
export class NotificationController {
|
||||||
|
constructor(private readonly notificationService: NotificationService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query('recipientId') recipientId: string,
|
||||||
|
@Query('unreadOnly') unreadOnly?: string,
|
||||||
|
@Query('limit') limit?: string,
|
||||||
|
) {
|
||||||
|
return this.notificationService.list({
|
||||||
|
recipientId,
|
||||||
|
unreadOnly: unreadOnly === 'true',
|
||||||
|
limit: limit ? Number(limit) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/read')
|
||||||
|
markRead(@Param('id') id: string, @Body() dto: MarkNotificationReadDto) {
|
||||||
|
return this.notificationService.markRead(id, dto.recipientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('read-all')
|
||||||
|
markAllRead(@Body() dto: MarkNotificationReadDto) {
|
||||||
|
return this.notificationService.markAllRead(dto.recipientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/server/src/modules/notification/notification.module.ts
Normal file
10
apps/server/src/modules/notification/notification.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NotificationController } from './notification.controller';
|
||||||
|
import { NotificationService } from './notification.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [NotificationController],
|
||||||
|
providers: [NotificationService],
|
||||||
|
exports: [NotificationService],
|
||||||
|
})
|
||||||
|
export class NotificationModule {}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { NotificationService } from './notification.service';
|
||||||
|
|
||||||
|
describe('NotificationService', () => {
|
||||||
|
const makeService = () => {
|
||||||
|
const prisma = {
|
||||||
|
notification: {
|
||||||
|
create: jest.fn(),
|
||||||
|
createMany: jest.fn(),
|
||||||
|
findMany: jest.fn(),
|
||||||
|
updateMany: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { prisma, service: new NotificationService(prisma as any) };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('lists unread notifications for one recipient in newest-first order', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.notification.findMany.mockResolvedValue([{ id: 'n-1' }]);
|
||||||
|
|
||||||
|
await expect(service.list({ recipientId: 'm-1', unreadOnly: true, limit: 20 })).resolves.toEqual([{ id: 'n-1' }]);
|
||||||
|
|
||||||
|
expect(prisma.notification.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { recipientId: 'm-1', readAt: null },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks one notification as read only for the requesting recipient', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.notification.updateMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
await service.markRead('n-1', 'm-1');
|
||||||
|
|
||||||
|
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'n-1', recipientId: 'm-1', readAt: null },
|
||||||
|
data: { readAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks all unread notifications for one recipient as read', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.notification.updateMany.mockResolvedValue({ count: 2 });
|
||||||
|
|
||||||
|
await service.markAllRead('m-1');
|
||||||
|
|
||||||
|
expect(prisma.notification.updateMany).toHaveBeenCalledWith({
|
||||||
|
where: { recipientId: 'm-1', readAt: null },
|
||||||
|
data: { readAt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates notifications with stable V2.7 event types', async () => {
|
||||||
|
const { prisma, service } = makeService();
|
||||||
|
prisma.notification.create.mockResolvedValue({ id: 'n-risk' });
|
||||||
|
|
||||||
|
await service.create({
|
||||||
|
recipientId: 'm-manager',
|
||||||
|
actorId: 'xiaobao',
|
||||||
|
type: 'risk_alert',
|
||||||
|
title: '版本存在延期风险',
|
||||||
|
resourceType: 'version',
|
||||||
|
resourceId: 'ver-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
versionId: 'ver-1',
|
||||||
|
metadata: { riskLevel: 'likely_delayed' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prisma.notification.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
recipientId: 'm-manager',
|
||||||
|
type: 'risk_alert',
|
||||||
|
resourceType: 'version',
|
||||||
|
resourceId: 'ver-1',
|
||||||
|
metadata: { riskLevel: 'likely_delayed' },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
99
apps/server/src/modules/notification/notification.service.ts
Normal file
99
apps/server/src/modules/notification/notification.service.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export const NOTIFICATION_TYPES = ['assignment', 'mention', 'risk_alert', 'overdue_item'] as const;
|
||||||
|
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
||||||
|
|
||||||
|
export interface NotificationListQuery {
|
||||||
|
recipientId: string;
|
||||||
|
unreadOnly?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationCreateInput {
|
||||||
|
recipientId: string;
|
||||||
|
actorId?: string | null;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string;
|
||||||
|
resourceVersionId?: string | null;
|
||||||
|
productId?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
versionId?: string | null;
|
||||||
|
metadata?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NotificationService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
list(query: NotificationListQuery) {
|
||||||
|
const recipientId = requireText(query.recipientId, 'recipientId');
|
||||||
|
return this.prisma.notification.findMany({
|
||||||
|
where: {
|
||||||
|
recipientId,
|
||||||
|
...(query.unreadOnly ? { readAt: null } : {}),
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: normalizeLimit(query.limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: NotificationCreateInput) {
|
||||||
|
const data = normalizeNotificationInput(input);
|
||||||
|
return this.prisma.notification.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(items: NotificationCreateInput[]) {
|
||||||
|
const data = items.map(normalizeNotificationInput);
|
||||||
|
if (data.length === 0) return { count: 0 };
|
||||||
|
return this.prisma.notification.createMany({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
markRead(id: string, recipientId: string) {
|
||||||
|
return this.prisma.notification.updateMany({
|
||||||
|
where: { id: requireText(id, 'id'), recipientId: requireText(recipientId, 'recipientId'), readAt: null },
|
||||||
|
data: { readAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllRead(recipientId: string) {
|
||||||
|
return this.prisma.notification.updateMany({
|
||||||
|
where: { recipientId: requireText(recipientId, 'recipientId'), readAt: null },
|
||||||
|
data: { readAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNotificationInput(input: NotificationCreateInput) {
|
||||||
|
if (!NOTIFICATION_TYPES.includes(input.type)) {
|
||||||
|
throw new BadRequestException(`Unsupported notification type: ${input.type}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
recipientId: requireText(input.recipientId, 'recipientId'),
|
||||||
|
actorId: input.actorId ?? null,
|
||||||
|
type: input.type,
|
||||||
|
title: requireText(input.title, 'title'),
|
||||||
|
body: input.body ?? '',
|
||||||
|
resourceType: requireText(input.resourceType, 'resourceType'),
|
||||||
|
resourceId: requireText(input.resourceId, 'resourceId'),
|
||||||
|
resourceVersionId: input.resourceVersionId ?? null,
|
||||||
|
productId: input.productId ?? null,
|
||||||
|
projectId: input.projectId ?? null,
|
||||||
|
versionId: input.versionId ?? null,
|
||||||
|
metadata: input.metadata ?? {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLimit(limit?: number): number {
|
||||||
|
if (!Number.isFinite(limit)) return 50;
|
||||||
|
return Math.max(1, Math.min(100, Math.floor(limit as number)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireText(value: string | undefined | null, field: string): string {
|
||||||
|
const normalized = value?.trim();
|
||||||
|
if (!normalized) throw new BadRequestException(`${field} is required`);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user