@goscribe/server 1.2.0 → 1.3.1
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/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/dist/context.d.ts +5 -1
- package/dist/lib/activity_human_description.d.ts +13 -0
- package/dist/lib/activity_human_description.js +221 -0
- package/dist/lib/activity_human_description.test.d.ts +1 -0
- package/dist/lib/activity_human_description.test.js +16 -0
- package/dist/lib/activity_log_service.d.ts +87 -0
- package/dist/lib/activity_log_service.js +276 -0
- package/dist/lib/activity_log_service.test.d.ts +1 -0
- package/dist/lib/activity_log_service.test.js +27 -0
- package/dist/lib/ai-session.d.ts +15 -2
- package/dist/lib/ai-session.js +147 -85
- package/dist/lib/constants.d.ts +13 -0
- package/dist/lib/constants.js +12 -0
- package/dist/lib/email.d.ts +11 -0
- package/dist/lib/email.js +193 -0
- package/dist/lib/env.d.ts +13 -0
- package/dist/lib/env.js +16 -0
- package/dist/lib/inference.d.ts +4 -1
- package/dist/lib/inference.js +3 -3
- package/dist/lib/logger.d.ts +4 -4
- package/dist/lib/logger.js +30 -8
- package/dist/lib/notification-service.d.ts +152 -0
- package/dist/lib/notification-service.js +473 -0
- package/dist/lib/notification-service.test.d.ts +1 -0
- package/dist/lib/notification-service.test.js +87 -0
- package/dist/lib/prisma.d.ts +2 -1
- package/dist/lib/prisma.js +5 -1
- package/dist/lib/pusher.d.ts +23 -0
- package/dist/lib/pusher.js +69 -5
- package/dist/lib/retry.d.ts +15 -0
- package/dist/lib/retry.js +37 -0
- package/dist/lib/storage.js +2 -2
- package/dist/lib/stripe.d.ts +9 -0
- package/dist/lib/stripe.js +36 -0
- package/dist/lib/subscription_service.d.ts +37 -0
- package/dist/lib/subscription_service.js +654 -0
- package/dist/lib/usage_service.d.ts +26 -0
- package/dist/lib/usage_service.js +59 -0
- package/dist/lib/worksheet-generation.d.ts +91 -0
- package/dist/lib/worksheet-generation.js +95 -0
- package/dist/lib/worksheet-generation.test.d.ts +1 -0
- package/dist/lib/worksheet-generation.test.js +20 -0
- package/dist/lib/workspace-access.d.ts +18 -0
- package/dist/lib/workspace-access.js +13 -0
- package/dist/routers/_app.d.ts +1349 -253
- package/dist/routers/_app.js +10 -0
- package/dist/routers/admin.d.ts +361 -0
- package/dist/routers/admin.js +633 -0
- package/dist/routers/annotations.d.ts +219 -0
- package/dist/routers/annotations.js +187 -0
- package/dist/routers/auth.d.ts +88 -7
- package/dist/routers/auth.js +339 -19
- package/dist/routers/chat.d.ts +6 -12
- package/dist/routers/copilot.d.ts +199 -0
- package/dist/routers/copilot.js +571 -0
- package/dist/routers/flashcards.d.ts +47 -81
- package/dist/routers/flashcards.js +143 -27
- package/dist/routers/members.d.ts +36 -7
- package/dist/routers/members.js +200 -19
- package/dist/routers/notifications.d.ts +99 -0
- package/dist/routers/notifications.js +127 -0
- package/dist/routers/payment.d.ts +89 -0
- package/dist/routers/payment.js +403 -0
- package/dist/routers/podcast.d.ts +8 -13
- package/dist/routers/podcast.js +54 -31
- package/dist/routers/studyguide.d.ts +1 -29
- package/dist/routers/studyguide.js +80 -71
- package/dist/routers/worksheets.d.ts +105 -38
- package/dist/routers/worksheets.js +258 -68
- package/dist/routers/workspace.d.ts +139 -60
- package/dist/routers/workspace.js +455 -315
- package/dist/scripts/purge-deleted-users.d.ts +1 -0
- package/dist/scripts/purge-deleted-users.js +149 -0
- package/dist/server.js +130 -10
- package/dist/services/flashcard-progress.service.d.ts +18 -66
- package/dist/services/flashcard-progress.service.js +51 -42
- package/dist/trpc.d.ts +20 -21
- package/dist/trpc.js +150 -1
- package/mcq-test.cjs +36 -0
- package/package.json +9 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +471 -324
- package/src/context.ts +4 -1
- package/src/lib/activity_human_description.test.ts +28 -0
- package/src/lib/activity_human_description.ts +239 -0
- package/src/lib/activity_log_service.test.ts +37 -0
- package/src/lib/activity_log_service.ts +353 -0
- package/src/lib/ai-session.ts +79 -51
- package/src/lib/email.ts +213 -29
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +2 -2
- package/src/lib/notification-service.test.ts +106 -0
- package/src/lib/notification-service.ts +677 -0
- package/src/lib/prisma.ts +6 -1
- package/src/lib/pusher.ts +86 -2
- package/src/lib/stripe.ts +39 -0
- package/src/lib/subscription_service.ts +722 -0
- package/src/lib/usage_service.ts +74 -0
- package/src/lib/worksheet-generation.test.ts +31 -0
- package/src/lib/worksheet-generation.ts +139 -0
- package/src/routers/_app.ts +9 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +41 -0
- package/src/routers/auth.ts +338 -28
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +201 -68
- package/src/routers/members.ts +280 -80
- package/src/routers/notifications.ts +142 -0
- package/src/routers/payment.ts +448 -0
- package/src/routers/podcast.ts +112 -83
- package/src/routers/studyguide.ts +12 -0
- package/src/routers/worksheets.ts +289 -66
- package/src/routers/workspace.ts +329 -122
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +137 -11
- package/src/services/flashcard-progress.service.ts +49 -37
- package/src/trpc.ts +184 -5
- package/test-generate.js +30 -0
- package/test-ratio.cjs +9 -0
- package/zod-test.cjs +22 -0
- package/prisma/migrations/20250826124819_add_worksheet_difficulty_and_estimated_time/migration.sql +0 -213
- package/prisma/migrations/20250826133236_add_worksheet_question_progress/migration.sql +0 -31
- package/prisma/seed.mjs +0 -135
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import PusherService from './pusher.js';
|
|
2
|
+
|
|
3
|
+
type NotificationPriority = 'LOW' | 'NORMAL' | 'HIGH';
|
|
4
|
+
|
|
5
|
+
export const NotificationType = {
|
|
6
|
+
GENERAL: 'GENERAL',
|
|
7
|
+
USER_SIGNED_UP: 'USER_SIGNED_UP',
|
|
8
|
+
ACCOUNT_DELETION_SCHEDULED: 'ACCOUNT_DELETION_SCHEDULED',
|
|
9
|
+
ACCOUNT_PERMANENTLY_DELETED: 'ACCOUNT_PERMANENTLY_DELETED',
|
|
10
|
+
WORKSPACE_INVITE_RECEIVED: 'WORKSPACE_INVITE_RECEIVED',
|
|
11
|
+
WORKSPACE_INVITE_ACCEPTED: 'WORKSPACE_INVITE_ACCEPTED',
|
|
12
|
+
WORKSPACE_DELETED: 'WORKSPACE_DELETED',
|
|
13
|
+
PAYMENT_SUCCEEDED: 'PAYMENT_SUCCEEDED',
|
|
14
|
+
SUBSCRIPTION_ACTIVATED: 'SUBSCRIPTION_ACTIVATED',
|
|
15
|
+
SUBSCRIPTION_PAYMENT_SUCCEEDED: 'SUBSCRIPTION_PAYMENT_SUCCEEDED',
|
|
16
|
+
SUBSCRIPTION_CANCELED: 'SUBSCRIPTION_CANCELED',
|
|
17
|
+
PAYMENT_FAILED: 'PAYMENT_FAILED',
|
|
18
|
+
WORKSPACE_ROLE_CHANGED: 'WORKSPACE_ROLE_CHANGED',
|
|
19
|
+
WORKSPACE_MEMBERSHIP_REMOVED: 'WORKSPACE_MEMBERSHIP_REMOVED',
|
|
20
|
+
STUDY_GUIDE_COMMENT_ADDED: 'STUDY_GUIDE_COMMENT_ADDED',
|
|
21
|
+
ARTIFACT_READY: 'ARTIFACT_READY',
|
|
22
|
+
ARTIFACT_FAILED: 'ARTIFACT_FAILED',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export function artifactTypeLabel(artifactType: string): string {
|
|
26
|
+
const map: Record<string, string> = {
|
|
27
|
+
STUDY_GUIDE: 'Study guide',
|
|
28
|
+
FLASHCARD_SET: 'Flashcards',
|
|
29
|
+
WORKSHEET: 'Worksheet',
|
|
30
|
+
PODCAST_EPISODE: 'Podcast episode',
|
|
31
|
+
MEETING_SUMMARY: 'Meeting summary',
|
|
32
|
+
};
|
|
33
|
+
return map[artifactType] ?? artifactType;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildArtifactActionUrl(
|
|
37
|
+
workspaceId: string,
|
|
38
|
+
artifactId: string | undefined,
|
|
39
|
+
artifactType: string,
|
|
40
|
+
): string {
|
|
41
|
+
switch (artifactType) {
|
|
42
|
+
case 'STUDY_GUIDE':
|
|
43
|
+
return `/workspace/${workspaceId}/study-guide`;
|
|
44
|
+
case 'FLASHCARD_SET':
|
|
45
|
+
return `/workspace/${workspaceId}/flashcards`;
|
|
46
|
+
case 'WORKSHEET':
|
|
47
|
+
return artifactId
|
|
48
|
+
? `/workspace/${workspaceId}/worksheet/${artifactId}`
|
|
49
|
+
: `/workspace/${workspaceId}/worksheet`;
|
|
50
|
+
case 'PODCAST_EPISODE':
|
|
51
|
+
return artifactId
|
|
52
|
+
? `/workspace/${workspaceId}/podcasts/${artifactId}`
|
|
53
|
+
: `/workspace/${workspaceId}/podcasts`;
|
|
54
|
+
case 'MEETING_SUMMARY':
|
|
55
|
+
return `/workspace/${workspaceId}`;
|
|
56
|
+
default:
|
|
57
|
+
return `/workspace/${workspaceId}`;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function extractMentionEmails(text: string): string[] {
|
|
62
|
+
const re = /@([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g;
|
|
63
|
+
const out: string[] = [];
|
|
64
|
+
let m: RegExpExecArray | null;
|
|
65
|
+
while ((m = re.exec(text)) !== null) {
|
|
66
|
+
out.push(m[1].toLowerCase());
|
|
67
|
+
}
|
|
68
|
+
return [...new Set(out)];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function resolveUserIdsByEmailsInWorkspace(
|
|
72
|
+
db: any,
|
|
73
|
+
workspaceId: string,
|
|
74
|
+
emails: string[],
|
|
75
|
+
): Promise<string[]> {
|
|
76
|
+
if (!emails.length) return [];
|
|
77
|
+
const ids = new Set<string>();
|
|
78
|
+
for (const raw of emails) {
|
|
79
|
+
const user = await db.user.findFirst({
|
|
80
|
+
where: { email: { equals: raw, mode: 'insensitive' }, deletedAt: null },
|
|
81
|
+
select: { id: true },
|
|
82
|
+
});
|
|
83
|
+
if (!user) continue;
|
|
84
|
+
const member = await db.workspaceMember.findFirst({
|
|
85
|
+
where: { workspaceId, userId: user.id },
|
|
86
|
+
select: { id: true },
|
|
87
|
+
});
|
|
88
|
+
const owned = await db.workspace.findFirst({
|
|
89
|
+
where: { id: workspaceId, ownerId: user.id },
|
|
90
|
+
select: { id: true },
|
|
91
|
+
});
|
|
92
|
+
if (member || owned) ids.add(user.id);
|
|
93
|
+
}
|
|
94
|
+
return [...ids];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type CreateNotificationInput = {
|
|
98
|
+
userId: string;
|
|
99
|
+
type: string;
|
|
100
|
+
title: string;
|
|
101
|
+
body: string;
|
|
102
|
+
actorUserId?: string;
|
|
103
|
+
workspaceId?: string;
|
|
104
|
+
actionUrl?: string;
|
|
105
|
+
metadata?: Record<string, unknown>;
|
|
106
|
+
priority?: NotificationPriority;
|
|
107
|
+
sourceId?: string;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
async function getUnreadCount(db: any, userId: string) {
|
|
111
|
+
return db.notification.count({
|
|
112
|
+
where: { userId, read: false },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function createNotification(db: any, input: CreateNotificationInput) {
|
|
117
|
+
if (input.sourceId) {
|
|
118
|
+
const existing = await db.notification.findFirst({
|
|
119
|
+
where: {
|
|
120
|
+
userId: input.userId,
|
|
121
|
+
type: input.type,
|
|
122
|
+
sourceId: input.sourceId,
|
|
123
|
+
},
|
|
124
|
+
// Avoid selecting all columns (including drifted optional columns) for idempotency check.
|
|
125
|
+
select: { id: true },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (existing) {
|
|
129
|
+
return existing;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const created = await db.notification.create({
|
|
134
|
+
data: {
|
|
135
|
+
userId: input.userId,
|
|
136
|
+
actorUserId: input.actorUserId,
|
|
137
|
+
workspaceId: input.workspaceId,
|
|
138
|
+
type: input.type,
|
|
139
|
+
title: input.title,
|
|
140
|
+
body: input.body,
|
|
141
|
+
content: input.body,
|
|
142
|
+
actionUrl: input.actionUrl,
|
|
143
|
+
metadata: input.metadata,
|
|
144
|
+
priority: input.priority ?? 'NORMAL',
|
|
145
|
+
sourceId: input.sourceId,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const unreadCount = await getUnreadCount(db, input.userId);
|
|
150
|
+
await PusherService.emitNotificationNew(input.userId, {
|
|
151
|
+
notificationId: created.id,
|
|
152
|
+
type: created.type,
|
|
153
|
+
title: created.title,
|
|
154
|
+
unreadCount,
|
|
155
|
+
createdAt: created.createdAt.toISOString(),
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return created;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function createManyNotifications(db: any, inputs: CreateNotificationInput[]) {
|
|
162
|
+
if (!inputs.length) return [];
|
|
163
|
+
const created = [];
|
|
164
|
+
for (const input of inputs) {
|
|
165
|
+
created.push(await createNotification(db, input));
|
|
166
|
+
}
|
|
167
|
+
return created;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function getSystemAdminIds(db: any) {
|
|
171
|
+
const admins = await db.user.findMany({
|
|
172
|
+
where: {
|
|
173
|
+
role: { name: 'System Admin' },
|
|
174
|
+
deletedAt: null,
|
|
175
|
+
},
|
|
176
|
+
select: { id: true },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return admins.map((admin: { id: string }) => admin.id);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function notifyAdminsOnSignup(db: any, user: { id: string; name?: string | null; email?: string | null }) {
|
|
183
|
+
const adminIds = await getSystemAdminIds(db);
|
|
184
|
+
if (!adminIds.length) return;
|
|
185
|
+
|
|
186
|
+
const displayName = user.name || user.email || 'New user';
|
|
187
|
+
await createManyNotifications(
|
|
188
|
+
db,
|
|
189
|
+
adminIds.map((adminId: string) => ({
|
|
190
|
+
userId: adminId,
|
|
191
|
+
actorUserId: user.id,
|
|
192
|
+
type: NotificationType.USER_SIGNED_UP,
|
|
193
|
+
title: 'New user signed up',
|
|
194
|
+
body: `${displayName} just created an account.`,
|
|
195
|
+
actionUrl: '/admin/users',
|
|
196
|
+
metadata: { newUserId: user.id, email: user.email },
|
|
197
|
+
sourceId: `signup:${user.id}`,
|
|
198
|
+
priority: 'NORMAL',
|
|
199
|
+
})),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function notifyAdminsAccountDeletionScheduled(
|
|
204
|
+
db: any,
|
|
205
|
+
user: { id: string; name?: string | null; email?: string | null },
|
|
206
|
+
) {
|
|
207
|
+
const adminIds = await getSystemAdminIds(db);
|
|
208
|
+
const targets = adminIds.filter((id: string) => id !== user.id);
|
|
209
|
+
if (!targets.length) return;
|
|
210
|
+
|
|
211
|
+
const displayName = user.name || user.email || 'A user';
|
|
212
|
+
await createManyNotifications(
|
|
213
|
+
db,
|
|
214
|
+
targets.map((adminId: string) => ({
|
|
215
|
+
userId: adminId,
|
|
216
|
+
actorUserId: user.id,
|
|
217
|
+
type: NotificationType.ACCOUNT_DELETION_SCHEDULED,
|
|
218
|
+
title: `${displayName} scheduled account deletion`,
|
|
219
|
+
body: 'Their account will be permanently deleted after the grace period unless they restore it.',
|
|
220
|
+
actionUrl: '/admin/users',
|
|
221
|
+
metadata: { deletedUserId: user.id, email: user.email },
|
|
222
|
+
sourceId: `account-deletion-scheduled:${user.id}`,
|
|
223
|
+
priority: 'HIGH',
|
|
224
|
+
})),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function notifyAdminsAccountPermanentlyDeleted(
|
|
229
|
+
db: any,
|
|
230
|
+
user: { id: string; name?: string | null; email?: string | null },
|
|
231
|
+
) {
|
|
232
|
+
const adminIds = await getSystemAdminIds(db);
|
|
233
|
+
const targets = adminIds.filter((id: string) => id !== user.id);
|
|
234
|
+
if (!targets.length) return;
|
|
235
|
+
|
|
236
|
+
const displayName = user.name || user.email || 'A user';
|
|
237
|
+
await createManyNotifications(
|
|
238
|
+
db,
|
|
239
|
+
targets.map((adminId: string) => ({
|
|
240
|
+
userId: adminId,
|
|
241
|
+
type: NotificationType.ACCOUNT_PERMANENTLY_DELETED,
|
|
242
|
+
title: `${displayName} permanently deleted`,
|
|
243
|
+
body: 'Their account and data have been removed after the grace period.',
|
|
244
|
+
actionUrl: '/admin/users',
|
|
245
|
+
metadata: { deletedUserId: user.id, email: user.email },
|
|
246
|
+
sourceId: `account-purged:${user.id}`,
|
|
247
|
+
priority: 'HIGH',
|
|
248
|
+
})),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function notifyInviteRecipient(db: any, input: {
|
|
253
|
+
invitedUserId: string;
|
|
254
|
+
inviterUserId: string;
|
|
255
|
+
workspaceId: string;
|
|
256
|
+
workspaceTitle: string;
|
|
257
|
+
invitationId: string;
|
|
258
|
+
invitationToken: string;
|
|
259
|
+
inviterName?: string | null;
|
|
260
|
+
}) {
|
|
261
|
+
await createNotification(db, {
|
|
262
|
+
userId: input.invitedUserId,
|
|
263
|
+
actorUserId: input.inviterUserId,
|
|
264
|
+
workspaceId: input.workspaceId,
|
|
265
|
+
type: NotificationType.WORKSPACE_INVITE_RECEIVED,
|
|
266
|
+
title: 'Workspace invite received',
|
|
267
|
+
body: `${input.inviterName || 'A teammate'} invited you to join "${input.workspaceTitle}".`,
|
|
268
|
+
actionUrl: `/accept-invite?token=${encodeURIComponent(input.invitationToken)}`,
|
|
269
|
+
metadata: {
|
|
270
|
+
workspaceId: input.workspaceId,
|
|
271
|
+
workspaceName: input.workspaceTitle,
|
|
272
|
+
invitationId: input.invitationId,
|
|
273
|
+
inviterUserId: input.inviterUserId,
|
|
274
|
+
},
|
|
275
|
+
sourceId: `invite:${input.invitationId}`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function notifyInviteAccepted(db: any, input: {
|
|
280
|
+
recipientUserIds: string[];
|
|
281
|
+
actorUserId: string;
|
|
282
|
+
workspaceId: string;
|
|
283
|
+
workspaceTitle: string;
|
|
284
|
+
memberName?: string | null;
|
|
285
|
+
invitationId: string;
|
|
286
|
+
}) {
|
|
287
|
+
const uniqueRecipients = Array.from(new Set(input.recipientUserIds.filter(Boolean)));
|
|
288
|
+
await createManyNotifications(
|
|
289
|
+
db,
|
|
290
|
+
uniqueRecipients.map((userId) => ({
|
|
291
|
+
userId,
|
|
292
|
+
actorUserId: input.actorUserId,
|
|
293
|
+
workspaceId: input.workspaceId,
|
|
294
|
+
type: NotificationType.WORKSPACE_INVITE_ACCEPTED,
|
|
295
|
+
title: 'Workspace invite accepted',
|
|
296
|
+
body: `${input.memberName || 'A user'} joined "${input.workspaceTitle}".`,
|
|
297
|
+
actionUrl: `/workspace/${input.workspaceId}`,
|
|
298
|
+
metadata: {
|
|
299
|
+
workspaceId: input.workspaceId,
|
|
300
|
+
workspaceTitle: input.workspaceTitle,
|
|
301
|
+
invitationId: input.invitationId,
|
|
302
|
+
},
|
|
303
|
+
sourceId: `invite-accepted:${input.invitationId}:${userId}`,
|
|
304
|
+
})),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function notifyWorkspaceDeleted(db: any, input: {
|
|
309
|
+
recipientUserIds: string[];
|
|
310
|
+
actorUserId: string;
|
|
311
|
+
actorName: string;
|
|
312
|
+
workspaceId: string;
|
|
313
|
+
workspaceTitle: string;
|
|
314
|
+
}) {
|
|
315
|
+
const uniqueRecipients = Array.from(
|
|
316
|
+
new Set(input.recipientUserIds.filter((id) => id && id !== input.actorUserId)),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
if (!uniqueRecipients.length) return;
|
|
320
|
+
|
|
321
|
+
await createManyNotifications(
|
|
322
|
+
db,
|
|
323
|
+
uniqueRecipients.map((userId) => ({
|
|
324
|
+
userId,
|
|
325
|
+
actorUserId: input.actorUserId,
|
|
326
|
+
workspaceId: input.workspaceId,
|
|
327
|
+
type: NotificationType.WORKSPACE_DELETED,
|
|
328
|
+
title: `${input.actorName} deleted workspace`,
|
|
329
|
+
body: `"${input.workspaceTitle}" was deleted.`,
|
|
330
|
+
actionUrl: '/storage',
|
|
331
|
+
metadata: {
|
|
332
|
+
workspaceId: input.workspaceId,
|
|
333
|
+
workspaceTitle: input.workspaceTitle,
|
|
334
|
+
actorName: input.actorName,
|
|
335
|
+
},
|
|
336
|
+
sourceId: `workspace-deleted:${input.workspaceId}:${userId}`,
|
|
337
|
+
priority: 'HIGH',
|
|
338
|
+
})),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function createBillingNotificationsForUserAndAdmins(
|
|
343
|
+
db: any,
|
|
344
|
+
input: Omit<CreateNotificationInput, 'userId'> & {
|
|
345
|
+
userId: string;
|
|
346
|
+
adminBody: string;
|
|
347
|
+
adminTitle?: string;
|
|
348
|
+
},
|
|
349
|
+
) {
|
|
350
|
+
await createNotification(db, input);
|
|
351
|
+
|
|
352
|
+
const adminIds = await getSystemAdminIds(db);
|
|
353
|
+
const adminTargets = adminIds.filter((id: string) => id !== input.userId);
|
|
354
|
+
if (!adminTargets.length) return;
|
|
355
|
+
|
|
356
|
+
await createManyNotifications(
|
|
357
|
+
db,
|
|
358
|
+
adminTargets.map((adminId: string) => ({
|
|
359
|
+
userId: adminId,
|
|
360
|
+
actorUserId: input.userId,
|
|
361
|
+
workspaceId: input.workspaceId,
|
|
362
|
+
type: input.type,
|
|
363
|
+
title: input.adminTitle || input.title,
|
|
364
|
+
body: input.adminBody,
|
|
365
|
+
actionUrl: '/admin/users',
|
|
366
|
+
metadata: input.metadata,
|
|
367
|
+
priority: input.priority,
|
|
368
|
+
sourceId: input.sourceId ? `${input.sourceId}:admin:${adminId}` : undefined,
|
|
369
|
+
})),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function notifyPaymentSucceeded(db: any, input: {
|
|
374
|
+
userId: string;
|
|
375
|
+
planId?: string;
|
|
376
|
+
planName?: string;
|
|
377
|
+
stripeSessionId?: string;
|
|
378
|
+
amountPaid?: number;
|
|
379
|
+
}) {
|
|
380
|
+
const planLabel = input.planName || 'selected plan';
|
|
381
|
+
await createBillingNotificationsForUserAndAdmins(db, {
|
|
382
|
+
userId: input.userId,
|
|
383
|
+
type: NotificationType.PAYMENT_SUCCEEDED,
|
|
384
|
+
title: 'Payment successful',
|
|
385
|
+
adminTitle: 'User payment successful',
|
|
386
|
+
body: `Your payment succeeded for ${planLabel}.`,
|
|
387
|
+
adminBody: `A user completed a successful payment for ${planLabel}.`,
|
|
388
|
+
actionUrl: '/settings',
|
|
389
|
+
metadata: {
|
|
390
|
+
planId: input.planId,
|
|
391
|
+
planName: input.planName,
|
|
392
|
+
stripeSessionId: input.stripeSessionId,
|
|
393
|
+
amountPaid: input.amountPaid,
|
|
394
|
+
},
|
|
395
|
+
sourceId: input.stripeSessionId ? `checkout:${input.stripeSessionId}` : undefined,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export async function notifySubscriptionActivated(db: any, input: {
|
|
400
|
+
userId: string;
|
|
401
|
+
planId?: string;
|
|
402
|
+
planName?: string;
|
|
403
|
+
stripeSubscriptionId: string;
|
|
404
|
+
}) {
|
|
405
|
+
const planLabel = input.planName || 'your plan';
|
|
406
|
+
await createBillingNotificationsForUserAndAdmins(db, {
|
|
407
|
+
userId: input.userId,
|
|
408
|
+
type: NotificationType.SUBSCRIPTION_ACTIVATED,
|
|
409
|
+
title: `You subscribed to ${planLabel}`,
|
|
410
|
+
adminTitle: `User subscribed to ${planLabel}`,
|
|
411
|
+
body: `Your ${planLabel} subscription is active.`,
|
|
412
|
+
adminBody: `A user started a ${planLabel} subscription.`,
|
|
413
|
+
actionUrl: '/settings',
|
|
414
|
+
metadata: {
|
|
415
|
+
planId: input.planId,
|
|
416
|
+
planName: input.planName,
|
|
417
|
+
stripeSubscriptionId: input.stripeSubscriptionId,
|
|
418
|
+
},
|
|
419
|
+
sourceId: `subscription-created:${input.stripeSubscriptionId}`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export async function notifySubscriptionPaymentSucceeded(db: any, input: {
|
|
424
|
+
userId: string;
|
|
425
|
+
planId?: string;
|
|
426
|
+
planName?: string;
|
|
427
|
+
stripeInvoiceId: string;
|
|
428
|
+
amountPaid?: number;
|
|
429
|
+
}) {
|
|
430
|
+
const planLabel = input.planName || 'subscription';
|
|
431
|
+
await createBillingNotificationsForUserAndAdmins(db, {
|
|
432
|
+
userId: input.userId,
|
|
433
|
+
type: NotificationType.SUBSCRIPTION_PAYMENT_SUCCEEDED,
|
|
434
|
+
title: 'Subscription payment successful',
|
|
435
|
+
adminTitle: 'User subscription payment successful',
|
|
436
|
+
body: `Your ${planLabel} payment was successful.`,
|
|
437
|
+
adminBody: `A user successfully paid for ${planLabel}.`,
|
|
438
|
+
actionUrl: '/settings',
|
|
439
|
+
metadata: {
|
|
440
|
+
planId: input.planId,
|
|
441
|
+
planName: input.planName,
|
|
442
|
+
stripeInvoiceId: input.stripeInvoiceId,
|
|
443
|
+
amountPaid: input.amountPaid,
|
|
444
|
+
},
|
|
445
|
+
sourceId: `invoice-paid:${input.stripeInvoiceId}`,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export async function notifySubscriptionCanceled(db: any, input: {
|
|
450
|
+
userId: string;
|
|
451
|
+
planId?: string;
|
|
452
|
+
planName?: string;
|
|
453
|
+
stripeSubscriptionId: string;
|
|
454
|
+
}) {
|
|
455
|
+
const planLabel = input.planName || 'subscription';
|
|
456
|
+
await createBillingNotificationsForUserAndAdmins(db, {
|
|
457
|
+
userId: input.userId,
|
|
458
|
+
type: NotificationType.SUBSCRIPTION_CANCELED,
|
|
459
|
+
title: 'Subscription canceled',
|
|
460
|
+
adminTitle: 'User subscription canceled',
|
|
461
|
+
body: `Your ${planLabel} subscription was canceled.`,
|
|
462
|
+
adminBody: `A user canceled ${planLabel}.`,
|
|
463
|
+
actionUrl: '/settings',
|
|
464
|
+
metadata: {
|
|
465
|
+
planId: input.planId,
|
|
466
|
+
planName: input.planName,
|
|
467
|
+
stripeSubscriptionId: input.stripeSubscriptionId,
|
|
468
|
+
},
|
|
469
|
+
sourceId: `subscription-canceled:${input.stripeSubscriptionId}`,
|
|
470
|
+
priority: 'HIGH',
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export async function notifyPaymentFailed(db: any, input: {
|
|
475
|
+
userId: string;
|
|
476
|
+
planId?: string;
|
|
477
|
+
planName?: string;
|
|
478
|
+
stripeInvoiceId?: string;
|
|
479
|
+
stripePaymentIntentId?: string;
|
|
480
|
+
}) {
|
|
481
|
+
const planLabel = input.planName || 'payment';
|
|
482
|
+
const sourceId = input.stripeInvoiceId
|
|
483
|
+
? `invoice-failed:${input.stripeInvoiceId}`
|
|
484
|
+
: input.stripePaymentIntentId
|
|
485
|
+
? `pi-failed:${input.stripePaymentIntentId}`
|
|
486
|
+
: undefined;
|
|
487
|
+
|
|
488
|
+
await createBillingNotificationsForUserAndAdmins(db, {
|
|
489
|
+
userId: input.userId,
|
|
490
|
+
type: NotificationType.PAYMENT_FAILED,
|
|
491
|
+
title: 'Payment failed',
|
|
492
|
+
adminTitle: 'User payment failed',
|
|
493
|
+
body: `Your ${planLabel} payment failed. Please update billing and retry.`,
|
|
494
|
+
adminBody: `A user payment failed for ${planLabel}.`,
|
|
495
|
+
actionUrl: '/settings',
|
|
496
|
+
metadata: {
|
|
497
|
+
planId: input.planId,
|
|
498
|
+
planName: input.planName,
|
|
499
|
+
stripeInvoiceId: input.stripeInvoiceId,
|
|
500
|
+
stripePaymentIntentId: input.stripePaymentIntentId,
|
|
501
|
+
},
|
|
502
|
+
sourceId,
|
|
503
|
+
priority: 'HIGH',
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export async function notifyWorkspaceRoleChanged(
|
|
508
|
+
db: any,
|
|
509
|
+
input: {
|
|
510
|
+
memberUserId: string;
|
|
511
|
+
workspaceId: string;
|
|
512
|
+
workspaceTitle: string;
|
|
513
|
+
newRole: string;
|
|
514
|
+
oldRole: string;
|
|
515
|
+
actorUserId: string;
|
|
516
|
+
actorName: string;
|
|
517
|
+
},
|
|
518
|
+
) {
|
|
519
|
+
const roleLabel = input.newRole === 'admin' ? 'admin' : 'member';
|
|
520
|
+
const oldLabel = input.oldRole === 'admin' ? 'admin' : 'member';
|
|
521
|
+
await createNotification(db, {
|
|
522
|
+
userId: input.memberUserId,
|
|
523
|
+
actorUserId: input.actorUserId,
|
|
524
|
+
workspaceId: input.workspaceId,
|
|
525
|
+
type: NotificationType.WORKSPACE_ROLE_CHANGED,
|
|
526
|
+
title: `Your role in "${input.workspaceTitle}" changed`,
|
|
527
|
+
body: `${input.actorName} changed your role from ${oldLabel} to ${roleLabel}.`,
|
|
528
|
+
actionUrl: `/workspace/${input.workspaceId}/members`,
|
|
529
|
+
metadata: {
|
|
530
|
+
oldRole: input.oldRole,
|
|
531
|
+
newRole: input.newRole,
|
|
532
|
+
workspaceTitle: input.workspaceTitle,
|
|
533
|
+
},
|
|
534
|
+
priority: 'NORMAL',
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export async function notifyWorkspaceMembershipRemoved(
|
|
539
|
+
db: any,
|
|
540
|
+
input: {
|
|
541
|
+
memberUserId: string;
|
|
542
|
+
workspaceId: string;
|
|
543
|
+
workspaceTitle: string;
|
|
544
|
+
actorUserId: string;
|
|
545
|
+
actorName: string;
|
|
546
|
+
},
|
|
547
|
+
) {
|
|
548
|
+
await createNotification(db, {
|
|
549
|
+
userId: input.memberUserId,
|
|
550
|
+
actorUserId: input.actorUserId,
|
|
551
|
+
workspaceId: input.workspaceId,
|
|
552
|
+
type: NotificationType.WORKSPACE_MEMBERSHIP_REMOVED,
|
|
553
|
+
title: `Removed from "${input.workspaceTitle}"`,
|
|
554
|
+
body: `${input.actorName} removed you from this workspace.`,
|
|
555
|
+
actionUrl: '/storage',
|
|
556
|
+
metadata: { workspaceTitle: input.workspaceTitle },
|
|
557
|
+
priority: 'HIGH',
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export async function notifyStudyGuideCommentAdded(
|
|
562
|
+
db: any,
|
|
563
|
+
input: {
|
|
564
|
+
authorUserId: string;
|
|
565
|
+
authorName: string;
|
|
566
|
+
content: string;
|
|
567
|
+
highlightId: string;
|
|
568
|
+
commentId: string;
|
|
569
|
+
workspaceId: string;
|
|
570
|
+
artifactId: string;
|
|
571
|
+
artifactTitle: string;
|
|
572
|
+
recipientUserIds: string[];
|
|
573
|
+
},
|
|
574
|
+
) {
|
|
575
|
+
const mentionEmails = extractMentionEmails(input.content);
|
|
576
|
+
const mentionUserIds = await resolveUserIdsByEmailsInWorkspace(
|
|
577
|
+
db,
|
|
578
|
+
input.workspaceId,
|
|
579
|
+
mentionEmails,
|
|
580
|
+
);
|
|
581
|
+
const combined = new Set<string>([
|
|
582
|
+
...input.recipientUserIds.filter(Boolean),
|
|
583
|
+
...mentionUserIds,
|
|
584
|
+
]);
|
|
585
|
+
combined.delete(input.authorUserId);
|
|
586
|
+
const targets = [...combined];
|
|
587
|
+
if (!targets.length) return;
|
|
588
|
+
|
|
589
|
+
const preview =
|
|
590
|
+
input.content.length > 140
|
|
591
|
+
? `${input.content.slice(0, 137)}...`
|
|
592
|
+
: input.content;
|
|
593
|
+
|
|
594
|
+
await createManyNotifications(
|
|
595
|
+
db,
|
|
596
|
+
targets.map((userId) => ({
|
|
597
|
+
userId,
|
|
598
|
+
actorUserId: input.authorUserId,
|
|
599
|
+
workspaceId: input.workspaceId,
|
|
600
|
+
type: NotificationType.STUDY_GUIDE_COMMENT_ADDED,
|
|
601
|
+
title: `${input.authorName} commented on "${input.artifactTitle}"`,
|
|
602
|
+
body: preview,
|
|
603
|
+
actionUrl: `/workspace/${input.workspaceId}/study-guide`,
|
|
604
|
+
metadata: {
|
|
605
|
+
artifactId: input.artifactId,
|
|
606
|
+
highlightId: input.highlightId,
|
|
607
|
+
commentId: input.commentId,
|
|
608
|
+
},
|
|
609
|
+
sourceId: `comment:${input.commentId}:${userId}`,
|
|
610
|
+
})),
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export async function notifyArtifactReady(
|
|
615
|
+
db: any,
|
|
616
|
+
input: {
|
|
617
|
+
userId: string;
|
|
618
|
+
workspaceId: string;
|
|
619
|
+
artifactId: string;
|
|
620
|
+
artifactType: string;
|
|
621
|
+
title: string;
|
|
622
|
+
},
|
|
623
|
+
) {
|
|
624
|
+
const kind = artifactTypeLabel(input.artifactType);
|
|
625
|
+
await createNotification(db, {
|
|
626
|
+
userId: input.userId,
|
|
627
|
+
workspaceId: input.workspaceId,
|
|
628
|
+
type: NotificationType.ARTIFACT_READY,
|
|
629
|
+
title: `${kind} ready`,
|
|
630
|
+
body: `"${input.title}" is ready to open.`,
|
|
631
|
+
actionUrl: buildArtifactActionUrl(
|
|
632
|
+
input.workspaceId,
|
|
633
|
+
input.artifactId,
|
|
634
|
+
input.artifactType,
|
|
635
|
+
),
|
|
636
|
+
metadata: {
|
|
637
|
+
artifactId: input.artifactId,
|
|
638
|
+
artifactType: input.artifactType,
|
|
639
|
+
},
|
|
640
|
+
sourceId: `artifact-ready:${input.artifactId}`,
|
|
641
|
+
priority: 'NORMAL',
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export async function notifyArtifactFailed(
|
|
646
|
+
db: any,
|
|
647
|
+
input: {
|
|
648
|
+
userId: string;
|
|
649
|
+
workspaceId: string;
|
|
650
|
+
artifactType: string;
|
|
651
|
+
title?: string;
|
|
652
|
+
artifactId?: string;
|
|
653
|
+
message: string;
|
|
654
|
+
},
|
|
655
|
+
) {
|
|
656
|
+
const kind = artifactTypeLabel(input.artifactType);
|
|
657
|
+
await createNotification(db, {
|
|
658
|
+
userId: input.userId,
|
|
659
|
+
workspaceId: input.workspaceId,
|
|
660
|
+
type: NotificationType.ARTIFACT_FAILED,
|
|
661
|
+
title: `${kind} generation failed`,
|
|
662
|
+
body: input.message,
|
|
663
|
+
actionUrl: buildArtifactActionUrl(
|
|
664
|
+
input.workspaceId,
|
|
665
|
+
input.artifactId,
|
|
666
|
+
input.artifactType,
|
|
667
|
+
),
|
|
668
|
+
metadata: {
|
|
669
|
+
artifactId: input.artifactId,
|
|
670
|
+
artifactType: input.artifactType,
|
|
671
|
+
},
|
|
672
|
+
sourceId: input.artifactId
|
|
673
|
+
? `artifact-failed:${input.artifactId}`
|
|
674
|
+
: undefined,
|
|
675
|
+
priority: 'HIGH',
|
|
676
|
+
});
|
|
677
|
+
}
|
package/src/lib/prisma.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
|
|
1
|
+
// Resolve the client from this package's generated output so types always match
|
|
2
|
+
// `prisma/schema.prisma` (the `@prisma/client` entry can resolve to a different
|
|
3
|
+
// generated client in multi-folder workspaces and lose model delegates in the IDE).
|
|
4
|
+
import { PrismaClient } from "../../node_modules/.prisma/client/index.js";
|
|
5
|
+
|
|
6
|
+
export { ArtifactType } from "../../node_modules/.prisma/client/index.js";
|
|
2
7
|
|
|
3
8
|
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
|
4
9
|
|