Skip to content

Commit c79e461

Browse files
feat: Enhance activity tracking with new models for activity logs and document visits, and implement search functionality for user documents.
1 parent a4eaf14 commit c79e461

91 files changed

Lines changed: 7912 additions & 1516 deletions

File tree

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: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,58 @@
1414
"Read(//Users/jasonchen/.claude/plugins/cache/ui-ux-pro-max-skill/ui-ux-pro-max/2.0.1/**)",
1515
"Bash(python3 skills/ui-ux-pro-max/scripts/search.py \"productivity documentation SaaS workspace dashboard modern minimal\" --design-system -p \"DocStudio\")",
1616
"Bash(python3 src/ui-ux-pro-max/scripts/search.py \"productivity documentation SaaS workspace modern\" --design-system -p \"DocStudio\")",
17-
"WebFetch(domain:www.reactbits.dev)"
17+
"WebFetch(domain:www.reactbits.dev)",
18+
"Bash(grep -r \"FloatingMenu\\\\|SlashCommand\\\\|Commands\\\\.create\\\\|suggestion\" /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/components/tiptap*)",
19+
"Bash(grep -r \"addKeyboardShortcuts\\\\|addCommands\" /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/components/tiptap*)",
20+
"Bash(grep -r \"toggleBold\\\\|toggleItalic\\\\|Ctrl\\\\|Cmd\" /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/components/tiptap*)",
21+
"Bash(find /Users/jasonchen/Desktop/self/doc_studio/apps/web/src -name \"use-mark.ts\" -exec cat {})",
22+
"Bash(wc -l apps/web/src/app/\\\\\\(main\\\\\\)/spaces/[id]/documents/[documentId]/page.tsx)",
23+
"Bash(find . -path */y-prosemirror/dist* -name *.js)",
24+
"Bash(find . -path */y-prosemirror* -name *.mjs -o -path */y-prosemirror* -name *.cjs -o -path */y-prosemirror* -name *.js)",
25+
"Bash(grep -r export.*yUndoPluginKey node_modules/.pnpm/y-prosemirror*/node_modules/y-prosemirror/)",
26+
"Bash(grep -A5 'class PluginKey' node_modules/.pnpm/prosemirror-state@*/node_modules/prosemirror-state/dist/index.js)",
27+
"Bash(grep createKey node_modules/.pnpm/prosemirror-state@*/node_modules/prosemirror-state/dist/index.js)",
28+
"Bash(grep -A3 \"^function createKey\" node_modules/.pnpm/prosemirror-state@*/node_modules/prosemirror-state/dist/index.js)",
29+
"Bash(grep -A6 \"^function createKey\" node_modules/.pnpm/prosemirror-state@*/node_modules/prosemirror-state/dist/index.js)",
30+
"Bash(node -e \":*)",
31+
"Bash(grep -A2 'get key' node_modules/.pnpm/prosemirror-state@*/node_modules/prosemirror-state/dist/index.js)",
32+
"Bash(grep -B2 -A5 \"this.key\\\\|get key\\\\|\\\\.key =\" node_modules/.pnpm/prosemirror-state@*/node_modules/prosemirror-state/dist/index.js)",
33+
"Bash(grep \"undoCommand\\\\|redoCommand\" node_modules/.pnpm/y-prosemirror@*/node_modules/y-prosemirror/dist/y-prosemirror.cjs)",
34+
"Bash(find . -path */node_modules/y-prosemirror -type d)",
35+
"Bash(find /Users/jasonchen/Desktop/self/doc_studio -path *PublicModule* -o -path *public*controller*)",
36+
"Bash(grep -r \"openGraph\\\\|og:\" /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/app --include=*.tsx)",
37+
"Bash(npx turbo:*)",
38+
"Bash(npx nest:*)",
39+
"Bash(find /Users/jasonchen/Desktop/self/doc_studio/apps/web/src -name *collaboration* -o -name *hocuspocus*)",
40+
"Bash(grep -r \"HocuspocusProvider\\\\|WebsocketProvider\\\\|Collaboration\\\\|CollaborationCursor\" /Users/jasonchen/Desktop/self/doc_studio/apps/web/src --include=*.ts --include=*.tsx)",
41+
"Bash(grep -r \"onlineUsers\\\\|connectedUsers\\\\|active.*users\\\\|collaborators\" /Users/jasonchen/Desktop/self/doc_studio/apps/web/src --include=*.ts --include=*.tsx)",
42+
"Bash(grep -r \"sharp\\\\|image.*upload\\\\|uploadImage\\\\|Image\\\\|image-extension\" /Users/jasonchen/Desktop/self/doc_studio/apps/web/src --include=*.ts --include=*.tsx)",
43+
"Bash(grep -r \"sharp\\\\|processImage\\\\|thumbnail\" /Users/jasonchen/Desktop/self/doc_studio/apps/api/src --include=*.ts)",
44+
"Bash(grep -r \"documentService.getDocument\\\\|recordDocumentVisit\\\\|activityService.recordDocumentVisit\" /Users/jasonchen/Desktop/self/doc_studio/apps/api/src --include=*.ts)",
45+
"Bash(grep -r \"YjsUpdate\\\\|yjs.*update\\\\|ydocData\" /Users/jasonchen/Desktop/self/doc_studio/apps/api/src --include=*.ts)",
46+
"Bash(grep -r \"useSearch\\\\|searchService\\\\|getRecentDocuments\" /Users/jasonchen/Desktop/self/doc_studio/apps/web/src --include=*.tsx --include=*.ts)",
47+
"Bash(find /Users/jasonchen/Desktop/self/doc_studio -type f -name *.prisma)",
48+
"Bash(grep -r \"DELETE\\\\|GET.*shares\\\\|list.*shares\" /Users/jasonchen/Desktop/self/doc_studio/apps/api/src/share --include=*.ts)",
49+
"Bash(ls -la /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/app/\\\\\\(main\\\\\\)/spaces/[id\\\\\\)/documents/)",
50+
"Bash(grep -l \"Stage 3\\\\|团队协作\\\\|stage-3\" /Users/jasonchen/Desktop/self/doc_studio/plan/*.md)",
51+
"Bash(ls /Users/jasonchen/Desktop/self/doc_studio/apps/api/tsconfig*.json)",
52+
"Bash(ls /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/components/ui/badge*)",
53+
"Bash(grep -r \"tree\\\\|getTree\" /Users/jasonchen/Desktop/self/doc_studio/apps/api/src/documents --include=*.ts)",
54+
"Bash(find /Users/jasonchen/Desktop/self/doc_studio/apps/web/src -name *analytics* -o -name *comment* -o -name *visit*)",
55+
"Bash(grep -r \"slash-commands\\\\|SlashCommands\" /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/components/tiptap-templates --include=*.ts --include=*.tsx)",
56+
"Bash(grep -r \"DocumentVisitLog\\\\|DocumentStats\\\\|CommentLike\" /Users/jasonchen/Desktop/self/doc_studio/apps --include=*.ts --include=*.tsx --include=*.prisma)",
57+
"Bash(ls /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/components/ui/tooltip* /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/components/ui/separator* /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/components/ui/toggle*)",
58+
"WebFetch(domain:tiptap.dev)",
59+
"WebFetch(domain:github.com)",
60+
"WebFetch(domain:raw.githubusercontent.com)",
61+
"Bash(ls -la /Users/jasonchen/Desktop/self/doc_studio/apps/web/src/components/tiptap-node/*/)",
62+
"Bash(curl -s http://localhost:3001/share/cmmynrvjz001diirm8m57h96j/content)",
63+
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); c=d[''''content'''']; print\\(type\\(c\\).__name__\\); nodes=[n[''''type''''] for n in c.get\\(''''content'''',[]\\)]; print\\(f''''Nodes: {len\\(nodes\\)}''''\\); print\\(nodes[:20]\\)\")",
64+
"WebFetch(domain:www.npmjs.com)",
65+
"Bash(find /Users/jasonchen/Desktop/self/doc_studio/node_modules -path */highlight.js/styles -type d)",
66+
"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\")",
67+
"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\\)$\")"
1869
]
1970
}
2071
}

apps/api/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@nestjs/platform-fastify": "^11.0.1",
4040
"@nestjs/serve-static": "^5.0.4",
4141
"@nestjs/swagger": "^11.2.5",
42+
"@nestjs/throttler": "^6.5.0",
4243
"@prisma/client": "5.22.0",
4344
"@types/minio": "^7.1.1",
4445
"bcryptjs": "^3.0.3",
@@ -53,6 +54,7 @@
5354
"prisma": "5.22.0",
5455
"reflect-metadata": "^0.2.2",
5556
"rxjs": "^7.8.1",
57+
"sharp": "^0.34.5",
5658
"yjs": "^13.6.29"
5759
},
5860
"devDependencies": {
@@ -67,6 +69,7 @@
6769
"@types/node": "^22.10.7",
6870
"@types/passport-jwt": "^4.0.1",
6971
"@types/passport-local": "^1.0.38",
72+
"@types/sharp": "^0.32.0",
7073
"@types/supertest": "^6.0.2",
7174
"dotenv-cli": "^11.0.0",
7275
"eslint": "^9.18.0",

apps/api/src/app.module.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Module, ValidationPipe } from '@nestjs/common';
2-
import { APP_PIPE } from '@nestjs/core';
2+
import { APP_PIPE, APP_GUARD } from '@nestjs/core';
33
import { AppController } from './app.controller';
44
import { AppService } from './app.service';
55
import { PrismaModule } from './prisma/prisma.module';
@@ -13,6 +13,8 @@ import { AdminModule } from './admin/admin.module';
1313
import { PublicModule } from './modules/public/public.module';
1414
import { CollaborationModule } from './collaboration/collaboration.module';
1515
import { FilesModule } from './files/files.module';
16+
import { ThrottlerModule } from '@nestjs/throttler';
17+
import { FastifyThrottlerGuard } from './common/guards/fastify-throttler.guard';
1618

1719
import { SnapshotsModule } from './snapshots/snapshots.module';
1820
import { SearchModule } from './search/search.module';
@@ -22,6 +24,13 @@ import { TemplatesModule } from './templates/templates.module';
2224
@Module({
2325
imports: [
2426
PrismaModule,
27+
// Rate Limiting — 全局默认 60次/分钟,各 endpoint 可通过 @Throttle() 单独覆盖
28+
ThrottlerModule.forRoot([
29+
{
30+
ttl: 60000, // 1 minute
31+
limit: 60, // 60 requests per minute
32+
},
33+
]),
2534
AuthModule,
2635
UsersModule,
2736
SpacesModule,
@@ -44,6 +53,10 @@ import { TemplatesModule } from './templates/templates.module';
4453
provide: APP_PIPE,
4554
useClass: ValidationPipe,
4655
},
56+
{
57+
provide: APP_GUARD,
58+
useClass: FastifyThrottlerGuard,
59+
},
4760
],
4861
})
4962
export class AppModule {}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { ThrottlerGuard } from '@nestjs/throttler';
3+
import { FastifyRequest } from 'fastify';
4+
5+
/**
6+
* Fastify 适配的 ThrottlerGuard
7+
* 默认 ThrottlerGuard 基于 Express,无法直接获取 Fastify 的 req.ip
8+
*/
9+
@Injectable()
10+
export class FastifyThrottlerGuard extends ThrottlerGuard {
11+
getTracker(req: FastifyRequest): Promise<string> {
12+
return Promise.resolve(req.ip);
13+
}
14+
}

apps/api/src/common/ydoc-utils.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import * as Y from 'yjs';
2+
3+
/**
4+
* Convert a Y.XmlFragment to a Tiptap/ProseMirror JSON document.
5+
* This mirrors what @tiptap/extension-collaboration does internally.
6+
*/
7+
export function xmlFragmentToTiptapJson(fragment: Y.XmlFragment): object {
8+
const content: object[] = [];
9+
fragment.forEach((child) => {
10+
const node = xmlElementToNode(child);
11+
if (node) content.push(node);
12+
});
13+
return { type: 'doc', content };
14+
}
15+
16+
/**
17+
* Convert a Yjs XML node to a ProseMirror/Tiptap JSON node.
18+
* Returns null for invalid nodes (e.g. empty text nodes) which ProseMirror rejects.
19+
*/
20+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
21+
function xmlElementToNode(node: any): object | null {
22+
if (node instanceof Y.XmlText) {
23+
const delta = node.toDelta() as Array<{
24+
insert?: string;
25+
attributes?: Record<string, unknown>;
26+
}>;
27+
28+
// Multi-segment delta: produce separate text nodes for each segment
29+
// But since we return a single node here, join text and handle single-segment marks
30+
if (delta.length === 1 && delta[0].insert) {
31+
const text = delta[0].insert as string;
32+
// ProseMirror does not allow empty text nodes
33+
if (!text) return null;
34+
35+
const result: Record<string, unknown> = { type: 'text', text };
36+
if (delta[0].attributes && Object.keys(delta[0].attributes).length > 0) {
37+
result.marks = Object.entries(delta[0].attributes).map(
38+
([type, attrs]) => {
39+
if (typeof attrs === 'object' && attrs !== null) {
40+
return { type, attrs };
41+
}
42+
return { type };
43+
},
44+
);
45+
}
46+
return result;
47+
}
48+
49+
// Multi-segment or empty delta
50+
const text = delta.map((d) => d.insert ?? '').join('');
51+
// ProseMirror does not allow empty text nodes
52+
if (!text) return null;
53+
return { type: 'text', text };
54+
}
55+
56+
if (node instanceof Y.XmlElement) {
57+
const tag = node.nodeName as string;
58+
const { type, attrs: typeAttrs } = tagToTiptapType(tag);
59+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
60+
const elementAttrs = node.getAttributes() as Record<string, unknown>;
61+
const mergedAttrs = { ...elementAttrs, ...typeAttrs };
62+
63+
// Recursively convert children, filtering out invalid (null) nodes
64+
const childContent: object[] = [];
65+
(node as unknown as Y.XmlFragment).forEach((child) => {
66+
const childNode = xmlElementToNode(child);
67+
if (childNode) childContent.push(childNode);
68+
});
69+
70+
return {
71+
type,
72+
...(Object.keys(mergedAttrs).length > 0 ? { attrs: mergedAttrs } : {}),
73+
...(childContent.length > 0 ? { content: childContent } : {}),
74+
};
75+
}
76+
77+
// Unknown node type — skip rather than producing invalid empty text
78+
return null;
79+
}
80+
81+
/**
82+
* Convert a Yjs XmlElement nodeName to a Tiptap node type.
83+
*
84+
* Tiptap collaboration stores nodes in Yjs using their original camelCase
85+
* Tiptap names (e.g. "bulletList", "taskItem", "horizontalRule").
86+
* Only a few nodes use HTML tag names (p, ul, ol, li, h1-h6, etc.).
87+
* We map the HTML tags to Tiptap names, and pass everything else through as-is.
88+
*/
89+
function tagToTiptapType(tag: string): {
90+
type: string;
91+
attrs?: Record<string, unknown>;
92+
} {
93+
// Heading tags: h1-h6
94+
const headingMatch = /^h([1-6])$/i.exec(tag);
95+
if (headingMatch) {
96+
return { type: 'heading', attrs: { level: Number(headingMatch[1]) } };
97+
}
98+
99+
// HTML tag → Tiptap type mapping (only for actual HTML tags)
100+
const htmlTagMap: Record<string, string> = {
101+
p: 'paragraph',
102+
ul: 'bulletList',
103+
ol: 'orderedList',
104+
li: 'listItem',
105+
blockquote: 'blockquote',
106+
pre: 'codeBlock',
107+
code: 'code',
108+
hr: 'horizontalRule',
109+
br: 'hardBreak',
110+
img: 'image',
111+
table: 'table',
112+
tr: 'tableRow',
113+
th: 'tableHeader',
114+
td: 'tableCell',
115+
strong: 'bold',
116+
em: 'italic',
117+
s: 'strike',
118+
u: 'underline',
119+
a: 'link',
120+
};
121+
122+
const lower = tag.toLowerCase();
123+
if (htmlTagMap[lower]) {
124+
return { type: htmlTagMap[lower] };
125+
}
126+
127+
// Already a Tiptap camelCase name (bulletList, taskItem, drawing, etc.) — pass through as-is
128+
return { type: tag };
129+
}
130+
131+
/**
132+
* Extract Tiptap JSON from a full Yjs state update binary.
133+
* Returns a ProseMirror-compatible JSON doc, or null on failure.
134+
*/
135+
export function ydocUpdateToTiptapJson(
136+
updateBytes: Uint8Array,
137+
): object | null {
138+
try {
139+
const doc = new Y.Doc();
140+
Y.applyUpdate(doc, updateBytes);
141+
142+
const fragmentNames = ['content', 'default'];
143+
let tiptapJson: object | null = null;
144+
145+
for (const name of fragmentNames) {
146+
const fragment = doc.getXmlFragment(name);
147+
if (fragment.length > 0) {
148+
tiptapJson = xmlFragmentToTiptapJson(fragment);
149+
break;
150+
}
151+
}
152+
153+
doc.destroy();
154+
return tiptapJson;
155+
} catch {
156+
return null;
157+
}
158+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,15 @@ export class DocumentsService {
9393
title: true,
9494
parentId: true,
9595
order: true,
96+
createdAt: true,
9697
updatedAt: true,
98+
creator: {
99+
select: {
100+
id: true,
101+
name: true,
102+
avatarUrl: true,
103+
},
104+
},
97105
},
98106
});
99107
return docs;

0 commit comments

Comments
 (0)