Skip to content

Commit 6c3b996

Browse files
feat: Implement notification system with document permissions and SSE support
- Added Notification and NotificationPreference models for user notifications. - Implemented document-level permissions with the ability to restrict access. - Introduced SSE endpoint for real-time notification delivery. - Enhanced DocumentsService to handle comment notifications and document permission management. - Updated API with new endpoints for managing document permissions and user notifications. - Improved search functionality with full-text search capabilities for documents. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b528611 commit 6c3b996

49 files changed

Lines changed: 3770 additions & 412 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/settings.local.json

Lines changed: 0 additions & 83 deletions
This file was deleted.

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ node_modules/
33
.pnpm-store/
44

55
.claude/
6-
6+
.claude/settings.local.json
77
# Build outputs
88
dist/
99
build/
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
-- CreateEnum
2+
CREATE TYPE "NotificationType" AS ENUM ('SPACE_INVITATION', 'INVITATION_ACCEPTED', 'MEMBER_JOINED', 'MEMBER_REMOVED', 'ROLE_CHANGED', 'DOCUMENT_COMMENTED', 'DOCUMENT_MENTIONED', 'DOCUMENT_SHARED', 'DOCUMENT_UPDATED', 'SPACE_DELETED', 'SYSTEM');
3+
4+
-- CreateTable
5+
CREATE TABLE "notifications" (
6+
"id" TEXT NOT NULL,
7+
"recipientId" TEXT NOT NULL,
8+
"type" "NotificationType" NOT NULL,
9+
"title" TEXT NOT NULL,
10+
"content" TEXT,
11+
"isRead" BOOLEAN NOT NULL DEFAULT false,
12+
"entityType" "EntityType",
13+
"entityId" TEXT,
14+
"spaceId" TEXT,
15+
"actorId" TEXT,
16+
"actorName" TEXT,
17+
"metadata" TEXT,
18+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
19+
20+
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
21+
);
22+
23+
-- CreateTable
24+
CREATE TABLE "notification_preferences" (
25+
"id" TEXT NOT NULL,
26+
"userId" TEXT NOT NULL,
27+
"preferences" TEXT NOT NULL,
28+
29+
CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id")
30+
);
31+
32+
-- CreateIndex
33+
CREATE INDEX "notifications_recipientId_isRead_createdAt_idx" ON "notifications"("recipientId", "isRead", "createdAt" DESC);
34+
35+
-- CreateIndex
36+
CREATE INDEX "notifications_recipientId_createdAt_idx" ON "notifications"("recipientId", "createdAt" DESC);
37+
38+
-- CreateIndex
39+
CREATE UNIQUE INDEX "notification_preferences_userId_key" ON "notification_preferences"("userId");
40+
41+
-- AddForeignKey
42+
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
43+
44+
-- AddForeignKey
45+
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
-- CreateEnum
2+
CREATE TYPE "DocumentAccessLevel" AS ENUM ('EDITOR', 'VIEWER');
3+
4+
-- AlterTable
5+
ALTER TABLE "documents" ADD COLUMN "isRestricted" BOOLEAN NOT NULL DEFAULT false;
6+
7+
-- CreateTable
8+
CREATE TABLE "document_permissions" (
9+
"id" TEXT NOT NULL,
10+
"documentId" TEXT NOT NULL,
11+
"userId" TEXT NOT NULL,
12+
"permission" "DocumentAccessLevel" NOT NULL DEFAULT 'EDITOR',
13+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14+
15+
CONSTRAINT "document_permissions_pkey" PRIMARY KEY ("id")
16+
);
17+
18+
-- CreateIndex
19+
CREATE INDEX "document_permissions_documentId_idx" ON "document_permissions"("documentId");
20+
21+
-- CreateIndex
22+
CREATE INDEX "document_permissions_userId_idx" ON "document_permissions"("userId");
23+
24+
-- CreateIndex
25+
CREATE UNIQUE INDEX "document_permissions_documentId_userId_key" ON "document_permissions"("documentId", "userId");
26+
27+
-- AddForeignKey
28+
ALTER TABLE "document_permissions" ADD CONSTRAINT "document_permissions_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "documents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
29+
30+
-- AddForeignKey
31+
ALTER TABLE "document_permissions" ADD CONSTRAINT "document_permissions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- 添加搜索向量列
2+
ALTER TABLE documents ADD COLUMN IF NOT EXISTS search_vector tsvector;
3+
4+
-- 创建 GIN 索引(加速全文搜索)
5+
CREATE INDEX IF NOT EXISTS idx_documents_search_vector ON documents USING GIN(search_vector);
6+
7+
-- 创建触发函数:在 INSERT 或 UPDATE title/content 时自动更新 search_vector
8+
-- 使用 'simple' 配置(不分词,按空格/标点拆分),中英文通用
9+
-- 标题权重 A(最高),内容权重 B
10+
CREATE OR REPLACE FUNCTION update_document_search_vector()
11+
RETURNS TRIGGER AS $$
12+
BEGIN
13+
NEW.search_vector :=
14+
setweight(to_tsvector('simple', COALESCE(NEW.title, '')), 'A') ||
15+
setweight(to_tsvector('simple', COALESCE(NEW.content, '')), 'B');
16+
RETURN NEW;
17+
END;
18+
$$ LANGUAGE plpgsql;
19+
20+
-- 创建触发器
21+
DROP TRIGGER IF EXISTS trg_document_search_vector ON documents;
22+
CREATE TRIGGER trg_document_search_vector
23+
BEFORE INSERT OR UPDATE OF title, content ON documents
24+
FOR EACH ROW EXECUTE FUNCTION update_document_search_vector();
25+
26+
-- 回填已有数据的 search_vector
27+
UPDATE documents SET search_vector =
28+
setweight(to_tsvector('simple', COALESCE(title, '')), 'A') ||
29+
setweight(to_tsvector('simple', COALESCE(content, '')), 'B');
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Please do not edit this file manually
2-
# It should be added in your version-control system (e.g., Git)
3-
provider = "postgresql"
2+
# It should be added in your version-control system (i.e. Git)
3+
provider = "postgresql"

apps/api/prisma/schema.prisma

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ model User {
3232
documentVisits DocumentVisit[]
3333
templates DocumentTemplate[]
3434
favorites DocumentFavorite[]
35+
notifications Notification[] @relation("UserNotifications")
36+
notificationPreference NotificationPreference? @relation("UserNotificationPreference")
37+
documentPermissions DocumentPermission[]
3538
3639
@@map("users")
3740
}
@@ -72,7 +75,9 @@ model Document {
7275
createdBy String
7376
createdAt DateTime @default(now())
7477
updatedAt DateTime @updatedAt
75-
deletedAt DateTime? // 软删除:非空表示已移至回收站
78+
deletedAt DateTime? // 软删除:非空表示已移至回收站
79+
isRestricted Boolean @default(false) // 是否启用文档级权限(默认继承空间权限)
80+
search_vector Unsupported("tsvector")? // PostgreSQL 全文搜索向量(由 DB 触发器自动维护)
7681
7782
// 关系
7883
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
@@ -83,6 +88,7 @@ model Document {
8388
snapshots DocumentSnapshot[]
8489
visits DocumentVisit[]
8590
favorites DocumentFavorite[]
91+
documentPermissions DocumentPermission[]
8692
8793
@@index([spaceId])
8894
@@index([spaceId, deletedAt])
@@ -300,6 +306,82 @@ enum TemplateCategory {
300306
OTHER
301307
}
302308

309+
// ==================== 文档权限模型 ====================
310+
model DocumentPermission {
311+
id String @id @default(cuid())
312+
documentId String
313+
userId String
314+
permission DocumentAccessLevel @default(EDITOR)
315+
createdAt DateTime @default(now())
316+
317+
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
318+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
319+
320+
@@unique([documentId, userId])
321+
@@index([documentId])
322+
@@index([userId])
323+
@@map("document_permissions")
324+
}
325+
326+
enum DocumentAccessLevel {
327+
EDITOR
328+
VIEWER
329+
}
330+
331+
// ==================== 通知模型 ====================
332+
model Notification {
333+
id String @id @default(cuid())
334+
recipientId String
335+
type NotificationType
336+
title String
337+
content String?
338+
isRead Boolean @default(false)
339+
340+
// 关联上下文 — 点击通知时可跳转
341+
entityType EntityType?
342+
entityId String?
343+
spaceId String?
344+
345+
// 触发者信息(冗余存储,避免关联查询)
346+
actorId String?
347+
actorName String?
348+
349+
metadata String? @db.Text
350+
createdAt DateTime @default(now())
351+
352+
recipient User @relation("UserNotifications", fields: [recipientId], references: [id], onDelete: Cascade)
353+
354+
@@index([recipientId, isRead, createdAt(sort: Desc)])
355+
@@index([recipientId, createdAt(sort: Desc)])
356+
@@map("notifications")
357+
}
358+
359+
// 通知类型枚举
360+
enum NotificationType {
361+
SPACE_INVITATION
362+
INVITATION_ACCEPTED
363+
MEMBER_JOINED
364+
MEMBER_REMOVED
365+
ROLE_CHANGED
366+
DOCUMENT_COMMENTED
367+
DOCUMENT_MENTIONED
368+
DOCUMENT_SHARED
369+
DOCUMENT_UPDATED
370+
SPACE_DELETED
371+
SYSTEM
372+
}
373+
374+
// ==================== 通知偏好模型 ====================
375+
model NotificationPreference {
376+
id String @id @default(cuid())
377+
userId String @unique
378+
preferences String @db.Text // JSON: { "SPACE_INVITATION": true, ... }
379+
380+
user User @relation("UserNotificationPreference", fields: [userId], references: [id], onDelete: Cascade)
381+
382+
@@map("notification_preferences")
383+
}
384+
303385
// ==================== 文档收藏模型 ====================
304386
model DocumentFavorite {
305387
id String @id @default(cuid())

apps/api/src/app.module.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { SnapshotsModule } from './snapshots/snapshots.module';
2020
import { SearchModule } from './search/search.module';
2121
import { ActivityModule } from './activity/activity.module';
2222
import { TemplatesModule } from './templates/templates.module';
23+
import { NotificationsModule } from './notifications/notifications.module';
2324

2425
@Module({
2526
imports: [
@@ -45,13 +46,19 @@ import { TemplatesModule } from './templates/templates.module';
4546
SearchModule,
4647
ActivityModule,
4748
TemplatesModule,
49+
NotificationsModule,
4850
],
4951
controllers: [AppController],
5052
providers: [
5153
AppService,
5254
{
5355
provide: APP_PIPE,
54-
useClass: ValidationPipe,
56+
useFactory: () =>
57+
new ValidationPipe({
58+
transform: true, // 启用 class-transformer,自动将 query 字符串转为 DTO 类型
59+
transformOptions: { enableImplicitConversion: true },
60+
whitelist: true,
61+
}),
5562
},
5663
{
5764
provide: APP_GUARD,

apps/api/src/collaboration/collaboration.service.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ export class CollaborationService implements OnModuleInit, OnModuleDestroy {
224224
permissions: { where: { userId } },
225225
},
226226
},
227+
documentPermissions: { where: { userId } },
227228
},
228229
});
229230

@@ -232,15 +233,38 @@ export class CollaborationService implements OnModuleInit, OnModuleDestroy {
232233
}
233234

234235
const isOwner = doc.space.ownerId === userId;
235-
const isMember = doc.space.permissions.length > 0;
236+
const spacePermission = doc.space.permissions[0];
237+
const isMember = spacePermission != null;
236238

237239
if (!isOwner && !isMember) {
238240
throw new Error('Unauthorized: no access to this document');
239241
}
240242

241-
// Set read-only for VIEWERs
242-
const permission = doc.space.permissions[0];
243-
if (permission?.role === 'VIEWER') {
243+
// ─── 文档级权限检查 ───
244+
let readOnly = false;
245+
246+
if (doc.isRestricted) {
247+
const isOwnerOrAdmin =
248+
isOwner || spacePermission?.role === 'ADMIN';
249+
250+
if (isOwnerOrAdmin) {
251+
// OWNER/ADMIN 始终可编辑
252+
readOnly = false;
253+
} else {
254+
const docPerm = doc.documentPermissions[0];
255+
if (!docPerm) {
256+
throw new Error(
257+
'Unauthorized: no access to this restricted document',
258+
);
259+
}
260+
readOnly = docPerm.permission === 'VIEWER';
261+
}
262+
} else {
263+
// 继承空间权限
264+
readOnly = spacePermission?.role === 'VIEWER';
265+
}
266+
267+
if (readOnly) {
244268
(data as any).connection.readOnly = true;
245269
}
246270

0 commit comments

Comments
 (0)