@goscribe/server 1.1.7 → 1.3.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.
- package/.env.example +43 -0
- package/check-difficulty.cjs +14 -0
- package/check-questions.cjs +14 -0
- package/db-summary.cjs +22 -0
- package/dist/routers/auth.js +1 -1
- package/mcq-test.cjs +36 -0
- package/package.json +10 -2
- package/prisma/migrations/20260413143206_init/migration.sql +873 -0
- package/prisma/schema.prisma +485 -292
- 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 +194 -112
- package/src/lib/constants.ts +14 -0
- package/src/lib/email.ts +230 -0
- package/src/lib/env.ts +23 -6
- package/src/lib/inference.ts +3 -3
- package/src/lib/logger.ts +26 -9
- 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 +90 -6
- package/src/lib/retry.ts +61 -0
- package/src/lib/storage.ts +2 -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/lib/workspace-access.ts +13 -0
- package/src/routers/_app.ts +11 -0
- package/src/routers/admin.ts +710 -0
- package/src/routers/annotations.ts +227 -0
- package/src/routers/auth.ts +432 -33
- package/src/routers/copilot.ts +719 -0
- package/src/routers/flashcards.ts +207 -80
- 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 +133 -108
- package/src/routers/studyguide.ts +80 -74
- package/src/routers/worksheets.ts +300 -80
- package/src/routers/workspace.ts +538 -328
- package/src/scripts/purge-deleted-users.ts +167 -0
- package/src/server.ts +140 -12
- package/src/services/flashcard-progress.service.ts +52 -43
- 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
- package/src/routers/meetingsummary.ts +0 -416
package/src/lib/email.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer';
|
|
2
|
+
import { logger } from './logger.js';
|
|
3
|
+
import { env } from './env.js';
|
|
4
|
+
|
|
5
|
+
// Commented out Resend flow as requested
|
|
6
|
+
// import { Resend } from 'resend';
|
|
7
|
+
// const resend = env.RESEND_API_KEY ? new Resend(env.RESEND_API_KEY) : null;
|
|
8
|
+
|
|
9
|
+
const transporter = nodemailer.createTransport({
|
|
10
|
+
host: env.SMTP_HOST,
|
|
11
|
+
port: env.SMTP_PORT,
|
|
12
|
+
secure: env.SMTP_SECURE,
|
|
13
|
+
auth: {
|
|
14
|
+
user: env.SMTP_USER,
|
|
15
|
+
pass: env.SMTP_PASSWORD,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const FROM_EMAIL = env.EMAIL_FROM;
|
|
20
|
+
const APP_URL = env.FRONTEND_URL;
|
|
21
|
+
|
|
22
|
+
export async function sendVerificationEmail(
|
|
23
|
+
email: string,
|
|
24
|
+
token: string,
|
|
25
|
+
name?: string | null
|
|
26
|
+
): Promise<boolean> {
|
|
27
|
+
const verifyUrl = `${APP_URL}/verify-email?token=${token}`;
|
|
28
|
+
const greeting = name ? `, ${name}` : '';
|
|
29
|
+
|
|
30
|
+
const html = `
|
|
31
|
+
<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
|
|
32
|
+
<h2 style="font-size:20px;font-weight:600;margin-bottom:8px;">Welcome to Scribe${greeting}!</h2>
|
|
33
|
+
<p style="color:#6b7280;font-size:14px;line-height:1.6;margin-bottom:24px;">
|
|
34
|
+
Please verify your email address to get the most out of your account.
|
|
35
|
+
</p>
|
|
36
|
+
<a href="${verifyUrl}" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Verify Email</a>
|
|
37
|
+
<p style="color:#9ca3af;font-size:12px;margin-top:32px;line-height:1.5;">
|
|
38
|
+
If you did not create an account on Scribe, you can safely ignore this email.
|
|
39
|
+
This link expires in 24 hours.
|
|
40
|
+
</p>
|
|
41
|
+
</div>
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
if (!env.SMTP_HOST) {
|
|
46
|
+
logger.warn('Email service not configured (SMTP_HOST missing). Logging email content instead:');
|
|
47
|
+
logger.info(`Verification Email to ${email}: ${verifyUrl}`);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await transporter.sendMail({
|
|
52
|
+
from: FROM_EMAIL,
|
|
53
|
+
to: email,
|
|
54
|
+
subject: 'Verify your email - Scribe',
|
|
55
|
+
html,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
logger.info(`Verification email sent to ${email}`);
|
|
59
|
+
return true;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
logger.error('Email send error:', err);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function sendInvitationEmail(invitation: {
|
|
67
|
+
email: string;
|
|
68
|
+
token: string;
|
|
69
|
+
role: string;
|
|
70
|
+
workspaceTitle: string;
|
|
71
|
+
invitedByName: string;
|
|
72
|
+
}): Promise<boolean> {
|
|
73
|
+
const inviteUrl = `${APP_URL}/accept-invite?token=${invitation.token}`;
|
|
74
|
+
|
|
75
|
+
const html = `
|
|
76
|
+
<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
|
|
77
|
+
<h2 style="font-size:20px;font-weight:600;margin-bottom:8px;">Workspace Invitation</h2>
|
|
78
|
+
<p style="color:#374151;font-size:14px;line-height:1.6;margin-bottom:16px;">
|
|
79
|
+
<strong>${invitation.invitedByName}</strong> has invited you to join the <strong>${invitation.workspaceTitle}</strong> workspace as a <strong>${invitation.role}</strong>.
|
|
80
|
+
</p>
|
|
81
|
+
<p style="color:#6b7280;font-size:14px;line-height:1.6;margin-bottom:24px;">
|
|
82
|
+
Click the button below to accept the invitation and start collaborating.
|
|
83
|
+
</p>
|
|
84
|
+
<a href="${inviteUrl}" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Accept Invitation</a>
|
|
85
|
+
<p style="color:#9ca3af;font-size:12px;margin-top:32px;line-height:1.5;">
|
|
86
|
+
This invitation was sent to ${invitation.email}. If you weren't expecting this invitation, you can safely ignore this email.
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
if (!env.SMTP_HOST) {
|
|
93
|
+
logger.warn('Email service not configured (SMTP_HOST missing). Logging invitation link instead:');
|
|
94
|
+
logger.info(`Invitation Link for ${invitation.email}: ${inviteUrl}`);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await transporter.sendMail({
|
|
99
|
+
from: FROM_EMAIL,
|
|
100
|
+
to: invitation.email,
|
|
101
|
+
subject: `Invitation to join ${invitation.workspaceTitle} on Scribe`,
|
|
102
|
+
html,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
logger.info(`Invitation email sent to ${invitation.email}`);
|
|
106
|
+
return true;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logger.error('Email send error:', err);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function sendAccountDeletionScheduledEmail(email: string, token: string): Promise<boolean> {
|
|
114
|
+
const restoreUrl = `${APP_URL}/restore-account?token=${token}`;
|
|
115
|
+
|
|
116
|
+
const html = `
|
|
117
|
+
<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
|
|
118
|
+
<h2 style="font-size:20px;font-weight:600;margin-bottom:8px;color:#dc2626;">Account Deletion Scheduled</h2>
|
|
119
|
+
<p style="color:#374151;font-size:14px;line-height:1.6;margin-bottom:16px;">
|
|
120
|
+
Your account is scheduled for permanent deletion in 30 days.
|
|
121
|
+
</p>
|
|
122
|
+
<p style="color:#6b7280;font-size:14px;line-height:1.6;margin-bottom:24px;">
|
|
123
|
+
If you change your mind, you can restore your account by clicking the button below. This link will remain active during the 30-day grace period.
|
|
124
|
+
</p>
|
|
125
|
+
<a href="${restoreUrl}" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Restore Account</a>
|
|
126
|
+
<p style="color:#9ca3af;font-size:12px;margin-top:32px;line-height:1.5;">
|
|
127
|
+
If you meant to delete your account, you can safely ignore this email.
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
if (!env.SMTP_HOST) {
|
|
134
|
+
logger.warn('Email service not configured (SMTP_HOST missing). Logging email content instead:');
|
|
135
|
+
logger.info(`Account Deletion Scheduled Email to ${email}: ${restoreUrl}`);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await transporter.sendMail({
|
|
140
|
+
from: FROM_EMAIL,
|
|
141
|
+
to: email,
|
|
142
|
+
subject: 'Account Deletion Scheduled - Scribe',
|
|
143
|
+
html,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
logger.info(`Account deletion scheduled email sent to ${email}`);
|
|
147
|
+
return true;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
logger.error('Email send error:', err);
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function sendPasswordResetEmail(
|
|
155
|
+
email: string,
|
|
156
|
+
token: string,
|
|
157
|
+
name?: string | null
|
|
158
|
+
): Promise<boolean> {
|
|
159
|
+
const resetUrl = `${APP_URL}/reset-password?token=${encodeURIComponent(token)}`;
|
|
160
|
+
const greeting = name ? `, ${name}` : '';
|
|
161
|
+
|
|
162
|
+
const html = `
|
|
163
|
+
<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
|
|
164
|
+
<h2 style="font-size:20px;font-weight:600;margin-bottom:8px;">Reset your password${greeting}</h2>
|
|
165
|
+
<p style="color:#6b7280;font-size:14px;line-height:1.6;margin-bottom:24px;">
|
|
166
|
+
We received a request to reset your Scribe password. Click the button below to choose a new password.
|
|
167
|
+
</p>
|
|
168
|
+
<a href="${resetUrl}" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Reset password</a>
|
|
169
|
+
<p style="color:#9ca3af;font-size:12px;margin-top:32px;line-height:1.5;">
|
|
170
|
+
If you did not request this, you can ignore this email. This link expires in 1 hour.
|
|
171
|
+
</p>
|
|
172
|
+
</div>
|
|
173
|
+
`;
|
|
174
|
+
|
|
175
|
+
try {
|
|
176
|
+
if (!env.SMTP_HOST) {
|
|
177
|
+
logger.warn('Email service not configured (SMTP_HOST missing). Logging reset link instead:');
|
|
178
|
+
logger.info(`Password reset for ${email}: ${resetUrl}`);
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await transporter.sendMail({
|
|
183
|
+
from: FROM_EMAIL,
|
|
184
|
+
to: email,
|
|
185
|
+
subject: 'Reset your password - Scribe',
|
|
186
|
+
html,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
logger.info(`Password reset email sent to ${email}`);
|
|
190
|
+
return true;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
logger.error('Email send error:', err);
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function sendAccountRestoredEmail(email: string): Promise<boolean> {
|
|
198
|
+
const loginUrl = `${APP_URL}/login`;
|
|
199
|
+
|
|
200
|
+
const html = `
|
|
201
|
+
<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto;padding:40px 20px;">
|
|
202
|
+
<h2 style="font-size:20px;font-weight:600;margin-bottom:8px;color:#16a34a;">Account Restored Successfully</h2>
|
|
203
|
+
<p style="color:#374151;font-size:14px;line-height:1.6;margin-bottom:16px;">
|
|
204
|
+
Your account has been successfully restored. Your data is safe and your account deletion process has been cancelled.
|
|
205
|
+
</p>
|
|
206
|
+
<a href="${loginUrl}" style="display:inline-block;background-color:#7c3aed;color:#fff;text-decoration:none;padding:10px 24px;border-radius:6px;font-size:14px;font-weight:500;">Log In</a>
|
|
207
|
+
</div>
|
|
208
|
+
`;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
if (!env.SMTP_HOST) {
|
|
212
|
+
logger.warn('Email service not configured (SMTP_HOST missing). Logging email content instead:');
|
|
213
|
+
logger.info(`Account Restored Email to ${email}`);
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await transporter.sendMail({
|
|
218
|
+
from: FROM_EMAIL,
|
|
219
|
+
to: email,
|
|
220
|
+
subject: 'Account Restored - Scribe',
|
|
221
|
+
html,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
logger.info(`Account restored email sent to ${email}`);
|
|
225
|
+
return true;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
logger.error('Email send error:', err);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
package/src/lib/env.ts
CHANGED
|
@@ -10,37 +10,54 @@ const envSchema = z.object({
|
|
|
10
10
|
// Database
|
|
11
11
|
DATABASE_URL: z.string().url(),
|
|
12
12
|
DIRECT_URL: z.string().url().optional(),
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
// Server
|
|
15
15
|
PORT: z.string().regex(/^\d+$/).default('3001').transform(Number),
|
|
16
16
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
// Auth
|
|
19
19
|
BETTER_AUTH_SECRET: z.string().min(32).optional(),
|
|
20
20
|
BETTER_AUTH_URL: z.string().url().optional(),
|
|
21
|
-
|
|
21
|
+
|
|
22
22
|
// Storage
|
|
23
23
|
GOOGLE_CLOUD_PROJECT_ID: z.string().optional(),
|
|
24
24
|
GOOGLE_CLOUD_BUCKET_NAME: z.string().optional(),
|
|
25
25
|
GOOGLE_APPLICATION_CREDENTIALS: z.string().optional(),
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
// Pusher
|
|
28
28
|
PUSHER_APP_ID: z.string().optional(),
|
|
29
29
|
PUSHER_KEY: z.string().optional(),
|
|
30
30
|
PUSHER_SECRET: z.string().optional(),
|
|
31
31
|
PUSHER_CLUSTER: z.string().optional(),
|
|
32
|
-
|
|
32
|
+
|
|
33
33
|
// Inference
|
|
34
34
|
INFERENCE_API_URL: z.string().url().optional(),
|
|
35
|
-
|
|
35
|
+
|
|
36
36
|
// CORS
|
|
37
37
|
FRONTEND_URL: z.string().url().default('http://localhost:3000'),
|
|
38
|
+
|
|
39
|
+
// Email
|
|
40
|
+
SMTP_HOST: z.string(),
|
|
41
|
+
SMTP_PORT: z.string().regex(/^\d+$/).default('587').transform(Number),
|
|
42
|
+
SMTP_USER: z.string().optional(),
|
|
43
|
+
SMTP_PASSWORD: z.string().optional(),
|
|
44
|
+
SMTP_SECURE: z.enum(['true', 'false']).default('false').transform((v) => v === 'true'),
|
|
45
|
+
EMAIL_FROM: z.string().default('Scribe <noreply@goscribe.app>'),
|
|
46
|
+
// Stripe
|
|
47
|
+
STRIPE_SECRET_KEY: z.string().startsWith('sk_').optional(),
|
|
48
|
+
STRIPE_SUCCESS_URL: z.string().url().default('http://localhost:3000/payment-success'),
|
|
49
|
+
STRIPE_CANCEL_URL: z.string().url().default('http://localhost:3000/payment-cancel'),
|
|
50
|
+
STRIPE_PRICE_SUB_BASIC: z.string().startsWith('price_').optional(),
|
|
51
|
+
STRIPE_PRICE_SUB_PRO: z.string().startsWith('price_').optional(),
|
|
52
|
+
STRIPE_PRICE_CREDITS_1000: z.string().startsWith('price_').optional(),
|
|
53
|
+
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_').optional(),
|
|
38
54
|
});
|
|
39
55
|
|
|
40
56
|
/**
|
|
41
57
|
* Parsed and validated environment variables
|
|
42
58
|
*/
|
|
43
59
|
export const env = envSchema.parse(process.env);
|
|
60
|
+
// console.log('DEBUG: SMTP_HOST loaded:', env.SMTP_HOST ? 'Yes' : 'No');
|
|
44
61
|
|
|
45
62
|
/**
|
|
46
63
|
* Check if running in production
|
package/src/lib/inference.ts
CHANGED
|
@@ -5,15 +5,15 @@ const openai = new OpenAI({
|
|
|
5
5
|
baseURL: process.env.INFERENCE_BASE_URL,
|
|
6
6
|
});
|
|
7
7
|
|
|
8
|
-
async function inference(
|
|
8
|
+
async function inference(messages: { role: 'system' | 'user' | 'assistant', content: string }[]) {
|
|
9
9
|
try {
|
|
10
10
|
const response = await openai.chat.completions.create({
|
|
11
11
|
model: "command-a-03-2025",
|
|
12
|
-
messages:
|
|
12
|
+
messages: messages,
|
|
13
13
|
});
|
|
14
14
|
return response;
|
|
15
15
|
} catch (error) {
|
|
16
|
-
|
|
16
|
+
// Inference error logged at call site
|
|
17
17
|
throw error;
|
|
18
18
|
}
|
|
19
19
|
}
|
package/src/lib/logger.ts
CHANGED
|
@@ -163,7 +163,8 @@ class Logger {
|
|
|
163
163
|
return `\x1b[90m${time}\x1b[0m`; // Gray color
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
private formatContext(context: string): string {
|
|
166
|
+
private formatContext(context: string | unknown): string {
|
|
167
|
+
if (typeof context !== 'string') return '';
|
|
167
168
|
const icon = CONTEXT_ICONS[context.toUpperCase()] || '📦';
|
|
168
169
|
return `\x1b[94m${icon}${context}\x1b[0m `; // Blue color
|
|
169
170
|
}
|
|
@@ -248,20 +249,36 @@ class Logger {
|
|
|
248
249
|
}
|
|
249
250
|
}
|
|
250
251
|
|
|
251
|
-
error(message: string,
|
|
252
|
-
|
|
252
|
+
error(message: string, contextOrError?: string | Error | unknown, metadata?: Record<string, any>, error?: Error): void {
|
|
253
|
+
if (contextOrError instanceof Error) {
|
|
254
|
+
this.log(LogLevel.ERROR, message, undefined, metadata, contextOrError);
|
|
255
|
+
} else {
|
|
256
|
+
this.log(LogLevel.ERROR, message, contextOrError as string | undefined, metadata, error);
|
|
257
|
+
}
|
|
253
258
|
}
|
|
254
259
|
|
|
255
|
-
warn(message: string,
|
|
256
|
-
|
|
260
|
+
warn(message: string, contextOrMeta?: string | Record<string, any>, metadata?: Record<string, any>): void {
|
|
261
|
+
if (typeof contextOrMeta === 'object' && contextOrMeta !== null) {
|
|
262
|
+
this.log(LogLevel.WARN, message, undefined, contextOrMeta as Record<string, any>);
|
|
263
|
+
} else {
|
|
264
|
+
this.log(LogLevel.WARN, message, contextOrMeta as string | undefined, metadata);
|
|
265
|
+
}
|
|
257
266
|
}
|
|
258
267
|
|
|
259
|
-
info(message: string,
|
|
260
|
-
|
|
268
|
+
info(message: string, contextOrMeta?: string | Record<string, any>, metadata?: Record<string, any>): void {
|
|
269
|
+
if (typeof contextOrMeta === 'object' && contextOrMeta !== null) {
|
|
270
|
+
this.log(LogLevel.INFO, message, undefined, contextOrMeta as Record<string, any>);
|
|
271
|
+
} else {
|
|
272
|
+
this.log(LogLevel.INFO, message, contextOrMeta as string | undefined, metadata);
|
|
273
|
+
}
|
|
261
274
|
}
|
|
262
275
|
|
|
263
|
-
debug(message: string,
|
|
264
|
-
|
|
276
|
+
debug(message: string, contextOrMeta?: string | Record<string, any>, metadata?: Record<string, any>): void {
|
|
277
|
+
if (typeof contextOrMeta === 'object' && contextOrMeta !== null) {
|
|
278
|
+
this.log(LogLevel.DEBUG, message, undefined, contextOrMeta as Record<string, any>);
|
|
279
|
+
} else {
|
|
280
|
+
this.log(LogLevel.DEBUG, message, contextOrMeta as string | undefined, metadata);
|
|
281
|
+
}
|
|
265
282
|
}
|
|
266
283
|
|
|
267
284
|
trace(message: string, context?: string, metadata?: Record<string, any>): void {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import PusherService from './pusher.js';
|
|
4
|
+
import { NotificationType, createNotification } from './notification-service.js';
|
|
5
|
+
|
|
6
|
+
type FakeNotification = {
|
|
7
|
+
id: string;
|
|
8
|
+
userId: string;
|
|
9
|
+
type: string;
|
|
10
|
+
title: string;
|
|
11
|
+
body: string;
|
|
12
|
+
content?: string;
|
|
13
|
+
sourceId?: string;
|
|
14
|
+
createdAt: Date;
|
|
15
|
+
read: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function createFakeDb() {
|
|
19
|
+
const store: FakeNotification[] = [];
|
|
20
|
+
|
|
21
|
+
const db = {
|
|
22
|
+
notification: {
|
|
23
|
+
async findFirst(args: any) {
|
|
24
|
+
const where = args?.where ?? {};
|
|
25
|
+
return (
|
|
26
|
+
store.find((item) => {
|
|
27
|
+
if (where.userId && item.userId !== where.userId) return false;
|
|
28
|
+
if (where.type && item.type !== where.type) return false;
|
|
29
|
+
if (where.sourceId && item.sourceId !== where.sourceId) return false;
|
|
30
|
+
return true;
|
|
31
|
+
}) ?? null
|
|
32
|
+
);
|
|
33
|
+
},
|
|
34
|
+
async create(args: any) {
|
|
35
|
+
const data = args.data;
|
|
36
|
+
const item: FakeNotification = {
|
|
37
|
+
id: `n_${store.length + 1}`,
|
|
38
|
+
userId: data.userId,
|
|
39
|
+
type: data.type,
|
|
40
|
+
title: data.title,
|
|
41
|
+
body: data.body,
|
|
42
|
+
content: data.content,
|
|
43
|
+
sourceId: data.sourceId,
|
|
44
|
+
createdAt: new Date(),
|
|
45
|
+
read: false,
|
|
46
|
+
};
|
|
47
|
+
store.push(item);
|
|
48
|
+
return item;
|
|
49
|
+
},
|
|
50
|
+
async count(args: any) {
|
|
51
|
+
const where = args?.where ?? {};
|
|
52
|
+
return store.filter((item) => {
|
|
53
|
+
if (where.userId && item.userId !== where.userId) return false;
|
|
54
|
+
if (where.read !== undefined && item.read !== where.read) return false;
|
|
55
|
+
return true;
|
|
56
|
+
}).length;
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return { db, store };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
test('createNotification deduplicates by sourceId', async () => {
|
|
65
|
+
const { db, store } = createFakeDb();
|
|
66
|
+
const originalEmit = PusherService.emitNotificationNew;
|
|
67
|
+
PusherService.emitNotificationNew = async () => {};
|
|
68
|
+
|
|
69
|
+
await createNotification(db, {
|
|
70
|
+
userId: 'u1',
|
|
71
|
+
type: NotificationType.PAYMENT_SUCCEEDED,
|
|
72
|
+
title: 'Payment successful',
|
|
73
|
+
body: 'Paid',
|
|
74
|
+
sourceId: 'source-1',
|
|
75
|
+
});
|
|
76
|
+
await createNotification(db, {
|
|
77
|
+
userId: 'u1',
|
|
78
|
+
type: NotificationType.PAYMENT_SUCCEEDED,
|
|
79
|
+
title: 'Payment successful',
|
|
80
|
+
body: 'Paid',
|
|
81
|
+
sourceId: 'source-1',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
assert.equal(store.length, 1);
|
|
85
|
+
PusherService.emitNotificationNew = originalEmit;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('createNotification emits unread count payload', async () => {
|
|
89
|
+
const { db } = createFakeDb();
|
|
90
|
+
const originalEmit = PusherService.emitNotificationNew;
|
|
91
|
+
let capturedUnread = -1;
|
|
92
|
+
|
|
93
|
+
PusherService.emitNotificationNew = async (_userId, payload) => {
|
|
94
|
+
capturedUnread = payload.unreadCount;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
await createNotification(db, {
|
|
98
|
+
userId: 'u1',
|
|
99
|
+
type: NotificationType.GENERAL,
|
|
100
|
+
title: 'Hello',
|
|
101
|
+
body: 'World',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
assert.equal(capturedUnread, 1);
|
|
105
|
+
PusherService.emitNotificationNew = originalEmit;
|
|
106
|
+
});
|