@goscribe/server 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context.d.ts +1 -1
- package/dist/context.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/file.d.ts.map +1 -0
- package/dist/lib/prisma.d.ts.map +1 -0
- package/dist/lib/storage.d.ts.map +1 -0
- package/dist/routers/_app.d.ts +268 -10
- package/dist/routers/_app.d.ts.map +1 -0
- package/dist/routers/_app.js +5 -1
- package/dist/routers/auth.d.ts +11 -6
- package/dist/routers/auth.d.ts.map +1 -0
- package/dist/routers/auth.js +15 -1
- package/dist/routers/flashcards.d.ts +124 -0
- package/dist/routers/flashcards.js +127 -0
- package/dist/routers/sample.js +21 -0
- package/dist/routers/worksheets.d.ts +129 -0
- package/dist/routers/worksheets.js +139 -0
- package/dist/routers/workspace.d.ts +3 -3
- package/dist/routers/workspace.d.ts.map +1 -0
- package/dist/routers/workspace.js +50 -36
- package/dist/server.d.ts.map +1 -0
- package/dist/trpc.d.ts +5 -5
- package/dist/trpc.d.ts.map +1 -0
- package/package.json +3 -2
- package/src/context.ts +1 -0
- package/src/lib/auth.ts +17 -5
- package/src/routers/_app.ts +5 -1
- package/src/routers/auth.ts +58 -20
- package/src/routers/flashcards.ts +135 -0
- package/src/routers/studyguide.ts +0 -0
- package/src/routers/worksheets.ts +149 -0
- package/src/routers/workspace.ts +47 -39
- package/src/server.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goscribe/server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"@google-cloud/storage": "^7.17.0",
|
|
25
25
|
"@prisma/client": "^6.14.0",
|
|
26
26
|
"@trpc/server": "^11.5.0",
|
|
27
|
-
"@types/cookie": "^1.0.0",
|
|
28
27
|
"bcryptjs": "^3.0.2",
|
|
29
28
|
"compression": "^1.8.1",
|
|
30
29
|
"cookie": "^1.0.2",
|
|
31
30
|
"cors": "^2.8.5",
|
|
31
|
+
"dotenv": "^17.2.1",
|
|
32
32
|
"express": "^5.1.0",
|
|
33
33
|
"helmet": "^8.1.0",
|
|
34
34
|
"morgan": "^1.10.1",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/compression": "^1.8.1",
|
|
41
|
+
"@types/cookie": "^1.0.0",
|
|
41
42
|
"@types/cors": "^2.8.19",
|
|
42
43
|
"@types/express": "^5.0.3",
|
|
43
44
|
"@types/morgan": "^1.9.10",
|
package/src/context.ts
CHANGED
|
@@ -9,6 +9,7 @@ export async function createContext({ req, res }: CreateExpressContextOptions) {
|
|
|
9
9
|
|
|
10
10
|
// Only use custom auth cookie
|
|
11
11
|
const custom = verifyCustomAuthCookie(cookies["auth_token"]);
|
|
12
|
+
|
|
12
13
|
if (custom) {
|
|
13
14
|
return { db: prisma, session: { user: { id: custom.userId } } as any, req, res, cookies };
|
|
14
15
|
}
|
package/src/lib/auth.ts
CHANGED
|
@@ -3,26 +3,38 @@ import crypto from "node:crypto";
|
|
|
3
3
|
|
|
4
4
|
// Custom HMAC cookie: auth_token = base64(userId).hex(hmacSHA256(base64(userId), secret))
|
|
5
5
|
export function verifyCustomAuthCookie(cookieValue: string | undefined): { userId: string } | null {
|
|
6
|
-
if (!cookieValue)
|
|
6
|
+
if (!cookieValue) {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
7
10
|
const secret = process.env.CUSTOM_AUTH_SECRET;
|
|
8
|
-
|
|
11
|
+
|
|
12
|
+
if (!secret) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
9
15
|
|
|
10
16
|
const parts = cookieValue.split(".");
|
|
11
|
-
|
|
17
|
+
|
|
18
|
+
if (parts.length !== 2) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
12
21
|
const [base64UserId, signatureHex] = parts;
|
|
13
22
|
|
|
14
23
|
let userId: string;
|
|
15
24
|
try {
|
|
16
25
|
const buf = Buffer.from(base64UserId, "base64url");
|
|
17
26
|
userId = buf.toString("utf8");
|
|
18
|
-
} catch {
|
|
27
|
+
} catch (error) {
|
|
19
28
|
return null;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
const hmac = crypto.createHmac("sha256", secret);
|
|
23
32
|
hmac.update(base64UserId);
|
|
24
33
|
const expected = hmac.digest("hex");
|
|
25
|
-
|
|
34
|
+
|
|
35
|
+
if (!timingSafeEqualHex(signatureHex, expected)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
26
38
|
|
|
27
39
|
return { userId };
|
|
28
40
|
}
|
package/src/routers/_app.ts
CHANGED
|
@@ -2,10 +2,14 @@ import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
|
|
|
2
2
|
import { router } from '../trpc.js';
|
|
3
3
|
import { auth } from './auth.js';
|
|
4
4
|
import { workspace } from './workspace.js';
|
|
5
|
+
import { flashcards } from './flashcards.js';
|
|
6
|
+
import { worksheets } from './worksheets.js';
|
|
5
7
|
|
|
6
8
|
export const appRouter = router({
|
|
7
9
|
auth,
|
|
8
|
-
workspace
|
|
10
|
+
workspace,
|
|
11
|
+
flashcards,
|
|
12
|
+
worksheets,
|
|
9
13
|
});
|
|
10
14
|
|
|
11
15
|
// Export type for client inference
|
package/src/routers/auth.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { router, publicProcedure, authedProcedure } from '../trpc.js';
|
|
3
3
|
import bcrypt from 'bcryptjs';
|
|
4
|
+
import { serialize } from 'cookie';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
// Helper to create custom auth token
|
|
8
|
+
function createCustomAuthToken(userId: string): string {
|
|
9
|
+
const secret = process.env.AUTH_SECRET;
|
|
10
|
+
if (!secret) {
|
|
11
|
+
throw new Error("AUTH_SECRET is not set");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const base64UserId = Buffer.from(userId, 'utf8').toString('base64url');
|
|
15
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
16
|
+
hmac.update(base64UserId);
|
|
17
|
+
const signature = hmac.digest('hex');
|
|
18
|
+
return `${base64UserId}.${signature}`;
|
|
19
|
+
}
|
|
4
20
|
|
|
5
21
|
export const auth = router({
|
|
6
22
|
signup: publicProcedure
|
|
@@ -48,39 +64,61 @@ export const auth = router({
|
|
|
48
64
|
throw new Error("Invalid credentials");
|
|
49
65
|
}
|
|
50
66
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
// Create custom auth token
|
|
68
|
+
const authToken = createCustomAuthToken(user.id);
|
|
69
|
+
|
|
70
|
+
// Set the cookie immediately after successful login
|
|
71
|
+
const cookieValue = serialize("auth_token", authToken, {
|
|
72
|
+
httpOnly: true,
|
|
73
|
+
secure: process.env.NODE_ENV === "production",
|
|
74
|
+
sameSite: "lax",
|
|
75
|
+
path: "/",
|
|
76
|
+
maxAge: 60 * 60 * 24 * 30, // 30 days
|
|
56
77
|
});
|
|
78
|
+
|
|
79
|
+
ctx.res.setHeader("Set-Cookie", cookieValue);
|
|
57
80
|
|
|
58
|
-
return {
|
|
81
|
+
return {
|
|
82
|
+
id: user.id,
|
|
83
|
+
email: user.email,
|
|
84
|
+
name: user.name,
|
|
85
|
+
image: user.image
|
|
86
|
+
};
|
|
59
87
|
}),
|
|
60
88
|
getSession: publicProcedure.query(async ({ ctx }) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
},
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
if (!session) {
|
|
68
|
-
throw new Error("Session not found");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (session.expires < new Date()) {
|
|
72
|
-
throw new Error("Session expired");
|
|
89
|
+
// Just return the current session from context
|
|
90
|
+
if (!ctx.session) {
|
|
91
|
+
throw new Error("No session found");
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
const user = await ctx.db.user.findUnique({
|
|
76
|
-
where: { id: session.
|
|
95
|
+
where: { id: (ctx.session as any).user.id },
|
|
77
96
|
});
|
|
78
97
|
|
|
79
98
|
if (!user) {
|
|
80
99
|
throw new Error("User not found");
|
|
81
100
|
}
|
|
82
101
|
|
|
83
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
user: {
|
|
104
|
+
id: user.id,
|
|
105
|
+
email: user.email,
|
|
106
|
+
name: user.name,
|
|
107
|
+
image: user.image
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}),
|
|
111
|
+
logout: publicProcedure.mutation(async ({ ctx }) => {
|
|
112
|
+
// Clear the auth cookie
|
|
113
|
+
ctx.res.setHeader("Set-Cookie", serialize("auth_token", "", {
|
|
114
|
+
httpOnly: true,
|
|
115
|
+
secure: process.env.NODE_ENV === "production",
|
|
116
|
+
sameSite: "lax",
|
|
117
|
+
path: "/",
|
|
118
|
+
maxAge: 0, // Expire immediately
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
return { success: true };
|
|
84
122
|
}),
|
|
85
123
|
});
|
|
86
124
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { router, authedProcedure } from '../trpc.js';
|
|
4
|
+
// Prisma enum values mapped manually to avoid type import issues in ESM
|
|
5
|
+
const ArtifactType = {
|
|
6
|
+
STUDY_GUIDE: 'STUDY_GUIDE',
|
|
7
|
+
FLASHCARD_SET: 'FLASHCARD_SET',
|
|
8
|
+
WORKSHEET: 'WORKSHEET',
|
|
9
|
+
MEETING_SUMMARY: 'MEETING_SUMMARY',
|
|
10
|
+
PODCAST_EPISODE: 'PODCAST_EPISODE',
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export const flashcards = router({
|
|
14
|
+
listSets: authedProcedure
|
|
15
|
+
.input(z.object({ workspaceId: z.string().uuid() }))
|
|
16
|
+
.query(async ({ ctx, input }) => {
|
|
17
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
18
|
+
where: { id: input.workspaceId, ownerId: ctx.session.user.id },
|
|
19
|
+
});
|
|
20
|
+
if (!workspace) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
21
|
+
return ctx.db.artifact.findMany({
|
|
22
|
+
where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET },
|
|
23
|
+
include: {
|
|
24
|
+
versions: {
|
|
25
|
+
orderBy: { version: 'desc' },
|
|
26
|
+
take: 1, // Get only the latest version
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
orderBy: { updatedAt: 'desc' },
|
|
30
|
+
});
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
createSet: authedProcedure
|
|
34
|
+
.input(z.object({ workspaceId: z.string().uuid(), title: z.string().min(1).max(120) }))
|
|
35
|
+
.mutation(async ({ ctx, input }) => {
|
|
36
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
37
|
+
where: { id: input.workspaceId, ownerId: ctx.session.user.id },
|
|
38
|
+
});
|
|
39
|
+
if (!workspace) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
40
|
+
return ctx.db.artifact.create({
|
|
41
|
+
data: {
|
|
42
|
+
workspaceId: input.workspaceId,
|
|
43
|
+
type: ArtifactType.FLASHCARD_SET,
|
|
44
|
+
title: input.title,
|
|
45
|
+
createdById: ctx.session.user.id,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}),
|
|
49
|
+
|
|
50
|
+
getSet: authedProcedure
|
|
51
|
+
.input(z.object({ setId: z.string().uuid() }))
|
|
52
|
+
.query(async ({ ctx, input }) => {
|
|
53
|
+
const set = await ctx.db.artifact.findFirst({
|
|
54
|
+
where: {
|
|
55
|
+
id: input.setId,
|
|
56
|
+
type: ArtifactType.FLASHCARD_SET,
|
|
57
|
+
workspace: { ownerId: ctx.session.user.id },
|
|
58
|
+
},
|
|
59
|
+
include: { flashcards: true },
|
|
60
|
+
});
|
|
61
|
+
if (!set) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
62
|
+
return set;
|
|
63
|
+
}),
|
|
64
|
+
|
|
65
|
+
createCard: authedProcedure
|
|
66
|
+
.input(z.object({
|
|
67
|
+
setId: z.string().uuid(),
|
|
68
|
+
front: z.string().min(1),
|
|
69
|
+
back: z.string().min(1),
|
|
70
|
+
tags: z.array(z.string()).optional(),
|
|
71
|
+
order: z.number().int().optional(),
|
|
72
|
+
}))
|
|
73
|
+
.mutation(async ({ ctx, input }) => {
|
|
74
|
+
const set = await ctx.db.artifact.findFirst({
|
|
75
|
+
where: { id: input.setId, type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } },
|
|
76
|
+
});
|
|
77
|
+
if (!set) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
78
|
+
return ctx.db.flashcard.create({
|
|
79
|
+
data: {
|
|
80
|
+
artifactId: input.setId,
|
|
81
|
+
front: input.front,
|
|
82
|
+
back: input.back,
|
|
83
|
+
tags: input.tags ?? [],
|
|
84
|
+
order: input.order ?? 0,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}),
|
|
88
|
+
|
|
89
|
+
updateCard: authedProcedure
|
|
90
|
+
.input(z.object({
|
|
91
|
+
cardId: z.string().uuid(),
|
|
92
|
+
front: z.string().optional(),
|
|
93
|
+
back: z.string().optional(),
|
|
94
|
+
tags: z.array(z.string()).optional(),
|
|
95
|
+
order: z.number().int().optional(),
|
|
96
|
+
}))
|
|
97
|
+
.mutation(async ({ ctx, input }) => {
|
|
98
|
+
const card = await ctx.db.flashcard.findFirst({
|
|
99
|
+
where: { id: input.cardId, artifact: { type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } } },
|
|
100
|
+
});
|
|
101
|
+
if (!card) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
102
|
+
return ctx.db.flashcard.update({
|
|
103
|
+
where: { id: input.cardId },
|
|
104
|
+
data: {
|
|
105
|
+
front: input.front ?? card.front,
|
|
106
|
+
back: input.back ?? card.back,
|
|
107
|
+
tags: input.tags ?? card.tags,
|
|
108
|
+
order: input.order ?? card.order,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}),
|
|
112
|
+
|
|
113
|
+
deleteCard: authedProcedure
|
|
114
|
+
.input(z.object({ cardId: z.string().uuid() }))
|
|
115
|
+
.mutation(async ({ ctx, input }) => {
|
|
116
|
+
const card = await ctx.db.flashcard.findFirst({
|
|
117
|
+
where: { id: input.cardId, artifact: { workspace: { ownerId: ctx.session.user.id } } },
|
|
118
|
+
});
|
|
119
|
+
if (!card) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
120
|
+
await ctx.db.flashcard.delete({ where: { id: input.cardId } });
|
|
121
|
+
return true;
|
|
122
|
+
}),
|
|
123
|
+
|
|
124
|
+
deleteSet: authedProcedure
|
|
125
|
+
.input(z.object({ setId: z.string().uuid() }))
|
|
126
|
+
.mutation(async ({ ctx, input }) => {
|
|
127
|
+
const deleted = await ctx.db.artifact.deleteMany({
|
|
128
|
+
where: { id: input.setId, type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } },
|
|
129
|
+
});
|
|
130
|
+
if (deleted.count === 0) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
131
|
+
return true;
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
|
|
File without changes
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { router, authedProcedure } from '../trpc.js';
|
|
4
|
+
|
|
5
|
+
// Avoid importing Prisma enums directly; mirror values as string literals
|
|
6
|
+
const ArtifactType = {
|
|
7
|
+
WORKSHEET: 'WORKSHEET',
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
const Difficulty = {
|
|
11
|
+
EASY: 'EASY',
|
|
12
|
+
MEDIUM: 'MEDIUM',
|
|
13
|
+
HARD: 'HARD',
|
|
14
|
+
} as const;
|
|
15
|
+
|
|
16
|
+
export const worksheets = router({
|
|
17
|
+
// List all worksheet artifacts for a workspace
|
|
18
|
+
listSets: authedProcedure
|
|
19
|
+
.input(z.object({ workspaceId: z.string().uuid() }))
|
|
20
|
+
.query(async ({ ctx, input }) => {
|
|
21
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
22
|
+
where: { id: input.workspaceId, ownerId: ctx.session.user.id },
|
|
23
|
+
});
|
|
24
|
+
if (!workspace) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
25
|
+
return ctx.db.artifact.findMany({
|
|
26
|
+
where: { workspaceId: input.workspaceId, type: ArtifactType.WORKSHEET },
|
|
27
|
+
include: {
|
|
28
|
+
versions: {
|
|
29
|
+
orderBy: { version: 'desc' },
|
|
30
|
+
take: 1, // Get only the latest version
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
orderBy: { updatedAt: 'desc' },
|
|
34
|
+
});
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
// Create a worksheet set
|
|
38
|
+
createWorksheet: authedProcedure
|
|
39
|
+
.input(z.object({ workspaceId: z.string().uuid(), title: z.string().min(1).max(120) }))
|
|
40
|
+
.mutation(async ({ ctx, input }) => {
|
|
41
|
+
const workspace = await ctx.db.workspace.findFirst({
|
|
42
|
+
where: { id: input.workspaceId, ownerId: ctx.session.user.id },
|
|
43
|
+
});
|
|
44
|
+
if (!workspace) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
45
|
+
return ctx.db.artifact.create({
|
|
46
|
+
data: {
|
|
47
|
+
workspaceId: input.workspaceId,
|
|
48
|
+
type: ArtifactType.WORKSHEET,
|
|
49
|
+
title: input.title,
|
|
50
|
+
createdById: ctx.session.user.id,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}),
|
|
54
|
+
|
|
55
|
+
// Get a worksheet with its questions
|
|
56
|
+
getWorksheet: authedProcedure
|
|
57
|
+
.input(z.object({ worksheetId: z.string().uuid() }))
|
|
58
|
+
.query(async ({ ctx, input }) => {
|
|
59
|
+
const worksheet = await ctx.db.artifact.findFirst({
|
|
60
|
+
where: {
|
|
61
|
+
id: input.worksheetId,
|
|
62
|
+
type: ArtifactType.WORKSHEET,
|
|
63
|
+
workspace: { ownerId: ctx.session.user.id },
|
|
64
|
+
},
|
|
65
|
+
include: { questions: true },
|
|
66
|
+
});
|
|
67
|
+
if (!worksheet) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
68
|
+
return worksheet;
|
|
69
|
+
}),
|
|
70
|
+
|
|
71
|
+
// Add a question to a worksheet
|
|
72
|
+
createWorksheetQuestion: authedProcedure
|
|
73
|
+
.input(z.object({
|
|
74
|
+
worksheetId: z.string().uuid(),
|
|
75
|
+
prompt: z.string().min(1),
|
|
76
|
+
answer: z.string().optional(),
|
|
77
|
+
difficulty: z.enum(['EASY', 'MEDIUM', 'HARD']).optional(),
|
|
78
|
+
order: z.number().int().optional(),
|
|
79
|
+
meta: z.record(z.string(), z.unknown()).optional(),
|
|
80
|
+
}))
|
|
81
|
+
.mutation(async ({ ctx, input }) => {
|
|
82
|
+
const worksheet = await ctx.db.artifact.findFirst({
|
|
83
|
+
where: { id: input.worksheetId, type: ArtifactType.WORKSHEET, workspace: { ownerId: ctx.session.user.id } },
|
|
84
|
+
});
|
|
85
|
+
if (!worksheet) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
86
|
+
return ctx.db.worksheetQuestion.create({
|
|
87
|
+
data: {
|
|
88
|
+
artifactId: input.worksheetId,
|
|
89
|
+
prompt: input.prompt,
|
|
90
|
+
answer: input.answer,
|
|
91
|
+
difficulty: (input.difficulty ?? Difficulty.MEDIUM) as any,
|
|
92
|
+
order: input.order ?? 0,
|
|
93
|
+
meta: input.meta as any,
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
}),
|
|
97
|
+
|
|
98
|
+
// Update a question
|
|
99
|
+
updateWorksheetQuestion: authedProcedure
|
|
100
|
+
.input(z.object({
|
|
101
|
+
worksheetQuestionId: z.string().uuid(),
|
|
102
|
+
prompt: z.string().optional(),
|
|
103
|
+
answer: z.string().optional(),
|
|
104
|
+
difficulty: z.enum(['EASY', 'MEDIUM', 'HARD']).optional(),
|
|
105
|
+
order: z.number().int().optional(),
|
|
106
|
+
meta: z.record(z.string(), z.unknown()).optional(),
|
|
107
|
+
}))
|
|
108
|
+
.mutation(async ({ ctx, input }) => {
|
|
109
|
+
const q = await ctx.db.worksheetQuestion.findFirst({
|
|
110
|
+
where: { id: input.worksheetQuestionId, artifact: { type: ArtifactType.WORKSHEET, workspace: { ownerId: ctx.session.user.id } } },
|
|
111
|
+
});
|
|
112
|
+
if (!q) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
113
|
+
return ctx.db.worksheetQuestion.update({
|
|
114
|
+
where: { id: input.worksheetQuestionId },
|
|
115
|
+
data: {
|
|
116
|
+
prompt: input.prompt ?? q.prompt,
|
|
117
|
+
answer: input.answer ?? q.answer,
|
|
118
|
+
difficulty: (input.difficulty ?? q.difficulty) as any,
|
|
119
|
+
order: input.order ?? q.order,
|
|
120
|
+
meta: (input.meta ?? q.meta) as any,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}),
|
|
124
|
+
|
|
125
|
+
// Delete a question
|
|
126
|
+
deleteWorksheetQuestion: authedProcedure
|
|
127
|
+
.input(z.object({ worksheetQuestionId: z.string().uuid() }))
|
|
128
|
+
.mutation(async ({ ctx, input }) => {
|
|
129
|
+
const q = await ctx.db.worksheetQuestion.findFirst({
|
|
130
|
+
where: { id: input.worksheetQuestionId, artifact: { workspace: { ownerId: ctx.session.user.id } } },
|
|
131
|
+
});
|
|
132
|
+
if (!q) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
133
|
+
await ctx.db.worksheetQuestion.delete({ where: { id: input.worksheetQuestionId } });
|
|
134
|
+
return true;
|
|
135
|
+
}),
|
|
136
|
+
|
|
137
|
+
// Delete a worksheet set and its questions
|
|
138
|
+
deleteWorksheet: authedProcedure
|
|
139
|
+
.input(z.object({ worksheetId: z.string().uuid() }))
|
|
140
|
+
.mutation(async ({ ctx, input }) => {
|
|
141
|
+
const deleted = await ctx.db.artifact.deleteMany({
|
|
142
|
+
where: { id: input.worksheetId, type: ArtifactType.WORKSHEET, workspace: { ownerId: ctx.session.user.id } },
|
|
143
|
+
});
|
|
144
|
+
if (deleted.count === 0) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
145
|
+
return true;
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
|
package/src/routers/workspace.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
2
3
|
import { router, publicProcedure, authedProcedure } from '../trpc.js';
|
|
3
4
|
import { bucket } from '../lib/storage.js';
|
|
4
|
-
import { FileAsset } from '@prisma/client';
|
|
5
5
|
|
|
6
6
|
export const workspace = router({
|
|
7
|
-
//
|
|
7
|
+
// List current user's workspaces
|
|
8
8
|
list: authedProcedure
|
|
9
|
-
.query(async ({ ctx
|
|
9
|
+
.query(async ({ ctx }) => {
|
|
10
10
|
const workspaces = await ctx.db.workspace.findMany({
|
|
11
11
|
where: {
|
|
12
|
-
ownerId: ctx.session
|
|
12
|
+
ownerId: ctx.session.user.id,
|
|
13
13
|
},
|
|
14
|
+
orderBy: { updatedAt: 'desc' },
|
|
14
15
|
});
|
|
15
16
|
return workspaces;
|
|
16
17
|
}),
|
|
@@ -20,25 +21,26 @@ export const workspace = router({
|
|
|
20
21
|
name: z.string().min(1).max(100),
|
|
21
22
|
description: z.string().max(500).optional(),
|
|
22
23
|
}))
|
|
23
|
-
.mutation(({ ctx, input}) => {
|
|
24
|
-
|
|
24
|
+
.mutation(async ({ ctx, input}) => {
|
|
25
|
+
const ws = await ctx.db.workspace.create({
|
|
25
26
|
data: {
|
|
26
27
|
title: input.name,
|
|
27
28
|
description: input.description,
|
|
28
|
-
ownerId: ctx.session
|
|
29
|
+
ownerId: ctx.session.user.id,
|
|
29
30
|
},
|
|
30
31
|
});
|
|
32
|
+
return ws;
|
|
31
33
|
}),
|
|
32
34
|
get: authedProcedure
|
|
33
35
|
.input(z.object({
|
|
34
36
|
id: z.string().uuid(),
|
|
35
37
|
}))
|
|
36
|
-
.query(({ ctx, input }) => {
|
|
37
|
-
|
|
38
|
-
where: {
|
|
39
|
-
id: input.id,
|
|
40
|
-
},
|
|
38
|
+
.query(async ({ ctx, input }) => {
|
|
39
|
+
const ws = await ctx.db.workspace.findFirst({
|
|
40
|
+
where: { id: input.id, ownerId: ctx.session.user.id },
|
|
41
41
|
});
|
|
42
|
+
if (!ws) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
43
|
+
return ws;
|
|
42
44
|
}),
|
|
43
45
|
update: authedProcedure
|
|
44
46
|
.input(z.object({
|
|
@@ -46,27 +48,29 @@ export const workspace = router({
|
|
|
46
48
|
name: z.string().min(1).max(100).optional(),
|
|
47
49
|
description: z.string().max(500).optional(),
|
|
48
50
|
}))
|
|
49
|
-
.mutation(({ ctx, input }) => {
|
|
50
|
-
|
|
51
|
-
where: {
|
|
52
|
-
|
|
53
|
-
|
|
51
|
+
.mutation(async ({ ctx, input }) => {
|
|
52
|
+
const existed = await ctx.db.workspace.findFirst({
|
|
53
|
+
where: { id: input.id, ownerId: ctx.session.user.id },
|
|
54
|
+
});
|
|
55
|
+
if (!existed) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
56
|
+
const updated = await ctx.db.workspace.update({
|
|
57
|
+
where: { id: input.id },
|
|
54
58
|
data: {
|
|
55
|
-
title: input.name,
|
|
59
|
+
title: input.name ?? existed.title,
|
|
56
60
|
description: input.description,
|
|
57
61
|
},
|
|
58
|
-
});
|
|
62
|
+
});
|
|
63
|
+
return updated;
|
|
59
64
|
}),
|
|
60
65
|
delete: authedProcedure
|
|
61
66
|
.input(z.object({
|
|
62
67
|
id: z.string().uuid(),
|
|
63
68
|
}))
|
|
64
|
-
.mutation(({ ctx, input }) => {
|
|
65
|
-
ctx.db.workspace.
|
|
66
|
-
where: {
|
|
67
|
-
id: input.id,
|
|
68
|
-
},
|
|
69
|
+
.mutation(async ({ ctx, input }) => {
|
|
70
|
+
const deleted = await ctx.db.workspace.deleteMany({
|
|
71
|
+
where: { id: input.id, ownerId: ctx.session.user.id },
|
|
69
72
|
});
|
|
73
|
+
if (deleted.count === 0) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
70
74
|
return true;
|
|
71
75
|
}),
|
|
72
76
|
uploadFiles: authedProcedure
|
|
@@ -81,6 +85,9 @@ export const workspace = router({
|
|
|
81
85
|
),
|
|
82
86
|
}))
|
|
83
87
|
.mutation(async ({ ctx, input }) => {
|
|
88
|
+
// ensure workspace belongs to user
|
|
89
|
+
const ws = await ctx.db.workspace.findFirst({ where: { id: input.id, ownerId: ctx.session.user.id } });
|
|
90
|
+
if (!ws) throw new TRPCError({ code: 'NOT_FOUND' });
|
|
84
91
|
const results = [];
|
|
85
92
|
|
|
86
93
|
for (const file of input.files) {
|
|
@@ -127,31 +134,32 @@ export const workspace = router({
|
|
|
127
134
|
fileId: z.array(z.string().uuid()),
|
|
128
135
|
id: z.string().uuid(),
|
|
129
136
|
}))
|
|
130
|
-
.mutation(({ ctx, input }) => {
|
|
131
|
-
|
|
137
|
+
.mutation(async ({ ctx, input }) => {
|
|
138
|
+
// ensure files are in the user's workspace
|
|
139
|
+
const files = await ctx.db.fileAsset.findMany({
|
|
132
140
|
where: {
|
|
133
141
|
id: { in: input.fileId },
|
|
134
142
|
workspaceId: input.id,
|
|
143
|
+
userId: ctx.session.user.id,
|
|
135
144
|
},
|
|
136
145
|
});
|
|
146
|
+
// Delete from GCS (best-effort)
|
|
147
|
+
for (const file of files) {
|
|
148
|
+
if (file.bucket && file.objectKey) {
|
|
149
|
+
const gcsFile: import('@google-cloud/storage').File = bucket.file(file.objectKey);
|
|
150
|
+
gcsFile.delete({ ignoreNotFound: true }).catch((err: unknown) => {
|
|
151
|
+
console.error(`Error deleting file ${file.objectKey} from bucket ${file.bucket}:`, err);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
137
155
|
|
|
138
|
-
|
|
139
|
-
files.then((fileRecords: FileAsset[]) => {
|
|
140
|
-
fileRecords.forEach((file: FileAsset) => {
|
|
141
|
-
if (file.bucket && file.objectKey) {
|
|
142
|
-
const gcsFile: import('@google-cloud/storage').File = bucket.file(file.objectKey);
|
|
143
|
-
gcsFile.delete({ ignoreNotFound: true }).catch((err: unknown) => {
|
|
144
|
-
console.error(`Error deleting file ${file.objectKey} from bucket ${file.bucket}:`, err);
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
return ctx.db.fileAsset.deleteMany({
|
|
156
|
+
await ctx.db.fileAsset.deleteMany({
|
|
151
157
|
where: {
|
|
152
158
|
id: { in: input.fileId },
|
|
153
159
|
workspaceId: input.id,
|
|
160
|
+
userId: ctx.session.user.id,
|
|
154
161
|
},
|
|
155
162
|
});
|
|
163
|
+
return true;
|
|
156
164
|
}),
|
|
157
165
|
});
|
package/src/server.ts
CHANGED