Skip to content

Commit b528611

Browse files
feat: Add trash (soft delete) and favorites features
- Trash: Document soft delete with `deletedAt` field, restore, permanent delete, trash page per space, sidebar entry, undo toast with restore action - Favorites: DocumentFavorite model, star toggle in document table (optimistic), /favorites page with paginated sortable table, Dashboard favorites section, sidebar "My Favorites" entry - API: 8 new endpoints (trash CRUD + favorites CRUD) - Space document table: pagination, sorting, delete button, favorite star - Sidebar: fixed trash at bottom, document tree scrolls independently - Document tree: updated delete dialog copy, improved spacing/typography - Plan: Stage 7 created with full product roadmap, Phase 1 & 3 marked complete Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c79e461 commit b528611

17 files changed

Lines changed: 1656 additions & 154 deletions

File tree

.claude/settings.local.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,19 @@
6565
"Bash(find /Users/jasonchen/Desktop/self/doc_studio/node_modules -path */highlight.js/styles -type d)",
6666
"Bash(python3 /Users/jasonchen/.claude/plugins/cache/ui-ux-pro-max-skill/ui-ux-pro-max/2.0.1/.claude/skills/ui-ux-pro-max/scripts/search.py \"document viewer shared reading content-first minimal elegant\" --design-system -p \"DocStudio Share Page\")",
6767
"Bash(python3 /Users/jasonchen/.claude/plugins/cache/ui-ux-pro-max-skill/ui-ux-pro-max/2.0.1/.claude/skills/ui-ux-pro-max/scripts/search.py \"content reader document elegant minimal\" --domain style -n 3)",
68-
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")"
68+
"Bash(grep -E \"\\\\.\\(ts|tsx\\)$\")",
69+
"mcp__Desktop_Commander__list_directory",
70+
"mcp__Desktop_Commander__read_file",
71+
"Bash(find /Users/jasonchen/Desktop/self/doc_studio/apps/web -type f \\\\\\(-name *sidebar* -o -name *navigation* \\\\\\))",
72+
"Bash(ls -la /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/app/\\\\\\(main\\\\\\)/dashboard/)",
73+
"Bash(ls -la /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/app/\\\\\\(main\\\\\\)/spaces/[id]/)",
74+
"Bash(npx prisma:*)",
75+
"Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/docStudio_dev?schema=public\" npx prisma migrate dev --name add-soft-delete-and-favorites --schema=apps/api/prisma/schema.prisma)",
76+
"Bash(DATABASE_URL=\"postgresql://postgres:postgres@localhost:5432/docStudio_dev?schema=public\" pnpm --filter api exec prisma generate)",
77+
"Bash(lsof -ti:3000)",
78+
"mcp__Desktop_Commander__start_search",
79+
"mcp__Desktop_Commander__stop_search",
80+
"mcp__Desktop_Commander__get_file_info"
6981
]
7082
}
7183
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
-- AlterTable
2+
ALTER TABLE "documents" ADD COLUMN "deletedAt" TIMESTAMP(3);
3+
4+
-- CreateTable
5+
CREATE TABLE "document_favorites" (
6+
"id" TEXT NOT NULL,
7+
"userId" TEXT NOT NULL,
8+
"documentId" TEXT NOT NULL,
9+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
11+
CONSTRAINT "document_favorites_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
-- CreateIndex
15+
CREATE INDEX "document_favorites_userId_createdAt_idx" ON "document_favorites"("userId", "createdAt" DESC);
16+
17+
-- CreateIndex
18+
CREATE UNIQUE INDEX "document_favorites_userId_documentId_key" ON "document_favorites"("userId", "documentId");
19+
20+
-- CreateIndex
21+
CREATE INDEX "documents_spaceId_deletedAt_idx" ON "documents"("spaceId", "deletedAt");
22+
23+
-- AddForeignKey
24+
ALTER TABLE "document_favorites" ADD CONSTRAINT "document_favorites_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
25+
26+
-- AddForeignKey
27+
ALTER TABLE "document_favorites" ADD CONSTRAINT "document_favorites_documentId_fkey" FOREIGN KEY ("documentId") REFERENCES "documents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
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 (i.e. Git)
3-
provider = "postgresql"
2+
# It should be added in your version-control system (e.g., Git)
3+
provider = "postgresql"

apps/api/prisma/schema.prisma

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ model User {
3131
activityLogs ActivityLog[]
3232
documentVisits DocumentVisit[]
3333
templates DocumentTemplate[]
34+
favorites DocumentFavorite[]
3435
3536
@@map("users")
3637
}
@@ -71,6 +72,7 @@ model Document {
7172
createdBy String
7273
createdAt DateTime @default(now())
7374
updatedAt DateTime @updatedAt
75+
deletedAt DateTime? // 软删除:非空表示已移至回收站
7476
7577
// 关系
7678
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
@@ -80,8 +82,10 @@ model Document {
8082
shareLinks ShareLink[]
8183
snapshots DocumentSnapshot[]
8284
visits DocumentVisit[]
85+
favorites DocumentFavorite[]
8386
8487
@@index([spaceId])
88+
@@index([spaceId, deletedAt])
8589
@@index([parentId])
8690
@@index([createdBy])
8791
@@map("documents")
@@ -295,3 +299,18 @@ enum TemplateCategory {
295299
GUIDE
296300
OTHER
297301
}
302+
303+
// ==================== 文档收藏模型 ====================
304+
model DocumentFavorite {
305+
id String @id @default(cuid())
306+
userId String
307+
documentId String
308+
createdAt DateTime @default(now())
309+
310+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
311+
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
312+
313+
@@unique([userId, documentId])
314+
@@index([userId, createdAt(sort: Desc)])
315+
@@map("document_favorites")
316+
}

apps/api/src/documents/documents.controller.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import {
22
Controller,
33
Get,
4-
Head,
54
Post,
65
Body,
76
Patch,
@@ -10,6 +9,7 @@ import {
109
UseGuards,
1110
Req,
1211
HttpCode,
12+
Query,
1313
NotFoundException,
1414
} from '@nestjs/common';
1515
import { DocumentsService } from './documents.service';
@@ -23,6 +23,24 @@ import { SpacePermissionGuard } from '../common/guards/space-permission.guard';
2323
export class DocumentsController {
2424
constructor(private readonly documentsService: DocumentsService) {}
2525

26+
// ─── 收藏功能(放在 :id 路由之前,避免路由冲突) ───
27+
28+
@UseGuards(JwtAuthGuard)
29+
@Get('favorites')
30+
getFavorites(@Req() req: any) {
31+
return this.documentsService.getFavorites(req.user.id);
32+
}
33+
34+
// ─── 回收站(放在 :id 路由之前) ───
35+
36+
@UseGuards(JwtAuthGuard, SpacePermissionGuard)
37+
@Get('trash')
38+
findTrash(@Query('spaceId') spaceId: string) {
39+
return this.documentsService.findTrash(spaceId);
40+
}
41+
42+
// ─── CRUD ───
43+
2644
@UseGuards(JwtAuthGuard, SpacePermissionGuard)
2745
@Post()
2846
create(@Body() createDocumentDto: CreateDocumentDto, @Req() req: any) {
@@ -73,9 +91,38 @@ export class DocumentsController {
7391
return this.documentsService.move(id, moveDocumentDto);
7492
}
7593

94+
/** 软删除(移至回收站) */
7695
@UseGuards(JwtAuthGuard, SpacePermissionGuard)
7796
@Delete(':id')
7897
remove(@Param('id') id: string, @Req() req: any) {
7998
return this.documentsService.remove(id, req.user.id);
8099
}
100+
101+
/** 恢复文档 */
102+
@UseGuards(JwtAuthGuard, SpacePermissionGuard)
103+
@Post(':id/restore')
104+
restore(@Param('id') id: string, @Req() req: any) {
105+
return this.documentsService.restore(id, req.user.id);
106+
}
107+
108+
/** 永久删除 */
109+
@UseGuards(JwtAuthGuard, SpacePermissionGuard)
110+
@Delete(':id/permanent')
111+
permanentlyDelete(@Param('id') id: string) {
112+
return this.documentsService.permanentlyDelete(id);
113+
}
114+
115+
// ─── 收藏操作 ───
116+
117+
@UseGuards(JwtAuthGuard)
118+
@Post(':id/favorite')
119+
favorite(@Param('id') id: string, @Req() req: any) {
120+
return this.documentsService.favorite(id, req.user.id);
121+
}
122+
123+
@UseGuards(JwtAuthGuard)
124+
@Delete(':id/favorite')
125+
unfavorite(@Param('id') id: string, @Req() req: any) {
126+
return this.documentsService.unfavorite(id, req.user.id);
127+
}
81128
}

apps/api/src/documents/documents.service.ts

Lines changed: 173 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export class DocumentsService {
8686

8787
async findAll(spaceId: string) {
8888
const docs = await this.prisma.document.findMany({
89-
where: { spaceId },
89+
where: { spaceId, deletedAt: null },
9090
orderBy: { order: 'asc' },
9191
select: {
9292
id: true,
@@ -109,7 +109,7 @@ export class DocumentsService {
109109

110110
/** Lightweight existence check — no side effects, no relations loaded */
111111
async exists(id: string): Promise<boolean> {
112-
const count = await this.prisma.document.count({ where: { id } });
112+
const count = await this.prisma.document.count({ where: { id, deletedAt: null } });
113113
return count > 0;
114114
}
115115

@@ -177,18 +177,33 @@ export class DocumentsService {
177177
return doc;
178178
}
179179

180+
/** 软删除:将文档移至回收站 */
180181
async remove(id: string, userId?: string) {
181182
const doc = await this.prisma.document.findUnique({
182183
where: { id },
183184
include: { space: { select: { name: true } } },
184185
});
185186

186-
const result = await this.prisma.document.delete({
187-
where: { id },
188-
});
187+
if (!doc) {
188+
throw new NotFoundException(`Document with ID ${id} not found`);
189+
}
190+
191+
// 软删除:设置 deletedAt,同时将子文档一并软删除
192+
const now = new Date();
193+
await this.prisma.$transaction([
194+
this.prisma.document.update({
195+
where: { id },
196+
data: { deletedAt: now },
197+
}),
198+
// 子文档也一并移入回收站
199+
this.prisma.document.updateMany({
200+
where: { parentId: id, deletedAt: null },
201+
data: { deletedAt: now },
202+
}),
203+
]);
189204

190205
// 记录删除活动
191-
if (userId && doc) {
206+
if (userId) {
192207
this.activityService.log({
193208
userId,
194209
action: ActivityAction.DELETE,
@@ -200,7 +215,158 @@ export class DocumentsService {
200215
});
201216
}
202217

203-
return result;
218+
return { success: true };
219+
}
220+
221+
/** 获取空间回收站列表 */
222+
async findTrash(spaceId: string) {
223+
return this.prisma.document.findMany({
224+
where: {
225+
spaceId,
226+
deletedAt: { not: null },
227+
},
228+
orderBy: { deletedAt: 'desc' },
229+
select: {
230+
id: true,
231+
title: true,
232+
parentId: true,
233+
deletedAt: true,
234+
createdAt: true,
235+
updatedAt: true,
236+
creator: {
237+
select: {
238+
id: true,
239+
name: true,
240+
avatarUrl: true,
241+
},
242+
},
243+
},
244+
});
245+
}
246+
247+
/** 恢复文档(从回收站) */
248+
async restore(id: string, userId?: string) {
249+
const doc = await this.prisma.document.findUnique({
250+
where: { id },
251+
include: { space: { select: { name: true } } },
252+
});
253+
254+
if (!doc) {
255+
throw new NotFoundException(`Document with ID ${id} not found`);
256+
}
257+
if (!doc.deletedAt) {
258+
throw new BadRequestException('Document is not in trash');
259+
}
260+
261+
// 恢复文档及其子文档
262+
await this.prisma.$transaction([
263+
this.prisma.document.update({
264+
where: { id },
265+
data: { deletedAt: null },
266+
}),
267+
this.prisma.document.updateMany({
268+
where: { parentId: id, deletedAt: doc.deletedAt },
269+
data: { deletedAt: null },
270+
}),
271+
]);
272+
273+
// 记录恢复活动
274+
if (userId) {
275+
this.activityService.log({
276+
userId,
277+
action: ActivityAction.RESTORE,
278+
entityType: EntityType.DOCUMENT,
279+
entityId: id,
280+
entityName: doc.title,
281+
spaceId: doc.spaceId,
282+
spaceName: doc.space.name,
283+
});
284+
}
285+
286+
return { success: true };
287+
}
288+
289+
/** 永久删除(从回收站彻底删除) */
290+
async permanentlyDelete(id: string) {
291+
const doc = await this.prisma.document.findUnique({ where: { id } });
292+
if (!doc) {
293+
throw new NotFoundException(`Document with ID ${id} not found`);
294+
}
295+
if (!doc.deletedAt) {
296+
throw new BadRequestException(
297+
'Document must be in trash before permanent deletion',
298+
);
299+
}
300+
301+
return this.prisma.document.delete({ where: { id } });
302+
}
303+
304+
// ─── 收藏功能 ───────────────────────────────────────────
305+
306+
/** 收藏文档 */
307+
async favorite(documentId: string, userId: string) {
308+
// upsert 避免重复
309+
return this.prisma.documentFavorite.upsert({
310+
where: { userId_documentId: { userId, documentId } },
311+
create: { userId, documentId },
312+
update: {},
313+
});
314+
}
315+
316+
/** 取消收藏 */
317+
async unfavorite(documentId: string, userId: string) {
318+
return this.prisma.documentFavorite
319+
.delete({
320+
where: { userId_documentId: { userId, documentId } },
321+
})
322+
.catch(() => {
323+
// 不存在也不报错
324+
return null;
325+
});
326+
}
327+
328+
/** 获取收藏列表 */
329+
async getFavorites(userId: string) {
330+
const favorites = await this.prisma.documentFavorite.findMany({
331+
where: { userId },
332+
orderBy: { createdAt: 'desc' },
333+
include: {
334+
document: {
335+
select: {
336+
id: true,
337+
title: true,
338+
spaceId: true,
339+
updatedAt: true,
340+
deletedAt: true,
341+
creator: {
342+
select: {
343+
id: true,
344+
name: true,
345+
avatarUrl: true,
346+
},
347+
},
348+
},
349+
},
350+
},
351+
});
352+
353+
// 过滤掉已删除的文档
354+
return favorites
355+
.filter((f) => f.document.deletedAt === null)
356+
.map((f) => ({
357+
id: f.id,
358+
documentId: f.documentId,
359+
createdAt: f.createdAt,
360+
document: f.document,
361+
}));
362+
}
363+
364+
/** 检查是否已收藏 */
365+
async isFavorited(documentId: string, userId: string): Promise<boolean> {
366+
const count = await this.prisma.documentFavorite.count({
367+
where: { userId, documentId },
368+
});
369+
return count > 0;
204370
}
205371

206372
/**

0 commit comments

Comments
 (0)