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 { V22QueryModule } from './modules/v22-query/v22-query.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { CommentModule } from './modules/comment/comment.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: [],
|
||||
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