Skip to content

Commit 980131f

Browse files
feat: Add user onboarding, cascade deletes, dashboard redesign, and share/auth improvements
- New onboarding module: auto-create default workspace and welcome document for new users - Add cascade delete to creator relations (Document, DocumentSnapshot, DocumentTemplate) - Google OAuth: force account selection prompt to avoid silent callback failures - Allow OAuth users to delete account without password - Public share links now include document content and creator info inline - Documents API returns hasYdocData flag instead of full binary payload - Add tiptapJsonToYDocBinary utility for seeding Yjs documents from Tiptap JSON - Dashboard UI redesign with simplified layout and space color accents - Various UI improvements across settings, editor, sidebar, and share pages
1 parent f11bc79 commit 980131f

27 files changed

Lines changed: 1622 additions & 694 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- DropForeignKey
2+
ALTER TABLE "document_snapshots" DROP CONSTRAINT "document_snapshots_createdBy_fkey";
3+
4+
-- DropForeignKey
5+
ALTER TABLE "document_templates" DROP CONSTRAINT "document_templates_createdBy_fkey";
6+
7+
-- DropForeignKey
8+
ALTER TABLE "documents" DROP CONSTRAINT "documents_createdBy_fkey";
9+
10+
-- AddForeignKey
11+
ALTER TABLE "documents" ADD CONSTRAINT "documents_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
12+
13+
-- AddForeignKey
14+
ALTER TABLE "document_snapshots" ADD CONSTRAINT "document_snapshots_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
15+
16+
-- AddForeignKey
17+
ALTER TABLE "document_templates" ADD CONSTRAINT "document_templates_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

apps/api/prisma/schema.prisma

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ model Document {
105105
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
106106
parent Document? @relation("DocumentHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
107107
children Document[] @relation("DocumentHierarchy")
108-
creator User @relation("DocumentCreator", fields: [createdBy], references: [id])
108+
creator User @relation("DocumentCreator", fields: [createdBy], references: [id], onDelete: Cascade)
109109
shareLinks ShareLink[]
110110
snapshots DocumentSnapshot[]
111111
visits DocumentVisit[]
@@ -281,7 +281,7 @@ model DocumentSnapshot {
281281
ydocData Bytes? // Yjs 编码快照(可选,用于精确 Yjs 回滚)
282282
message String? // 版本说明(可选)
283283
createdBy String
284-
creator User @relation(fields: [createdBy], references: [id])
284+
creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade)
285285
createdAt DateTime @default(now())
286286
287287
@@index([docId, createdAt])
@@ -304,7 +304,7 @@ model DocumentTemplate {
304304
createdAt DateTime @default(now())
305305
updatedAt DateTime @updatedAt
306306
307-
creator User @relation(fields: [createdBy], references: [id])
307+
creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade)
308308
space Space? @relation(fields: [spaceId], references: [id], onDelete: Cascade)
309309
310310
@@index([scope, category])

apps/api/src/auth/auth.controller.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export class AuthController {
142142
@CurrentUser() user: UserResponseDto,
143143
@Body() dto: DeleteAccountDto,
144144
) {
145-
return this.authService.deleteAccount(user.id, dto.password);
145+
return this.authService.deleteAccount(user.id, dto.password ?? '');
146146
}
147147

148148
// ==================== Refresh Token ====================
@@ -169,6 +169,7 @@ export class AuthController {
169169
`${process.env.API_URL || 'http://localhost:3001'}/auth/google/callback`,
170170
response_type: 'code',
171171
scope: 'email profile',
172+
prompt: 'select_account', // 每次都显示账号选择,避免静默回调失败
172173
});
173174
const url = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
174175
return res.code(302).header('Location', url).send();

apps/api/src/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { PassportModule } from '@nestjs/passport';
44
import { AuthService } from './auth.service';
55
import { AuthController } from './auth.controller';
66
import { UsersModule } from '../users/users.module';
7+
import { OnboardingModule } from '../onboarding/onboarding.module';
78
import { JwtStrategy } from './strategies/jwt.strategy';
89
import { GoogleStrategy } from './strategies/google.strategy';
910
import { GithubStrategy } from './strategies/github.strategy';
@@ -20,6 +21,7 @@ if (process.env.GITHUB_CLIENT_ID) {
2021
@Module({
2122
imports: [
2223
UsersModule,
24+
OnboardingModule,
2325
PassportModule,
2426
JwtModule.register({
2527
secret: process.env.JWT_SECRET,

apps/api/src/auth/auth.service.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { LoginDto } from './dto/login.dto';
1010
import { ChangePasswordDto } from './dto/change-password.dto';
1111
import { PrismaService } from '../prisma/prisma.service';
1212
import { EmailService } from '../email/email.service';
13+
import { OnboardingService } from '../onboarding/onboarding.service';
1314
import { randomBytes } from 'crypto';
1415
import * as bcrypt from 'bcryptjs';
1516

@@ -20,6 +21,7 @@ export class AuthService {
2021
private jwtService: JwtService,
2122
private prisma: PrismaService,
2223
private emailService: EmailService,
24+
private onboardingService: OnboardingService,
2325
) {}
2426

2527
private async generateTokens(userId: string, email: string) {
@@ -53,6 +55,9 @@ export class AuthService {
5355
// 异步发送验证邮件(不阻塞注册响应)
5456
this.emailService.sendEmailVerification(email, name, emailVerifyToken);
5557

58+
// 异步创建默认工作空间和欢迎文档(不阻塞注册响应)
59+
this.onboardingService.setupNewUser(user.id);
60+
5661
// 生成 token 对
5762
const tokens = await this.generateTokens(user.id, user.email);
5863

@@ -255,6 +260,9 @@ export class AuthService {
255260
emailVerified: true, // OAuth 已验证邮箱
256261
},
257262
});
263+
264+
// 异步创建默认工作空间和欢迎文档(不阻塞登录响应)
265+
this.onboardingService.setupNewUser(user.id);
258266
}
259267

260268
const tokens = await this.generateTokens(user.id, user.email);
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { IsNotEmpty, IsString } from 'class-validator';
1+
import { IsOptional, IsString } from 'class-validator';
22
import { ApiProperty } from '@nestjs/swagger';
33

44
export class DeleteAccountDto {
5-
@ApiProperty({ description: '当前密码(确认身份)' })
5+
@ApiProperty({ description: '当前密码(OAuth 用户可不填)', required: false })
66
@IsString()
7-
@IsNotEmpty({ message: '密码不能为空' })
8-
password: string;
7+
@IsOptional()
8+
password?: string;
99
}

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,73 @@ function tagToTiptapType(tag: string): {
128128
return { type: tag };
129129
}
130130

131+
/**
132+
* Convert a Tiptap/ProseMirror JSON document to a Yjs state update binary.
133+
* This is the reverse of ydocUpdateToTiptapJson — used to seed documents
134+
* (e.g. onboarding welcome doc) so they load through the Yjs collab path.
135+
*/
136+
export function tiptapJsonToYDocBinary(
137+
doc: { type: string; content?: any[] },
138+
): Buffer {
139+
const ydoc = new Y.Doc();
140+
const fragment = ydoc.getXmlFragment('content');
141+
142+
ydoc.transact(() => {
143+
insertTiptapNodes(fragment, doc.content || []);
144+
});
145+
146+
const update = Y.encodeStateAsUpdate(ydoc);
147+
ydoc.destroy();
148+
return Buffer.from(update);
149+
}
150+
151+
/**
152+
* Recursively insert Tiptap JSON nodes into a Yjs XmlFragment/XmlElement.
153+
* Consecutive text nodes are merged into a single Y.XmlText (matching
154+
* y-prosemirror's internal representation).
155+
*/
156+
function insertTiptapNodes(
157+
parent: Y.XmlFragment | Y.XmlElement,
158+
nodes: any[],
159+
): void {
160+
let currentText: Y.XmlText | null = null;
161+
162+
for (const node of nodes) {
163+
if (node.type === 'text') {
164+
if (!currentText) {
165+
currentText = new Y.XmlText();
166+
parent.insert(parent.length, [currentText]);
167+
}
168+
169+
const markAttrs: Record<string, any> | undefined = node.marks?.length
170+
? Object.fromEntries(
171+
node.marks.map((m: any) => [m.type, m.attrs ?? null]),
172+
)
173+
: undefined;
174+
175+
currentText.insert(currentText.length, node.text || '', markAttrs);
176+
} else {
177+
currentText = null;
178+
179+
const el = new Y.XmlElement(node.type);
180+
181+
if (node.attrs) {
182+
for (const [key, value] of Object.entries(node.attrs)) {
183+
if (value !== null && value !== undefined) {
184+
el.setAttribute(key, value as any);
185+
}
186+
}
187+
}
188+
189+
if (node.content) {
190+
insertTiptapNodes(el, node.content);
191+
}
192+
193+
parent.insert(parent.length, [el]);
194+
}
195+
}
196+
}
197+
131198
/**
132199
* Extract Tiptap JSON from a full Yjs state update binary.
133200
* Returns a ProseMirror-compatible JSON doc, or null on failure.

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,9 @@ export class DocumentsService {
190190
this.activityService.recordDocumentVisit(userId, doc.id, doc.spaceId);
191191
}
192192

193-
return doc;
193+
// 返回 hasYdocData 标记(布尔值),避免向前端传输大体积 ydocData 二进制
194+
const { ydocData, ...rest } = doc;
195+
return { ...rest, hasYdocData: ydocData != null };
194196
}
195197

196198
async update(
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Module } from '@nestjs/common';
2+
import { OnboardingService } from './onboarding.service';
3+
import { SpacesModule } from '../spaces/spaces.module';
4+
import { DocumentsModule } from '../documents/documents.module';
5+
6+
@Module({
7+
imports: [SpacesModule, DocumentsModule],
8+
providers: [OnboardingService],
9+
exports: [OnboardingService],
10+
})
11+
export class OnboardingModule {}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { SpacesService } from '../spaces/spaces.service';
3+
import { DocumentsService } from '../documents/documents.service';
4+
import { PrismaService } from '../prisma/prisma.service';
5+
import { tiptapJsonToYDocBinary } from '../common/ydoc-utils';
6+
import {
7+
WELCOME_DOCUMENT_TITLE,
8+
WELCOME_DOCUMENT_CONTENT,
9+
WELCOME_CONTENT_JSON,
10+
DEFAULT_SPACE_NAME,
11+
DEFAULT_SPACE_DESCRIPTION,
12+
} from './welcome-document.seed';
13+
14+
@Injectable()
15+
export class OnboardingService {
16+
private readonly logger = new Logger(OnboardingService.name);
17+
18+
constructor(
19+
private readonly spacesService: SpacesService,
20+
private readonly documentsService: DocumentsService,
21+
private readonly prisma: PrismaService,
22+
) {}
23+
24+
/**
25+
* 为新用户创建默认工作空间和欢迎文档。
26+
* Fire-and-forget:失败仅打日志,不阻塞注册流程。
27+
*/
28+
async setupNewUser(userId: string): Promise<void> {
29+
try {
30+
// 1. 创建默认空间
31+
const space = await this.spacesService.create(userId, {
32+
name: DEFAULT_SPACE_NAME,
33+
description: DEFAULT_SPACE_DESCRIPTION,
34+
});
35+
36+
this.logger.log(
37+
`Created default space "${space.name}" (${space.id}) for user ${userId}`,
38+
);
39+
40+
// 2. 在空间中创建欢迎文档
41+
const document = await this.documentsService.create(
42+
space.id,
43+
userId,
44+
{
45+
title: WELCOME_DOCUMENT_TITLE,
46+
content: WELCOME_DOCUMENT_CONTENT,
47+
},
48+
);
49+
50+
// 3. 生成 Yjs 二进制数据,使文档通过协作路径加载(与用户手动创建的文档一致)
51+
const ydocData = tiptapJsonToYDocBinary(WELCOME_CONTENT_JSON);
52+
await this.prisma.document.update({
53+
where: { id: document.id },
54+
data: { ydocData },
55+
});
56+
57+
this.logger.log(
58+
`Created welcome document "${document.title}" (${document.id}) in space ${space.id}`,
59+
);
60+
} catch (error: unknown) {
61+
const message =
62+
error instanceof Error ? error.message : 'Unknown error';
63+
this.logger.error(
64+
`Failed to setup onboarding for user ${userId}: ${message}`,
65+
error instanceof Error ? error.stack : undefined,
66+
);
67+
// 不抛出异常 — 注册流程不受影响
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)