@goscribe/server 1.1.7 → 1.2.0

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.
@@ -0,0 +1,61 @@
1
+ import { logger } from './logger.js';
2
+
3
+ export interface RetryOptions {
4
+ /** Maximum number of attempts (default: 3) */
5
+ maxRetries?: number;
6
+ /** Base delay in ms for exponential backoff (default: 2000) */
7
+ baseDelayMs?: number;
8
+ /** Timeout per attempt in ms (default: 300000 = 5 minutes) */
9
+ timeoutMs?: number;
10
+ /** Label for logging (optional) */
11
+ label?: string;
12
+ }
13
+
14
+ /**
15
+ * Wraps an async function with retry logic and exponential backoff.
16
+ * Throws the last error if all attempts fail.
17
+ */
18
+ export async function withRetry<T>(
19
+ fn: () => Promise<T>,
20
+ options: RetryOptions = {}
21
+ ): Promise<T> {
22
+ const {
23
+ maxRetries = 3,
24
+ baseDelayMs = 2000,
25
+ timeoutMs = 300000,
26
+ label = 'operation',
27
+ } = options;
28
+
29
+ let lastError: Error | null = null;
30
+
31
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
32
+ try {
33
+ logger.info(`[retry] ${label} attempt ${attempt}/${maxRetries}`);
34
+
35
+ // Create an AbortController for per-attempt timeout
36
+ const controller = new AbortController();
37
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
38
+
39
+ try {
40
+ const result = await fn();
41
+ clearTimeout(timeoutId);
42
+ return result;
43
+ } catch (error) {
44
+ clearTimeout(timeoutId);
45
+ throw error;
46
+ }
47
+ } catch (error) {
48
+ lastError = error instanceof Error ? error : new Error(String(error));
49
+ logger.error(`[retry] ${label} attempt ${attempt} failed: ${lastError.message}`);
50
+
51
+ if (attempt < maxRetries) {
52
+ const delay = Math.pow(2, attempt) * baseDelayMs;
53
+ logger.info(`[retry] ${label} retrying in ${delay}ms...`);
54
+ await new Promise((resolve) => setTimeout(resolve, delay));
55
+ }
56
+ }
57
+ }
58
+
59
+ logger.error(`[retry] ${label} all ${maxRetries} attempts failed`);
60
+ throw lastError!;
61
+ }
@@ -88,13 +88,13 @@ export async function makeFilePublic(objectKey: string): Promise<void> {
88
88
  // In Supabase, files are public by default when uploaded to public buckets
89
89
  // For private buckets, you would need to update the bucket policy
90
90
  // This function is kept for compatibility but may not be needed
91
- console.log(`File ${objectKey} is already public in Supabase Storage`);
91
+ // File is already public in Supabase Storage
92
92
  }
93
93
 
94
94
  export async function makeFilePrivate(objectKey: string): Promise<void> {
95
95
  // In Supabase, you would need to update the bucket policy to make files private
96
96
  // This function is kept for compatibility but may not be needed
97
- console.log(`File ${objectKey} privacy is controlled by bucket policy in Supabase Storage`);
97
+ // File privacy is controlled by bucket policy in Supabase Storage
98
98
  }
99
99
 
100
100
  // Export supabase client for direct access if needed
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Prisma `where` filter for workspace access checks.
3
+ * Allows access if the user is the owner OR a member of the workspace.
4
+ * Use this instead of `{ ownerId: userId }` to support collaborative workspaces.
5
+ */
6
+ export function workspaceAccessFilter(userId: string) {
7
+ return {
8
+ OR: [
9
+ { ownerId: userId },
10
+ { members: { some: { userId } } },
11
+ ],
12
+ };
13
+ }
@@ -8,6 +8,7 @@ import { studyguide } from './studyguide.js';
8
8
  import { podcast } from './podcast.js';
9
9
  import { chat } from './chat.js';
10
10
  import { members } from './members.js';
11
+ import { annotations } from './annotations.js';
11
12
 
12
13
  export const appRouter = router({
13
14
  auth,
@@ -17,6 +18,7 @@ export const appRouter = router({
17
18
  studyguide,
18
19
  podcast,
19
20
  chat,
21
+ annotations,
20
22
  // Public member endpoints (for invitation acceptance)
21
23
  member: router({
22
24
  acceptInvite: members.acceptInvite,
@@ -0,0 +1,186 @@
1
+ import { z } from 'zod';
2
+ import { TRPCError } from '@trpc/server';
3
+ import { router, authedProcedure } from '../trpc.js';
4
+
5
+ export const annotations = router({
6
+ // List all highlights (with nested comments) for an artifact version
7
+ listHighlights: authedProcedure
8
+ .input(
9
+ z.object({
10
+ artifactVersionId: z.string(),
11
+ })
12
+ )
13
+ .query(async ({ ctx, input }) => {
14
+ const highlights = await ctx.db.studyGuideHighlight.findMany({
15
+ where: { artifactVersionId: input.artifactVersionId },
16
+ include: {
17
+ comments: {
18
+ include: {
19
+ user: { select: { id: true, name: true, profilePicture: true } },
20
+ },
21
+ orderBy: { createdAt: 'asc' },
22
+ },
23
+ user: { select: { id: true, name: true, profilePicture: true } },
24
+ },
25
+ orderBy: { startOffset: 'asc' },
26
+ });
27
+
28
+ return highlights;
29
+ }),
30
+
31
+ // Create a new highlight
32
+ createHighlight: authedProcedure
33
+ .input(
34
+ z.object({
35
+ artifactVersionId: z.string(),
36
+ startOffset: z.number().int().min(0),
37
+ endOffset: z.number().int().min(0),
38
+ selectedText: z.string().min(1),
39
+ color: z.string().optional(),
40
+ })
41
+ )
42
+ .mutation(async ({ ctx, input }) => {
43
+ // Verify the artifact version exists
44
+ const version = await ctx.db.artifactVersion.findUnique({
45
+ where: { id: input.artifactVersionId },
46
+ });
47
+
48
+ if (!version) {
49
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Artifact version not found' });
50
+ }
51
+
52
+ const highlight = await ctx.db.studyGuideHighlight.create({
53
+ data: {
54
+ artifactVersionId: input.artifactVersionId,
55
+ userId: ctx.session.user.id,
56
+ startOffset: input.startOffset,
57
+ endOffset: input.endOffset,
58
+ selectedText: input.selectedText,
59
+ ...(input.color && { color: input.color }),
60
+ },
61
+ include: {
62
+ comments: true,
63
+ user: { select: { id: true, name: true, profilePicture: true } },
64
+ },
65
+ });
66
+
67
+ return highlight;
68
+ }),
69
+
70
+ // Delete a highlight (and its comments via cascade)
71
+ deleteHighlight: authedProcedure
72
+ .input(
73
+ z.object({
74
+ highlightId: z.string(),
75
+ })
76
+ )
77
+ .mutation(async ({ ctx, input }) => {
78
+ const highlight = await ctx.db.studyGuideHighlight.findUnique({
79
+ where: { id: input.highlightId },
80
+ });
81
+
82
+ if (!highlight) {
83
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Highlight not found' });
84
+ }
85
+
86
+ if (highlight.userId !== ctx.session.user.id) {
87
+ throw new TRPCError({ code: 'FORBIDDEN', message: 'You can only delete your own highlights' });
88
+ }
89
+
90
+ await ctx.db.studyGuideHighlight.delete({
91
+ where: { id: input.highlightId },
92
+ });
93
+
94
+ return { success: true };
95
+ }),
96
+
97
+ // Add a comment to a highlight
98
+ addComment: authedProcedure
99
+ .input(
100
+ z.object({
101
+ highlightId: z.string(),
102
+ content: z.string().min(1),
103
+ })
104
+ )
105
+ .mutation(async ({ ctx, input }) => {
106
+ const highlight = await ctx.db.studyGuideHighlight.findUnique({
107
+ where: { id: input.highlightId },
108
+ });
109
+
110
+ if (!highlight) {
111
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Highlight not found' });
112
+ }
113
+
114
+ const comment = await ctx.db.studyGuideComment.create({
115
+ data: {
116
+ highlightId: input.highlightId,
117
+ userId: ctx.session.user.id,
118
+ content: input.content,
119
+ },
120
+ include: {
121
+ user: { select: { id: true, name: true, profilePicture: true } },
122
+ },
123
+ });
124
+
125
+ return comment;
126
+ }),
127
+
128
+ // Update a comment
129
+ updateComment: authedProcedure
130
+ .input(
131
+ z.object({
132
+ commentId: z.string(),
133
+ content: z.string().min(1),
134
+ })
135
+ )
136
+ .mutation(async ({ ctx, input }) => {
137
+ const comment = await ctx.db.studyGuideComment.findUnique({
138
+ where: { id: input.commentId },
139
+ });
140
+
141
+ if (!comment) {
142
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Comment not found' });
143
+ }
144
+
145
+ if (comment.userId !== ctx.session.user.id) {
146
+ throw new TRPCError({ code: 'FORBIDDEN', message: 'You can only edit your own comments' });
147
+ }
148
+
149
+ const updated = await ctx.db.studyGuideComment.update({
150
+ where: { id: input.commentId },
151
+ data: { content: input.content },
152
+ include: {
153
+ user: { select: { id: true, name: true, profilePicture: true } },
154
+ },
155
+ });
156
+
157
+ return updated;
158
+ }),
159
+
160
+ // Delete a comment
161
+ deleteComment: authedProcedure
162
+ .input(
163
+ z.object({
164
+ commentId: z.string(),
165
+ })
166
+ )
167
+ .mutation(async ({ ctx, input }) => {
168
+ const comment = await ctx.db.studyGuideComment.findUnique({
169
+ where: { id: input.commentId },
170
+ });
171
+
172
+ if (!comment) {
173
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Comment not found' });
174
+ }
175
+
176
+ if (comment.userId !== ctx.session.user.id) {
177
+ throw new TRPCError({ code: 'FORBIDDEN', message: 'You can only delete your own comments' });
178
+ }
179
+
180
+ await ctx.db.studyGuideComment.delete({
181
+ where: { id: input.commentId },
182
+ });
183
+
184
+ return { success: true };
185
+ }),
186
+ });
@@ -4,13 +4,14 @@ import bcrypt from 'bcryptjs';
4
4
  import { serialize } from 'cookie';
5
5
  import crypto from 'node:crypto';
6
6
  import { TRPCError } from '@trpc/server';
7
- import { supabaseClient } from 'src/lib/storage.js';
7
+ import { supabaseClient } from '../lib/storage.js';
8
+ import { sendVerificationEmail } from '../lib/email.js';
8
9
 
9
10
  // Helper to create custom auth token
10
11
  function createCustomAuthToken(userId: string): string {
11
12
  const secret = process.env.AUTH_SECRET;
12
13
  if (!secret) {
13
- throw new Error("AUTH_SECRET is not set");
14
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: "AUTH_SECRET is not set" });
14
15
  }
15
16
 
16
17
  const base64UserId = Buffer.from(userId, 'utf8').toString('base64url');
@@ -80,7 +81,7 @@ export const auth = router({
80
81
  where: { email: input.email },
81
82
  });
82
83
  if (existing) {
83
- throw new Error("Email already registered");
84
+ throw new TRPCError({ code: 'CONFLICT', message: "Email already registered" });
84
85
  }
85
86
 
86
87
  const hash = await bcrypt.hash(input.password, 10);
@@ -90,12 +91,99 @@ export const auth = router({
90
91
  name: input.name,
91
92
  email: input.email,
92
93
  passwordHash: hash,
93
- emailVerified: new Date(), // skip verification for demo
94
+ // emailVerified is null -- user must verify
94
95
  },
95
96
  });
96
97
 
98
+ // Create verification token (24h expiry)
99
+ const token = crypto.randomUUID();
100
+ await ctx.db.verificationToken.create({
101
+ data: {
102
+ identifier: input.email,
103
+ token,
104
+ expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
105
+ },
106
+ });
107
+
108
+ // Send verification email (non-blocking)
109
+ sendVerificationEmail(input.email, token, input.name).catch(() => {});
110
+
97
111
  return { id: user.id, email: user.email, name: user.name };
98
112
  }),
113
+
114
+ // Verify email with token from the email link
115
+ verifyEmail: publicProcedure
116
+ .input(z.object({
117
+ token: z.string(),
118
+ }))
119
+ .mutation(async ({ ctx, input }) => {
120
+ const record = await ctx.db.verificationToken.findUnique({
121
+ where: { token: input.token },
122
+ });
123
+
124
+ if (!record) {
125
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'Invalid or expired verification link' });
126
+ }
127
+
128
+ if (record.expires < new Date()) {
129
+ // Clean up expired token
130
+ await ctx.db.verificationToken.delete({ where: { token: input.token } });
131
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Verification link has expired. Please request a new one.' });
132
+ }
133
+
134
+ // Mark user as verified
135
+ await ctx.db.user.update({
136
+ where: { email: record.identifier },
137
+ data: { emailVerified: new Date() },
138
+ });
139
+
140
+ // Delete used token
141
+ await ctx.db.verificationToken.delete({ where: { token: input.token } });
142
+
143
+ return { success: true, message: 'Email verified successfully' };
144
+ }),
145
+
146
+ // Resend verification email (for logged-in users who haven't verified)
147
+ resendVerification: publicProcedure
148
+ .mutation(async ({ ctx }) => {
149
+ if (!ctx.session?.user?.id) {
150
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Not logged in' });
151
+ }
152
+
153
+ const user = await ctx.db.user.findUnique({
154
+ where: { id: ctx.session.user.id },
155
+ });
156
+
157
+ if (!user || !user.email) {
158
+ throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' });
159
+ }
160
+
161
+ if (user.emailVerified) {
162
+ return { success: true, message: 'Email is already verified' };
163
+ }
164
+
165
+ // Delete any existing tokens for this email
166
+ await ctx.db.verificationToken.deleteMany({
167
+ where: { identifier: user.email },
168
+ });
169
+
170
+ // Create new token
171
+ const token = crypto.randomUUID();
172
+ await ctx.db.verificationToken.create({
173
+ data: {
174
+ identifier: user.email,
175
+ token,
176
+ expires: new Date(Date.now() + 24 * 60 * 60 * 1000),
177
+ },
178
+ });
179
+
180
+ const sent = await sendVerificationEmail(user.email, token, user.name);
181
+ if (!sent) {
182
+ throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to send email. Please try again.' });
183
+ }
184
+
185
+ return { success: true, message: 'Verification email sent' };
186
+ }),
99
187
  login: publicProcedure
100
188
  .input(z.object({
101
189
  email: z.string().email(),
@@ -106,12 +194,12 @@ export const auth = router({
106
194
  where: { email: input.email },
107
195
  });
108
196
  if (!user) {
109
- throw new Error("Invalid credentials");
197
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: "Invalid credentials" });
110
198
  }
111
199
 
112
200
  const valid = await bcrypt.compare(input.password, user.passwordHash!);
113
201
  if (!valid) {
114
- throw new Error("Invalid credentials");
202
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: "Invalid credentials" });
115
203
  }
116
204
 
117
205
  // Create custom auth token
@@ -141,7 +229,7 @@ export const auth = router({
141
229
  getSession: publicProcedure.query(async ({ ctx }) => {
142
230
  // Just return the current session from context
143
231
  if (!ctx.session) {
144
- throw new Error("No session found");
232
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: "No session found" });
145
233
  }
146
234
 
147
235
  const user = await ctx.db.user.findUnique({
@@ -149,7 +237,7 @@ export const auth = router({
149
237
  });
150
238
 
151
239
  if (!user) {
152
- throw new Error("User not found");
240
+ throw new TRPCError({ code: 'NOT_FOUND', message: "User not found" });
153
241
  }
154
242
 
155
243
  return {
@@ -157,6 +245,7 @@ export const auth = router({
157
245
  id: user.id,
158
246
  email: user.email,
159
247
  name: user.name,
248
+ emailVerified: !!user.emailVerified,
160
249
  }
161
250
  };
162
251
  }),
@@ -164,7 +253,7 @@ export const auth = router({
164
253
  const token = ctx.cookies["auth_token"];
165
254
 
166
255
  if (!token) {
167
- throw new Error("No token found");
256
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: "No token found" });
168
257
  }
169
258
 
170
259
  await ctx.db.session.delete({
@@ -5,14 +5,8 @@ import createInferenceService from '../lib/inference.js';
5
5
  import { aiSessionService } from '../lib/ai-session.js';
6
6
  import PusherService from '../lib/pusher.js';
7
7
  import { createFlashcardProgressService } from '../services/flashcard-progress.service.js';
8
- // Prisma enum values mapped manually to avoid type import issues in ESM
9
- const ArtifactType = {
10
- STUDY_GUIDE: 'STUDY_GUIDE',
11
- FLASHCARD_SET: 'FLASHCARD_SET',
12
- WORKSHEET: 'WORKSHEET',
13
- MEETING_SUMMARY: 'MEETING_SUMMARY',
14
- PODCAST_EPISODE: 'PODCAST_EPISODE',
15
- } as const;
8
+ import { ArtifactType } from '../lib/constants.js';
9
+ import { workspaceAccessFilter } from '../lib/workspace-access.js';
16
10
 
17
11
  export const flashcards = router({
18
12
  listSets: authedProcedure
@@ -37,7 +31,7 @@ export const flashcards = router({
37
31
  .input(z.object({ workspaceId: z.string() }))
38
32
  .query(async ({ ctx, input }) => {
39
33
  const set = await ctx.db.artifact.findFirst({
40
- where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } },
34
+ where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET, workspace: workspaceAccessFilter(ctx.session.user.id) },
41
35
  include: {
42
36
  flashcards: {
43
37
  include: {
@@ -59,7 +53,7 @@ export const flashcards = router({
59
53
  .input(z.object({ workspaceId: z.string() }))
60
54
  .query(async ({ ctx, input }) => {
61
55
  const artifact = await ctx.db.artifact.findFirst({
62
- where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } }, orderBy: { updatedAt: 'desc' },
56
+ where: { workspaceId: input.workspaceId, type: ArtifactType.FLASHCARD_SET, workspace: workspaceAccessFilter(ctx.session.user.id) }, orderBy: { updatedAt: 'desc' },
63
57
  });
64
58
  return artifact?.generating;
65
59
  }),
@@ -103,7 +97,7 @@ export const flashcards = router({
103
97
  }))
104
98
  .mutation(async ({ ctx, input }) => {
105
99
  const card = await ctx.db.flashcard.findFirst({
106
- where: { id: input.cardId, artifact: { type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } } },
100
+ where: { id: input.cardId, artifact: { type: ArtifactType.FLASHCARD_SET, workspace: workspaceAccessFilter(ctx.session.user.id) } },
107
101
  });
108
102
  if (!card) throw new TRPCError({ code: 'NOT_FOUND' });
109
103
  return ctx.db.flashcard.update({
@@ -121,7 +115,7 @@ export const flashcards = router({
121
115
  .input(z.object({ cardId: z.string() }))
122
116
  .mutation(async ({ ctx, input }) => {
123
117
  const card = await ctx.db.flashcard.findFirst({
124
- where: { id: input.cardId, artifact: { workspace: { ownerId: ctx.session.user.id } } },
118
+ where: { id: input.cardId, artifact: { workspace: workspaceAccessFilter(ctx.session.user.id) } },
125
119
  });
126
120
  if (!card) throw new TRPCError({ code: 'NOT_FOUND' });
127
121
  await ctx.db.flashcard.delete({ where: { id: input.cardId } });
@@ -132,7 +126,7 @@ export const flashcards = router({
132
126
  .input(z.object({ setId: z.string().uuid() }))
133
127
  .mutation(async ({ ctx, input }) => {
134
128
  const deleted = await ctx.db.artifact.deleteMany({
135
- where: { id: input.setId, type: ArtifactType.FLASHCARD_SET, workspace: { ownerId: ctx.session.user.id } },
129
+ where: { id: input.setId, type: ArtifactType.FLASHCARD_SET, workspace: workspaceAccessFilter(ctx.session.user.id) },
136
130
  });
137
131
  if (deleted.count === 0) throw new TRPCError({ code: 'NOT_FOUND' });
138
132
  return true;