Compare commits
4 Commits
1408d0cd1d
...
8e953cdd7c
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e953cdd7c | |||
| a07c004f2d | |||
| 203591ca05 | |||
| 61d48d3648 |
10232
pnpm-lock.yaml
generated
10232
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
packages:
|
||||
- "web"
|
||||
- "browser-ext"
|
||||
@@ -21,7 +21,7 @@ Status legend: [ ] todo, [~] in-progress, [x] done
|
||||
### Performance Optimization
|
||||
- [x] 09 — Image Caching & Lazy Loading → `09-image-caching.md`
|
||||
- [x] 10 — Memory Management & Leak Audit → `10-memory-leak-audit.md`
|
||||
- [~] 11 — Background Fetch & Sync Optimization → `11-background-fetch.md`
|
||||
- [x] 11 — Background Fetch & Sync Optimization → `11-background-fetch.md`
|
||||
- [x] 12 — App Launch Time Optimization → `12-launch-time.md`
|
||||
|
||||
### Native Features
|
||||
|
||||
1
web/.gitignore
vendored
1
web/.gitignore
vendored
@@ -5,6 +5,7 @@ dist
|
||||
.netlify
|
||||
.vinxi
|
||||
app.config.timestamp_*.js
|
||||
.pi-lens
|
||||
|
||||
# Environment
|
||||
.env*
|
||||
|
||||
2
web/.pi-lens/cache/review-graph.json
vendored
2
web/.pi-lens/cache/review-graph.json
vendored
File diff suppressed because one or more lines are too long
51
web/.vercelignore
Normal file
51
web/.vercelignore
Normal file
@@ -0,0 +1,51 @@
|
||||
# ── ML Model (255MB ONNX model — too large for Vercel, downloaded at runtime) ──
|
||||
src/server/models/spam-classifier/
|
||||
|
||||
# ── Build Artifacts ──
|
||||
.output/
|
||||
.nitro/
|
||||
dist/
|
||||
|
||||
# ── Test Files (not needed in production) ──
|
||||
e2e/
|
||||
test/
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
|
||||
# ── Development / Config ──
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
docker-compose.yaml
|
||||
vitest.config.ts
|
||||
vitest.node.config.ts
|
||||
playwright.config.ts
|
||||
drizzle.config.ts
|
||||
drizzle/
|
||||
|
||||
# ── Version Control ──
|
||||
.git/
|
||||
.gitignore
|
||||
.github/
|
||||
.husky/
|
||||
|
||||
# ── Environment (already in .gitignore, being explicit) ──
|
||||
.env
|
||||
.env.development
|
||||
.env.production
|
||||
.env.local
|
||||
|
||||
# ── Editors / OS ──
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ── Pi agent / dev tooling ──
|
||||
.pi-lens/
|
||||
.agents/
|
||||
@@ -15,56 +15,38 @@
|
||||
"benchmark:spamshield": "tsx src/server/services/spamshield/benchmark.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.15.0",
|
||||
"@sentry/solidstart": "^10.54.0",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@libsql/client": "^0.17.3",
|
||||
"@sentry/solidstart": "^10.56.0",
|
||||
"@solidjs/router": "^0.15.0",
|
||||
"@solidjs/start": "2.0.0-alpha.2",
|
||||
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
|
||||
"@stripe/stripe-js": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@trpc/client": "^10.45.2",
|
||||
"@trpc/server": "^10.45.2",
|
||||
"@types/three": "^0.184.1",
|
||||
"@typeschema/valibot": "^0.13.4",
|
||||
"@stripe/stripe-js": "^9.7.0",
|
||||
"@trpc/client": "^11.17.0",
|
||||
"@trpc/server": "^11.17.0",
|
||||
"@typeschema/valibot": "^0.14.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"bullmq": "^5.77.3",
|
||||
"clerk-solidjs": "^2.0.10",
|
||||
"dompurify": "^3.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"firebase-admin": "^13.10.0",
|
||||
"imapflow": "^1.3.4",
|
||||
"ioredis": "^5.10.1",
|
||||
"imapflow": "^1.3.5",
|
||||
"ioredis": "^5.11.0",
|
||||
"isomorphic-dompurify": "^3.15.0",
|
||||
"jose": "^5",
|
||||
"jose": "^6.2.3",
|
||||
"marked": "^18.0.4",
|
||||
"node-cron": "^4.2.1",
|
||||
"onnxruntime-node": "^1.26.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"puppeteer": "^25.0.4",
|
||||
"puppeteer": "^25.1.0",
|
||||
"resend": "^6.12.4",
|
||||
"solid-js": "^1.9.5",
|
||||
"stripe": "^22.1.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"stripe": "^22.2.0",
|
||||
"three": "^0.184.0",
|
||||
"twilio": "^6.0.2",
|
||||
"valibot": "^0.29.0",
|
||||
"vite": "^7.0.0",
|
||||
"ws": "^8.21.0"
|
||||
"valibot": "^1.4.1",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/ws": "^8.18.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright": "^1.60.0",
|
||||
"tsx": "^4.22.3",
|
||||
"vite-plugin-solid": "^2.11.12",
|
||||
"vitest": "^4.1.5"
|
||||
"@tailwindcss/vite": "^4.0.7",
|
||||
"tailwindcss": "^4.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import {
|
||||
authenticateUser,
|
||||
authenticateWithGoogle,
|
||||
authenticateWithApple,
|
||||
createUserWithPassword,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
refreshAccessToken,
|
||||
revokeUserSessions,
|
||||
authenticateUser,
|
||||
authenticateWithApple,
|
||||
createUserWithPassword,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
refreshAccessToken,
|
||||
revokeUserSessions,
|
||||
} from "~/server/services/user.service";
|
||||
import { verifyJWT } from "~/server/auth/jwt";
|
||||
|
||||
@@ -27,178 +26,166 @@ import { verifyJWT } from "~/server/auth/jwt";
|
||||
*/
|
||||
|
||||
export async function POST(event: APIEvent) {
|
||||
const action = event.params.action;
|
||||
const body = await event.request.json().catch(() => ({}));
|
||||
const action = event.params.action;
|
||||
const body = await event.request.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "login": {
|
||||
const { email, password } = body;
|
||||
if (!email || !password) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Email and password are required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await authenticateUser(email, password);
|
||||
return Response.json({
|
||||
id: result.user.id,
|
||||
name: result.user.name ?? "",
|
||||
email: result.user.email,
|
||||
accessToken: result.accessToken,
|
||||
sessionToken: result.sessionToken,
|
||||
isNewUser: false,
|
||||
});
|
||||
}
|
||||
try {
|
||||
switch (action) {
|
||||
case "login": {
|
||||
const { email, password } = body;
|
||||
if (!email || !password) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Email and password are required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await authenticateUser(email, password);
|
||||
return Response.json({
|
||||
id: result.user.id,
|
||||
name: result.user.name ?? "",
|
||||
email: result.user.email,
|
||||
accessToken: result.accessToken,
|
||||
sessionToken: result.sessionToken,
|
||||
isNewUser: false,
|
||||
});
|
||||
}
|
||||
|
||||
case "signup": {
|
||||
const { name, email, password } = body;
|
||||
if (!email || !password) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Name, email, and password are required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await createUserWithPassword(
|
||||
name ?? email.split("@")[0],
|
||||
email,
|
||||
password,
|
||||
);
|
||||
return Response.json({
|
||||
id: result.user.id,
|
||||
name: result.user.name ?? "",
|
||||
email: result.user.email,
|
||||
accessToken: result.accessToken,
|
||||
sessionToken: result.sessionToken,
|
||||
isNewUser: true,
|
||||
});
|
||||
}
|
||||
case "signup": {
|
||||
const { name, email, password } = body;
|
||||
if (!email || !password) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: "Name, email, and password are required",
|
||||
}),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await createUserWithPassword(
|
||||
name ?? email.split("@")[0],
|
||||
email,
|
||||
password,
|
||||
);
|
||||
return Response.json({
|
||||
id: result.user.id,
|
||||
name: result.user.name ?? "",
|
||||
email: result.user.email,
|
||||
accessToken: result.accessToken,
|
||||
sessionToken: result.sessionToken,
|
||||
isNewUser: true,
|
||||
});
|
||||
}
|
||||
|
||||
case "google": {
|
||||
const { idToken } = body;
|
||||
if (!idToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "idToken is required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await authenticateWithGoogle(idToken);
|
||||
return Response.json({
|
||||
id: result.user.id,
|
||||
name: result.user.name ?? "",
|
||||
email: result.user.email,
|
||||
image: result.user.image,
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
sessionToken: result.sessionToken,
|
||||
isNewUser: result.isNewUser ?? false,
|
||||
});
|
||||
}
|
||||
case "apple": {
|
||||
const { identityToken, authorizationCode, userIdentifier } = body;
|
||||
if (!identityToken || !authorizationCode) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: "identityToken and authorizationCode are required",
|
||||
}),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await authenticateWithApple(
|
||||
identityToken,
|
||||
authorizationCode,
|
||||
userIdentifier ?? null,
|
||||
);
|
||||
return Response.json({
|
||||
id: result.user.id,
|
||||
name: result.user.name ?? "",
|
||||
email: result.user.email,
|
||||
image: result.user.image,
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
sessionToken: result.sessionToken,
|
||||
isNewUser: result.isNewUser ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
case "apple": {
|
||||
const { identityToken, authorizationCode, userIdentifier } = body;
|
||||
if (!identityToken || !authorizationCode) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "identityToken and authorizationCode are required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await authenticateWithApple(
|
||||
identityToken,
|
||||
authorizationCode,
|
||||
userIdentifier ?? null,
|
||||
);
|
||||
return Response.json({
|
||||
id: result.user.id,
|
||||
name: result.user.name ?? "",
|
||||
email: result.user.email,
|
||||
image: result.user.image,
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
sessionToken: result.sessionToken,
|
||||
isNewUser: result.isNewUser ?? false,
|
||||
});
|
||||
}
|
||||
case "refresh": {
|
||||
const { refreshToken } = body;
|
||||
if (!refreshToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "refreshToken is required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await refreshAccessToken(refreshToken);
|
||||
return Response.json({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
case "refresh": {
|
||||
const { refreshToken } = body;
|
||||
if (!refreshToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "refreshToken is required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
const result = await refreshAccessToken(refreshToken);
|
||||
return Response.json({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
});
|
||||
}
|
||||
case "logout": {
|
||||
// Extract user from Bearer token
|
||||
const authHeader = event.request.headers.get("authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = await verifyJWT<{ sub: string }>(token);
|
||||
await revokeUserSessions(payload.sub);
|
||||
} catch {
|
||||
// Invalid token — still return success
|
||||
}
|
||||
}
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
case "logout": {
|
||||
// Extract user from Bearer token
|
||||
const authHeader = event.request.headers.get("authorization");
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = await verifyJWT<{ sub: string }>(token);
|
||||
await revokeUserSessions(payload.sub);
|
||||
} catch {
|
||||
// Invalid token — still return success
|
||||
}
|
||||
}
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
case "forgot-password": {
|
||||
const { email } = body;
|
||||
if (!email) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Email is required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
await forgotPassword(email);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
case "forgot-password": {
|
||||
const { email } = body;
|
||||
if (!email) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Email is required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
await forgotPassword(email);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
case "reset-password": {
|
||||
const { code, password } = body;
|
||||
if (!code || !password) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Code and password are required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
// The mobile app sends "code" but the service expects "token"
|
||||
// We accept both for backward compatibility
|
||||
const token = code;
|
||||
await resetPassword(token, password);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
||||
case "reset-password": {
|
||||
const { code, password } = body;
|
||||
if (!code || !password) {
|
||||
return new Response(
|
||||
JSON.stringify({ message: "Code and password are required" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
// The mobile app sends "code" but the service expects "token"
|
||||
// We accept both for backward compatibility
|
||||
const token = code;
|
||||
await resetPassword(token, password);
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
default:
|
||||
return new Response(
|
||||
JSON.stringify({ message: `Unknown action: ${action}` }),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const statusCode =
|
||||
error.code === "UNAUTHORIZED"
|
||||
? 401
|
||||
: error.code === "CONFLICT"
|
||||
? 409
|
||||
: error.code === "NOT_FOUND"
|
||||
? 404
|
||||
: error.code === "FORBIDDEN"
|
||||
? 403
|
||||
: 500;
|
||||
|
||||
default:
|
||||
return new Response(
|
||||
JSON.stringify({ message: `Unknown action: ${action}` }),
|
||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const statusCode = error.code === "UNAUTHORIZED" ? 401
|
||||
: error.code === "CONFLICT" ? 409
|
||||
: error.code === "NOT_FOUND" ? 404
|
||||
: error.code === "FORBIDDEN" ? 403
|
||||
: 500;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: error.message ?? "Internal server error",
|
||||
code: error.code ?? "INTERNAL_ERROR",
|
||||
}),
|
||||
{
|
||||
status: statusCode,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
message: error.message ?? "Internal server error",
|
||||
code: error.code ?? "INTERNAL_ERROR",
|
||||
}),
|
||||
{
|
||||
status: statusCode,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { exampleRouter } from "./routers/example";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { billingRouter } from "./routers/billing";
|
||||
import { notificationRouter } from "./routers/notification";
|
||||
import { darkwatchRouter } from "./routers/darkwatch";
|
||||
import { voiceprintRouter } from "./routers/voiceprint";
|
||||
import { spamshieldRouter } from "./routers/spamshield";
|
||||
@@ -17,22 +16,21 @@ import { familyRouter } from "./routers/family";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
example: exampleRouter,
|
||||
user: userRouter,
|
||||
billing: billingRouter,
|
||||
notification: notificationRouter,
|
||||
darkwatch: darkwatchRouter,
|
||||
voiceprint: voiceprintRouter,
|
||||
spamshield: spamshieldRouter,
|
||||
hometitle: hometitleRouter,
|
||||
removebrokers: removebrokersRouter,
|
||||
correlation: correlationRouter,
|
||||
reports: reportsRouter,
|
||||
scheduler: schedulerRouter,
|
||||
extension: extensionRouter,
|
||||
blog: blogRouter,
|
||||
admin: adminRouter,
|
||||
family: familyRouter,
|
||||
example: exampleRouter,
|
||||
user: userRouter,
|
||||
billing: billingRouter,
|
||||
darkwatch: darkwatchRouter,
|
||||
voiceprint: voiceprintRouter,
|
||||
spamshield: spamshieldRouter,
|
||||
hometitle: hometitleRouter,
|
||||
removebrokers: removebrokersRouter,
|
||||
correlation: correlationRouter,
|
||||
reports: reportsRouter,
|
||||
scheduler: schedulerRouter,
|
||||
extension: extensionRouter,
|
||||
blog: blogRouter,
|
||||
admin: adminRouter,
|
||||
family: familyRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import {
|
||||
object,
|
||||
string,
|
||||
optional,
|
||||
record,
|
||||
boolean,
|
||||
picklist,
|
||||
} from "valibot";
|
||||
import { createTRPCRouter, protectedProcedure, adminProcedure } from "../utils";
|
||||
import {
|
||||
sendEmail,
|
||||
sendPush,
|
||||
sendSMS,
|
||||
registerDevice,
|
||||
unregisterDevice,
|
||||
listDevices,
|
||||
getPreferences,
|
||||
updatePreferences,
|
||||
} from "~/server/services/notification.service";
|
||||
|
||||
const SendEmailSchema = object({
|
||||
to: string(),
|
||||
subject: string(),
|
||||
html: string(),
|
||||
text: optional(string()),
|
||||
});
|
||||
|
||||
const SendPushSchema = object({
|
||||
title: string(),
|
||||
body: string(),
|
||||
data: optional(record(string(), string())),
|
||||
});
|
||||
|
||||
const SendSMSSchema = object({
|
||||
phoneNumber: string(),
|
||||
message: string(),
|
||||
});
|
||||
|
||||
const RegisterDeviceSchema = object({
|
||||
token: string(),
|
||||
platform: picklist(["ios", "android", "web"]),
|
||||
deviceType: picklist(["mobile", "web", "desktop"]),
|
||||
});
|
||||
|
||||
const UnregisterDeviceSchema = object({
|
||||
token: string(),
|
||||
});
|
||||
|
||||
const UpdatePreferencesSchema = object({
|
||||
emailEnabled: optional(boolean()),
|
||||
pushEnabled: optional(boolean()),
|
||||
smsEnabled: optional(boolean()),
|
||||
});
|
||||
|
||||
export const notificationRouter = createTRPCRouter({
|
||||
sendEmail: adminProcedure
|
||||
.input(wrap(SendEmailSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return sendEmail(input.to, input.subject, input.html, input.text);
|
||||
}),
|
||||
|
||||
sendPush: protectedProcedure
|
||||
.input(wrap(SendPushSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return sendPush(ctx.user.id, input.title, input.body, input.data);
|
||||
}),
|
||||
|
||||
sendSMS: protectedProcedure
|
||||
.input(wrap(SendSMSSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return sendSMS(input.phoneNumber, input.message);
|
||||
}),
|
||||
|
||||
registerDevice: protectedProcedure
|
||||
.input(wrap(RegisterDeviceSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return registerDevice(ctx.user.id, input.token, input.platform, input.deviceType);
|
||||
}),
|
||||
|
||||
unregisterDevice: protectedProcedure
|
||||
.input(wrap(UnregisterDeviceSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return unregisterDevice(ctx.user.id, input.token);
|
||||
}),
|
||||
|
||||
listDevices: protectedProcedure.query(async ({ ctx }) => {
|
||||
return listDevices(ctx.user.id);
|
||||
}),
|
||||
|
||||
getPreferences: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getPreferences(ctx.user.id);
|
||||
}),
|
||||
|
||||
updatePreferences: protectedProcedure
|
||||
.input(wrap(UpdatePreferencesSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
return updatePreferences(ctx.user.id, input);
|
||||
}),
|
||||
});
|
||||
@@ -1,185 +1,179 @@
|
||||
import { wrap } from "@typeschema/valibot";
|
||||
import { object, string, minLength, email as emailVal } from "valibot";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../utils";
|
||||
import {
|
||||
UpdateUserSchema,
|
||||
InviteMemberSchema,
|
||||
RemoveMemberSchema,
|
||||
UpdateRoleSchema,
|
||||
createTRPCRouter,
|
||||
publicProcedure,
|
||||
protectedProcedure,
|
||||
} from "../utils";
|
||||
import {
|
||||
UpdateUserSchema,
|
||||
InviteMemberSchema,
|
||||
RemoveMemberSchema,
|
||||
UpdateRoleSchema,
|
||||
} from "../schemas/user";
|
||||
import {
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
createUserWithPassword,
|
||||
authenticateUser,
|
||||
authenticateWithGoogle,
|
||||
authenticateWithApple,
|
||||
refreshAccessToken,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
revokeUserSessions,
|
||||
getUserById,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
createUserWithPassword,
|
||||
authenticateUser,
|
||||
authenticateWithApple,
|
||||
refreshAccessToken,
|
||||
forgotPassword,
|
||||
resetPassword,
|
||||
revokeUserSessions,
|
||||
} from "~/server/services/user.service";
|
||||
import {
|
||||
getFamilyGroup,
|
||||
inviteMember,
|
||||
removeMember,
|
||||
updateMemberRole,
|
||||
getFamilyGroup,
|
||||
inviteMember,
|
||||
removeMember,
|
||||
updateMemberRole,
|
||||
} from "~/server/services/family.service";
|
||||
|
||||
const LoginSchema = object({
|
||||
email: string([emailVal()]),
|
||||
password: string([minLength(1)]),
|
||||
email: string([emailVal()]),
|
||||
password: string([minLength(1)]),
|
||||
});
|
||||
|
||||
const SignupSchema = object({
|
||||
name: string([minLength(1)]),
|
||||
email: string([emailVal()]),
|
||||
password: string([minLength(8)]),
|
||||
});
|
||||
|
||||
const GoogleAuthSchema = object({
|
||||
idToken: string([minLength(1)]),
|
||||
name: string([minLength(1)]),
|
||||
email: string([emailVal()]),
|
||||
password: string([minLength(8)]),
|
||||
});
|
||||
|
||||
const AppleAuthSchema = object({
|
||||
identityToken: string([minLength(1)]),
|
||||
authorizationCode: string([minLength(1)]),
|
||||
userIdentifier: string(),
|
||||
identityToken: string([minLength(1)]),
|
||||
authorizationCode: string([minLength(1)]),
|
||||
userIdentifier: string(),
|
||||
});
|
||||
|
||||
const RefreshTokenSchema = object({
|
||||
refreshToken: string([minLength(1)]),
|
||||
refreshToken: string([minLength(1)]),
|
||||
});
|
||||
|
||||
const ForgotPasswordSchema = object({
|
||||
email: string([emailVal()]),
|
||||
email: string([emailVal()]),
|
||||
});
|
||||
|
||||
const ResetPasswordSchema = object({
|
||||
token: string([minLength(1)]),
|
||||
password: string([minLength(8)]),
|
||||
token: string([minLength(1)]),
|
||||
password: string([minLength(8)]),
|
||||
});
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
login: publicProcedure
|
||||
.input(wrap(LoginSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return authenticateUser(input.email, input.password);
|
||||
}),
|
||||
login: publicProcedure
|
||||
.input(wrap(LoginSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return authenticateUser(input.email, input.password);
|
||||
}),
|
||||
|
||||
signup: publicProcedure
|
||||
.input(wrap(SignupSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return createUserWithPassword(input.name, input.email, input.password);
|
||||
}),
|
||||
signup: publicProcedure
|
||||
.input(wrap(SignupSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return createUserWithPassword(input.name, input.email, input.password);
|
||||
}),
|
||||
|
||||
googleAuth: publicProcedure
|
||||
.input(wrap(GoogleAuthSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return authenticateWithGoogle(input.idToken);
|
||||
}),
|
||||
appleAuth: publicProcedure
|
||||
.input(wrap(AppleAuthSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return authenticateWithApple(
|
||||
input.identityToken,
|
||||
input.authorizationCode,
|
||||
input.userIdentifier || null,
|
||||
);
|
||||
}),
|
||||
|
||||
appleAuth: publicProcedure
|
||||
.input(wrap(AppleAuthSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return authenticateWithApple(
|
||||
input.identityToken,
|
||||
input.authorizationCode,
|
||||
input.userIdentifier || null,
|
||||
);
|
||||
}),
|
||||
refreshToken: publicProcedure
|
||||
.input(wrap(RefreshTokenSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return refreshAccessToken(input.refreshToken);
|
||||
}),
|
||||
|
||||
refreshToken: publicProcedure
|
||||
.input(wrap(RefreshTokenSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return refreshAccessToken(input.refreshToken);
|
||||
}),
|
||||
forgotPassword: publicProcedure
|
||||
.input(wrap(ForgotPasswordSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return forgotPassword(input.email);
|
||||
}),
|
||||
|
||||
forgotPassword: publicProcedure
|
||||
.input(wrap(ForgotPasswordSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return forgotPassword(input.email);
|
||||
}),
|
||||
resetPassword: publicProcedure
|
||||
.input(wrap(ResetPasswordSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return resetPassword(input.token, input.password);
|
||||
}),
|
||||
|
||||
resetPassword: publicProcedure
|
||||
.input(wrap(ResetPasswordSchema))
|
||||
.mutation(async ({ input }) => {
|
||||
return resetPassword(input.token, input.password);
|
||||
}),
|
||||
me: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await getUserById(ctx.user.id);
|
||||
return user;
|
||||
}),
|
||||
|
||||
me: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await getUserById(ctx.user.id);
|
||||
return user;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.input(wrap(UpdateUserSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const updated = await updateUser(ctx.user.id, input);
|
||||
return updated;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(wrap(UpdateUserSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const updated = await updateUser(ctx.user.id, input);
|
||||
return updated;
|
||||
}),
|
||||
delete: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await deleteUser(ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await deleteUser(ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
logout: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await revokeUserSessions(ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
logout: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await revokeUserSessions(ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
listFamilyMembers: protectedProcedure.query(async ({ ctx }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
return group.members;
|
||||
}),
|
||||
|
||||
listFamilyMembers: protectedProcedure.query(async ({ ctx }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
return group.members;
|
||||
}),
|
||||
inviteFamilyMember: protectedProcedure
|
||||
.input(wrap(InviteMemberSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
|
||||
inviteFamilyMember: protectedProcedure
|
||||
.input(wrap(InviteMemberSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
const callerMember = group.members.find((m) => m.userId === ctx.user.id);
|
||||
|
||||
const callerMember = group.members.find(
|
||||
(m) => m.userId === ctx.user.id,
|
||||
);
|
||||
if (
|
||||
!callerMember ||
|
||||
(callerMember.role !== "owner" && callerMember.role !== "admin")
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only owner or admin can invite members",
|
||||
});
|
||||
}
|
||||
|
||||
if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only owner or admin can invite members",
|
||||
});
|
||||
}
|
||||
const invitation = await inviteMember(
|
||||
group.id,
|
||||
input.email,
|
||||
ctx.user.id,
|
||||
input.role,
|
||||
);
|
||||
|
||||
const invitation = await inviteMember(
|
||||
group.id,
|
||||
input.email,
|
||||
ctx.user.id,
|
||||
input.role,
|
||||
);
|
||||
return invitation;
|
||||
}),
|
||||
|
||||
return invitation;
|
||||
}),
|
||||
removeFamilyMember: protectedProcedure
|
||||
.input(wrap(RemoveMemberSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
await removeMember(group.id, input.userId, ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
removeFamilyMember: protectedProcedure
|
||||
.input(wrap(RemoveMemberSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
await removeMember(group.id, input.userId, ctx.user.id);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
updateFamilyMemberRole: protectedProcedure
|
||||
.input(wrap(UpdateRoleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
const updated = await updateMemberRole(
|
||||
group.id,
|
||||
input.userId,
|
||||
input.role,
|
||||
ctx.user.id,
|
||||
);
|
||||
return updated;
|
||||
}),
|
||||
updateFamilyMemberRole: protectedProcedure
|
||||
.input(wrap(UpdateRoleSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const group = await getFamilyGroup(ctx.user.id);
|
||||
const updated = await updateMemberRole(
|
||||
group.id,
|
||||
input.userId,
|
||||
input.role,
|
||||
ctx.user.id,
|
||||
);
|
||||
return updated;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,40 +1,84 @@
|
||||
import { sqliteTable, text, integer, uniqueIndex, index } from "drizzle-orm/sqlite-core";
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
uniqueIndex,
|
||||
index,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { familyGroupMembers } from "./subscription";
|
||||
|
||||
export { familyGroupMembers };
|
||||
|
||||
/**
|
||||
* Per-member service configuration.
|
||||
* The primary account holder assigns which services each member gets.
|
||||
* Default: all members get darkwatch + spamshield + removebrokers.
|
||||
* HomeTitle and VoicePrint limited by property/voice enrollment slots.
|
||||
*/
|
||||
export const familyMemberServices = sqliteTable("family_member_services", {
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
memberId: text("member_id").notNull().references(() => familyGroupMembers.id, { onDelete: "cascade" }),
|
||||
service: text("service").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).default(true).notNull(),
|
||||
configuredBy: text("configured_by").notNull(),
|
||||
configuredAt: integer("configured_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||
}, (table) => ({
|
||||
memberServiceUnique: uniqueIndex("family_member_services_member_service_unique").on(table.memberId, table.service),
|
||||
memberIdIdx: index("family_member_services_member_id_idx").on(table.memberId),
|
||||
serviceIdx: index("family_member_services_service_idx").on(table.service),
|
||||
}));
|
||||
export const familyMemberServices = sqliteTable(
|
||||
"family_member_services",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
memberId: text("member_id")
|
||||
.notNull()
|
||||
.references(() => familyGroupMembers.id, { onDelete: "cascade" }),
|
||||
service: text("service").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).default(true).notNull(),
|
||||
configuredBy: text("configured_by").notNull(),
|
||||
configuredAt: integer("configured_at", { mode: "timestamp_ms" })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(table) => ({
|
||||
memberServiceUnique: uniqueIndex(
|
||||
"family_member_services_member_service_unique",
|
||||
).on(table.memberId, table.service),
|
||||
memberIdIdx: index("family_member_services_member_id_idx").on(
|
||||
table.memberId,
|
||||
),
|
||||
serviceIdx: index("family_member_services_service_idx").on(table.service),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-member alert notification preferences.
|
||||
* Members can opt into/off specific alert types and channels.
|
||||
*/
|
||||
export const familyMemberAlertPreferences = sqliteTable("family_member_alert_preferences", {
|
||||
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
|
||||
memberId: text("member_id").notNull().references(() => familyGroupMembers.id, { onDelete: "cascade" }),
|
||||
alertType: text("alert_type").notNull(),
|
||||
channel: text("channel").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).default(true).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()),
|
||||
}, (table) => ({
|
||||
memberChannelTypeUnique: uniqueIndex("family_member_alert_prefs_member_channel_type_unique").on(table.memberId, table.channel, table.alertType),
|
||||
memberIdIdx: index("family_member_alert_prefs_member_id_idx").on(table.memberId),
|
||||
}));
|
||||
export const familyMemberAlertPreferences = sqliteTable(
|
||||
"family_member_alert_preferences",
|
||||
{
|
||||
id: text("id")
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
memberId: text("member_id")
|
||||
.notNull()
|
||||
.references(() => familyGroupMembers.id, { onDelete: "cascade" }),
|
||||
alertType: text("alert_type").notNull(),
|
||||
channel: text("channel").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).default(true).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||
.defaultNow()
|
||||
.notNull()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(table) => ({
|
||||
memberChannelTypeUnique: uniqueIndex(
|
||||
"family_member_alert_prefs_member_channel_type_unique",
|
||||
).on(table.memberId, table.channel, table.alertType),
|
||||
memberIdIdx: index("family_member_alert_prefs_member_id_idx").on(
|
||||
table.memberId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import type { JobPayload, JobType } from "../queue";
|
||||
|
||||
export type JobHandler<T extends JobType = JobType> = (payload: JobPayload[T]) => Promise<void>;
|
||||
export type JobHandler<T extends JobType = JobType> = (
|
||||
payload: JobPayload[T],
|
||||
) => Promise<void>;
|
||||
|
||||
export type HandlerMap = {
|
||||
[K in JobType]: JobHandler<K>;
|
||||
[K in JobType]: JobHandler<K>;
|
||||
};
|
||||
|
||||
let handlers: HandlerMap | null = null;
|
||||
|
||||
export function getHandlers(): HandlerMap {
|
||||
if (!handlers) {
|
||||
handlers = {
|
||||
"darkwatch.scan": require("./darkwatch.scan").handler,
|
||||
"darkwatch.digest": require("./darkwatch.digest").handler,
|
||||
"voiceprint.batch": require("./voiceprint.batch").handler,
|
||||
"hometitle.scan": require("./hometitle.scan").handler,
|
||||
"removebrokers.process": require("./removebrokers.process").handler,
|
||||
"reports.generate": require("./reports.generate").handler,
|
||||
"notifications.send": require("./notifications.send").handler,
|
||||
};
|
||||
}
|
||||
return handlers;
|
||||
if (!handlers) {
|
||||
handlers = {
|
||||
"darkwatch.scan": require("./darkwatch.scan").handler,
|
||||
"darkwatch.digest": require("./darkwatch.digest").handler,
|
||||
"voiceprint.batch": require("./voiceprint.batch").handler,
|
||||
"hometitle.scan": require("./hometitle.scan").handler,
|
||||
"removebrokers.process": require("./removebrokers.process").handler,
|
||||
"reports.generate": require("./reports.generate").handler,
|
||||
};
|
||||
}
|
||||
return handlers;
|
||||
}
|
||||
|
||||
export function setHandlers(mock: Partial<HandlerMap>): void {
|
||||
handlers = {
|
||||
"darkwatch.scan": mock["darkwatch.scan"] ?? (async () => {}),
|
||||
"darkwatch.digest": mock["darkwatch.digest"] ?? (async () => {}),
|
||||
"voiceprint.batch": mock["voiceprint.batch"] ?? (async () => {}),
|
||||
"hometitle.scan": mock["hometitle.scan"] ?? (async () => {}),
|
||||
"removebrokers.process": mock["removebrokers.process"] ?? (async () => {}),
|
||||
"reports.generate": mock["reports.generate"] ?? (async () => {}),
|
||||
"notifications.send": mock["notifications.send"] ?? (async () => {}),
|
||||
};
|
||||
handlers = {
|
||||
"darkwatch.scan": mock["darkwatch.scan"] ?? (async () => {}),
|
||||
"darkwatch.digest": mock["darkwatch.digest"] ?? (async () => {}),
|
||||
"voiceprint.batch": mock["voiceprint.batch"] ?? (async () => {}),
|
||||
"hometitle.scan": mock["hometitle.scan"] ?? (async () => {}),
|
||||
"removebrokers.process": mock["removebrokers.process"] ?? (async () => {}),
|
||||
"reports.generate": mock["reports.generate"] ?? (async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { alerts, subscriptions, users } from "~/server/db/schema";
|
||||
import { sendEmail, sendPush, sendSMS } from "~/server/services/notification.service";
|
||||
|
||||
interface NotificationsSendPayload {
|
||||
userId: string;
|
||||
alertId?: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
export async function handler(payload: NotificationsSendPayload): Promise<void> {
|
||||
const { userId, alertId, channel } = payload;
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
console.warn(`[notifications.send] User ${userId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (alertId) {
|
||||
const [alert] = await db
|
||||
.select()
|
||||
.from(alerts)
|
||||
.where(and(eq(alerts.id, alertId), eq(alerts.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (!alert) {
|
||||
console.warn(`[notifications.send] Alert ${alertId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendViaChannel(channel, user, alert.title, alert.message);
|
||||
} else {
|
||||
const unsentAlerts = await db
|
||||
.select()
|
||||
.from(alerts)
|
||||
.where(and(eq(alerts.userId, userId), eq(alerts.isRead, false)))
|
||||
.limit(20);
|
||||
|
||||
for (const alert of unsentAlerts) {
|
||||
for (const ch of alert.channel as string[]) {
|
||||
await sendViaChannel(ch, user, alert.title, alert.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaChannel(channel: string, user: { email: string; id: string }, title: string, message: string): Promise<void> {
|
||||
try {
|
||||
switch (channel) {
|
||||
case "email":
|
||||
await sendEmail(user.email, title, `<p>${message}</p>`);
|
||||
break;
|
||||
case "push":
|
||||
await sendPush(user.id, title, message);
|
||||
break;
|
||||
case "sms":
|
||||
await sendSMS(user.email, message);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[notifications.send] Failed to send via ${channel}:`, err);
|
||||
}
|
||||
}
|
||||
@@ -1,222 +1,243 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export const JOB_TYPES = [
|
||||
"darkwatch.scan",
|
||||
"darkwatch.digest",
|
||||
"voiceprint.batch",
|
||||
"hometitle.scan",
|
||||
"removebrokers.process",
|
||||
"reports.generate",
|
||||
"notifications.send",
|
||||
"darkwatch.scan",
|
||||
"darkwatch.digest",
|
||||
"voiceprint.batch",
|
||||
"hometitle.scan",
|
||||
"removebrokers.process",
|
||||
"reports.generate",
|
||||
] as const;
|
||||
|
||||
export type JobType = (typeof JOB_TYPES)[number];
|
||||
|
||||
export type JobPayload = {
|
||||
"darkwatch.scan": { userId: string; subscriptionId: string };
|
||||
"darkwatch.digest": { userId: string };
|
||||
"voiceprint.batch": { userId?: string; jobId?: string };
|
||||
"hometitle.scan": { userId: string; subscriptionId: string };
|
||||
"removebrokers.process": { subscriptionId?: string; requestId?: string };
|
||||
"reports.generate": { userId: string; reportScheduleId?: string; reportType: string };
|
||||
"notifications.send": { userId: string; alertId?: string; channel: string };
|
||||
"darkwatch.scan": { userId: string; subscriptionId: string };
|
||||
"darkwatch.digest": { userId: string };
|
||||
"voiceprint.batch": { userId?: string; jobId?: string };
|
||||
"hometitle.scan": { userId: string; subscriptionId: string };
|
||||
"removebrokers.process": { subscriptionId?: string; requestId?: string };
|
||||
"reports.generate": {
|
||||
userId: string;
|
||||
reportScheduleId?: string;
|
||||
reportType: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type JobStatus = "pending" | "running" | "completed" | "failed";
|
||||
|
||||
export interface Job<T extends JobType = JobType> {
|
||||
id: string;
|
||||
type: T;
|
||||
payload: JobPayload[T];
|
||||
status: JobStatus;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
error?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
id: string;
|
||||
type: T;
|
||||
payload: JobPayload[T];
|
||||
status: JobStatus;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
error?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface EnqueueOptions {
|
||||
delay?: number;
|
||||
maxAttempts?: number;
|
||||
delay?: number;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export interface QueueAdapter {
|
||||
enqueue<T extends JobType>(type: T, payload: JobPayload[T], options?: EnqueueOptions): Promise<Job<T>>;
|
||||
dequeue(): Promise<Job | null>;
|
||||
markComplete(jobId: string): Promise<void>;
|
||||
markFailed(jobId: string, error: string): Promise<void>;
|
||||
scheduleRetry(job: Job, delayMs: number): Promise<void>;
|
||||
getJob(jobId: string): Promise<Job | null>;
|
||||
getJobs(status?: JobStatus): Promise<Job[]>;
|
||||
enqueue<T extends JobType>(
|
||||
type: T,
|
||||
payload: JobPayload[T],
|
||||
options?: EnqueueOptions,
|
||||
): Promise<Job<T>>;
|
||||
dequeue(): Promise<Job | null>;
|
||||
markComplete(jobId: string): Promise<void>;
|
||||
markFailed(jobId: string, error: string): Promise<void>;
|
||||
scheduleRetry(job: Job, delayMs: number): Promise<void>;
|
||||
getJob(jobId: string): Promise<Job | null>;
|
||||
getJobs(status?: JobStatus): Promise<Job[]>;
|
||||
}
|
||||
|
||||
export class InMemoryQueue implements QueueAdapter {
|
||||
private jobs = new Map<string, Job>();
|
||||
private pendingQueue: string[] = [];
|
||||
private jobs = new Map<string, Job>();
|
||||
private pendingQueue: string[] = [];
|
||||
|
||||
async enqueue<T extends JobType>(type: T, payload: JobPayload[T], options?: EnqueueOptions): Promise<Job<T>> {
|
||||
const id = randomUUID();
|
||||
const job: Job<T> = {
|
||||
id,
|
||||
type,
|
||||
payload,
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
maxAttempts: options?.maxAttempts ?? 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.jobs.set(id, job as Job);
|
||||
if (options?.delay) {
|
||||
setTimeout(() => {
|
||||
this.pendingQueue.push(id);
|
||||
}, options.delay);
|
||||
} else {
|
||||
this.pendingQueue.push(id);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
async enqueue<T extends JobType>(
|
||||
type: T,
|
||||
payload: JobPayload[T],
|
||||
options?: EnqueueOptions,
|
||||
): Promise<Job<T>> {
|
||||
const id = randomUUID();
|
||||
const job: Job<T> = {
|
||||
id,
|
||||
type,
|
||||
payload,
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
maxAttempts: options?.maxAttempts ?? 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.jobs.set(id, job as Job);
|
||||
if (options?.delay) {
|
||||
setTimeout(() => {
|
||||
this.pendingQueue.push(id);
|
||||
}, options.delay);
|
||||
} else {
|
||||
this.pendingQueue.push(id);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async scheduleRetry(job: Job, delayMs: number): Promise<void> {
|
||||
job.status = "pending";
|
||||
job.attempts++;
|
||||
job.updatedAt = new Date();
|
||||
setTimeout(() => {
|
||||
this.pendingQueue.push(job.id);
|
||||
}, delayMs);
|
||||
}
|
||||
async scheduleRetry(job: Job, delayMs: number): Promise<void> {
|
||||
job.status = "pending";
|
||||
job.attempts++;
|
||||
job.updatedAt = new Date();
|
||||
setTimeout(() => {
|
||||
this.pendingQueue.push(job.id);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async dequeue(): Promise<Job | null> {
|
||||
while (this.pendingQueue.length > 0) {
|
||||
const id = this.pendingQueue.shift()!;
|
||||
const job = this.jobs.get(id);
|
||||
if (!job || job.status !== "pending") continue;
|
||||
job.status = "running";
|
||||
job.updatedAt = new Date();
|
||||
return job;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async dequeue(): Promise<Job | null> {
|
||||
while (this.pendingQueue.length > 0) {
|
||||
const id = this.pendingQueue.shift()!;
|
||||
const job = this.jobs.get(id);
|
||||
if (!job || job.status !== "pending") continue;
|
||||
job.status = "running";
|
||||
job.updatedAt = new Date();
|
||||
return job;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async markComplete(jobId: string): Promise<void> {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (job) {
|
||||
job.status = "completed";
|
||||
job.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
async markComplete(jobId: string): Promise<void> {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (job) {
|
||||
job.status = "completed";
|
||||
job.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
async markFailed(jobId: string, error: string): Promise<void> {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (job) {
|
||||
job.status = "failed";
|
||||
job.error = error;
|
||||
job.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
async markFailed(jobId: string, error: string): Promise<void> {
|
||||
const job = this.jobs.get(jobId);
|
||||
if (job) {
|
||||
job.status = "failed";
|
||||
job.error = error;
|
||||
job.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
async getJob(jobId: string): Promise<Job | null> {
|
||||
return this.jobs.get(jobId) ?? null;
|
||||
}
|
||||
async getJob(jobId: string): Promise<Job | null> {
|
||||
return this.jobs.get(jobId) ?? null;
|
||||
}
|
||||
|
||||
async getJobs(status?: JobStatus): Promise<Job[]> {
|
||||
const all = Array.from(this.jobs.values());
|
||||
if (status) return all.filter((j) => j.status === status);
|
||||
return all;
|
||||
}
|
||||
async getJobs(status?: JobStatus): Promise<Job[]> {
|
||||
const all = Array.from(this.jobs.values());
|
||||
if (status) return all.filter((j) => j.status === status);
|
||||
return all;
|
||||
}
|
||||
}
|
||||
|
||||
function createRedisAdapter(): QueueAdapter {
|
||||
// Lazy imports so this module works without Redis
|
||||
const BullMQ = require("bullmq");
|
||||
const IORedis = require("ioredis");
|
||||
// Lazy imports so this module works without Redis
|
||||
const BullMQ = require("bullmq");
|
||||
const IORedis = require("ioredis");
|
||||
|
||||
const connection = new IORedis.default(process.env.REDIS_URL ?? "redis://localhost:6379", {
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
const connection = new IORedis.default(
|
||||
process.env.REDIS_URL ?? "redis://localhost:6379",
|
||||
{
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
);
|
||||
|
||||
const queue = new BullMQ.Queue("kordant-jobs", { connection });
|
||||
let bullJobs = new Map<string, any>();
|
||||
const queue = new BullMQ.Queue("kordant-jobs", { connection });
|
||||
const bullJobs = new Map<string, any>();
|
||||
|
||||
async function toJob(bullJob: any): Promise<Job> {
|
||||
return {
|
||||
id: bullJob.id,
|
||||
type: bullJob.name as JobType,
|
||||
payload: bullJob.data,
|
||||
status: (await bullJob.getState()) as JobStatus,
|
||||
attempts: bullJob.attemptsMade,
|
||||
maxAttempts: bullJob.opts?.attempts ?? 3,
|
||||
error: bullJob.failedReason ?? undefined,
|
||||
createdAt: bullJob.timestamp ? new Date(bullJob.timestamp) : new Date(),
|
||||
updatedAt: bullJob.processedOn ? new Date(bullJob.processedOn) : new Date(),
|
||||
};
|
||||
}
|
||||
async function toJob(bullJob: any): Promise<Job> {
|
||||
return {
|
||||
id: bullJob.id,
|
||||
type: bullJob.name as JobType,
|
||||
payload: bullJob.data,
|
||||
status: (await bullJob.getState()) as JobStatus,
|
||||
attempts: bullJob.attemptsMade,
|
||||
maxAttempts: bullJob.opts?.attempts ?? 3,
|
||||
error: bullJob.failedReason ?? undefined,
|
||||
createdAt: bullJob.timestamp ? new Date(bullJob.timestamp) : new Date(),
|
||||
updatedAt: bullJob.processedOn
|
||||
? new Date(bullJob.processedOn)
|
||||
: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
async enqueue<T extends JobType>(type: T, payload: JobPayload[T], options?: EnqueueOptions) {
|
||||
const bullJob = await queue.add(type, payload, {
|
||||
attempts: options?.maxAttempts ?? 3,
|
||||
delay: options?.delay,
|
||||
backoff: { type: "exponential", delay: 60_000 },
|
||||
});
|
||||
return toJob(bullJob) as Promise<Job<T>>;
|
||||
},
|
||||
return {
|
||||
async enqueue<T extends JobType>(
|
||||
type: T,
|
||||
payload: JobPayload[T],
|
||||
options?: EnqueueOptions,
|
||||
) {
|
||||
const bullJob = await queue.add(type, payload, {
|
||||
attempts: options?.maxAttempts ?? 3,
|
||||
delay: options?.delay,
|
||||
backoff: { type: "exponential", delay: 60_000 },
|
||||
});
|
||||
return toJob(bullJob) as Promise<Job<T>>;
|
||||
},
|
||||
|
||||
async dequeue() {
|
||||
// BullMQ Worker handles dequeue automatically; this is for the polling worker
|
||||
return null;
|
||||
},
|
||||
async dequeue() {
|
||||
// BullMQ Worker handles dequeue automatically; this is for the polling worker
|
||||
return null;
|
||||
},
|
||||
|
||||
async markComplete(jobId) {
|
||||
// Handled by BullMQ Worker
|
||||
},
|
||||
async markComplete(jobId) {
|
||||
// Handled by BullMQ Worker
|
||||
},
|
||||
|
||||
async markFailed(jobId, error) {
|
||||
// Handled by BullMQ Worker
|
||||
},
|
||||
async markFailed(jobId, error) {
|
||||
// Handled by BullMQ Worker
|
||||
},
|
||||
|
||||
async scheduleRetry(job, delayMs) {
|
||||
// BullMQ handles retries via backoff
|
||||
},
|
||||
async scheduleRetry(job, delayMs) {
|
||||
// BullMQ handles retries via backoff
|
||||
},
|
||||
|
||||
async getJob(jobId) {
|
||||
const bullJob = await queue.getJob(jobId);
|
||||
if (!bullJob) return null;
|
||||
return toJob(bullJob);
|
||||
},
|
||||
async getJob(jobId) {
|
||||
const bullJob = await queue.getJob(jobId);
|
||||
if (!bullJob) return null;
|
||||
return toJob(bullJob);
|
||||
},
|
||||
|
||||
async getJobs(status) {
|
||||
const states = status ? [status] : ["waiting", "active", "completed", "failed"];
|
||||
const allJobs: Job[] = [];
|
||||
for (const state of states) {
|
||||
const jobs = await queue.getJobs(state);
|
||||
for (const j of jobs) {
|
||||
allJobs.push(await toJob(j));
|
||||
}
|
||||
}
|
||||
return allJobs;
|
||||
},
|
||||
};
|
||||
async getJobs(status) {
|
||||
const states = status
|
||||
? [status]
|
||||
: ["waiting", "active", "completed", "failed"];
|
||||
const allJobs: Job[] = [];
|
||||
for (const state of states) {
|
||||
const jobs = await queue.getJobs(state);
|
||||
for (const j of jobs) {
|
||||
allJobs.push(await toJob(j));
|
||||
}
|
||||
}
|
||||
return allJobs;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let adapter: QueueAdapter;
|
||||
|
||||
export function getQueue(): QueueAdapter {
|
||||
if (!adapter) {
|
||||
if (process.env.REDIS_URL) {
|
||||
adapter = createRedisAdapter();
|
||||
} else {
|
||||
adapter = new InMemoryQueue();
|
||||
}
|
||||
}
|
||||
return adapter;
|
||||
if (!adapter) {
|
||||
if (process.env.REDIS_URL) {
|
||||
adapter = createRedisAdapter();
|
||||
} else {
|
||||
adapter = new InMemoryQueue();
|
||||
}
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export function setQueue(mock: QueueAdapter): void {
|
||||
adapter = mock;
|
||||
adapter = mock;
|
||||
}
|
||||
|
||||
export function resetQueue(): void {
|
||||
adapter = undefined as unknown as QueueAdapter;
|
||||
adapter = undefined as unknown as QueueAdapter;
|
||||
}
|
||||
|
||||
42
web/src/server/lib/email.ts
Normal file
42
web/src/server/lib/email.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { resend } from "~/server/lib/resend";
|
||||
|
||||
export async function sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
text?: string,
|
||||
) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn("[email] Resend not configured, skipping email");
|
||||
return { id: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL ?? "noreply@kordant.ai",
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text: text ?? "",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[email] Resend error:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to send email",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[email] Email sent:", data?.id);
|
||||
return { id: data?.id ?? null };
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCError) throw err;
|
||||
console.error("[email] Email send error:", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to send email",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,72 @@
|
||||
import { object, string, optional, parse, safeParse } from "valibot";
|
||||
|
||||
const envSchema = object({
|
||||
// Database
|
||||
DATABASE_URL: string(),
|
||||
DATABASE_AUTH_TOKEN: optional(string()),
|
||||
// Database
|
||||
DATABASE_URL: string(),
|
||||
DATABASE_AUTH_TOKEN: optional(string()),
|
||||
|
||||
// Server
|
||||
PORT: optional(string()),
|
||||
NODE_ENV: optional(string()),
|
||||
LOG_LEVEL: optional(string()),
|
||||
APP_URL: optional(string()),
|
||||
// Server
|
||||
PORT: optional(string()),
|
||||
NODE_ENV: optional(string()),
|
||||
LOG_LEVEL: optional(string()),
|
||||
APP_URL: optional(string()),
|
||||
|
||||
// Auth
|
||||
JWT_SECRET: string(),
|
||||
SESSION_SECRET: optional(string()),
|
||||
// Auth
|
||||
JWT_SECRET: string(),
|
||||
SESSION_SECRET: optional(string()),
|
||||
|
||||
// Clerk
|
||||
CLERK_SECRET_KEY: string(),
|
||||
VITE_CLERK_PUBLISHABLE_KEY: string(),
|
||||
// Clerk
|
||||
CLERK_SECRET_KEY: string(),
|
||||
VITE_CLERK_PUBLISHABLE_KEY: string(),
|
||||
|
||||
// Stripe
|
||||
STRIPE_SECRET_KEY: string(),
|
||||
STRIPE_WEBHOOK_SECRET: string(),
|
||||
// Stripe
|
||||
STRIPE_SECRET_KEY: string(),
|
||||
STRIPE_WEBHOOK_SECRET: string(),
|
||||
|
||||
// Redis (for BullMQ)
|
||||
REDIS_URL: optional(string()),
|
||||
// Redis (for BullMQ)
|
||||
REDIS_URL: optional(string()),
|
||||
|
||||
// Sentry
|
||||
VITE_SENTRY_DSN: optional(string()),
|
||||
// Sentry
|
||||
VITE_SENTRY_DSN: optional(string()),
|
||||
|
||||
// Email
|
||||
RESEND_API_KEY: optional(string()),
|
||||
// Email
|
||||
RESEND_API_KEY: optional(string()),
|
||||
|
||||
// Push
|
||||
FCM_PROJECT_ID: optional(string()),
|
||||
FCM_CLIENT_EMAIL: optional(string()),
|
||||
FCM_PRIVATE_KEY: optional(string()),
|
||||
// SMS
|
||||
TWILIO_ACCOUNT_SID: optional(string()),
|
||||
TWILIO_AUTH_TOKEN: optional(string()),
|
||||
TWILIO_MESSAGING_SERVICE_SID: optional(string()),
|
||||
|
||||
// SMS
|
||||
TWILIO_ACCOUNT_SID: optional(string()),
|
||||
TWILIO_AUTH_TOKEN: optional(string()),
|
||||
TWILIO_MESSAGING_SERVICE_SID: optional(string()),
|
||||
// External APIs
|
||||
ATTOM_API_KEY: optional(string()),
|
||||
HIBP_API_KEY: optional(string()),
|
||||
HIBP_RATE_PER_SECOND: optional(string()),
|
||||
SECURITYTRAILS_API_KEY: optional(string()),
|
||||
CENSYS_API_ID: optional(string()),
|
||||
CENSYS_API_SECRET: optional(string()),
|
||||
SHODAN_API_KEY: optional(string()),
|
||||
|
||||
// External APIs
|
||||
ATTOM_API_KEY: optional(string()),
|
||||
HIBP_API_KEY: optional(string()),
|
||||
HIBP_RATE_PER_SECOND: optional(string()),
|
||||
SECURITYTRAILS_API_KEY: optional(string()),
|
||||
CENSYS_API_ID: optional(string()),
|
||||
CENSYS_API_SECRET: optional(string()),
|
||||
SHODAN_API_KEY: optional(string()),
|
||||
|
||||
// WebSocket
|
||||
WS_PORT: optional(string()),
|
||||
// WebSocket
|
||||
WS_PORT: optional(string()),
|
||||
});
|
||||
|
||||
export function validateEnv() {
|
||||
const result = safeParse(envSchema, {
|
||||
...process.env,
|
||||
});
|
||||
const result = safeParse(envSchema, {
|
||||
...process.env,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const missingKeys = result.issues
|
||||
.map((issue) => issue.path?.[0]?.key as string | undefined)
|
||||
.filter((k): k is string => k !== undefined);
|
||||
if (!result.success) {
|
||||
const missingKeys = result.issues
|
||||
.map((issue) => issue.path?.[0]?.key as string | undefined)
|
||||
.filter((k): k is string => k !== undefined);
|
||||
|
||||
console.error("Environment validation failed:");
|
||||
console.error("Missing required variables:", missingKeys.join(", "));
|
||||
console.error("\nPlease check .env.example for all required variables.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.error("Environment validation failed:");
|
||||
console.error("Missing required variables:", missingKeys.join(", "));
|
||||
console.error("\nPlease check .env.example for all required variables.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return parse(envSchema, { ...process.env });
|
||||
return parse(envSchema, { ...process.env });
|
||||
}
|
||||
|
||||
export const env = validateEnv();
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { initializeApp, cert, getApps } from "firebase-admin/app";
|
||||
import { getMessaging } from "firebase-admin/messaging";
|
||||
|
||||
const projectId = process.env.FCM_PROJECT_ID;
|
||||
const clientEmail = process.env.FCM_CLIENT_EMAIL;
|
||||
const privateKey = process.env.FCM_PRIVATE_KEY;
|
||||
|
||||
if (!getApps().length && projectId && clientEmail && privateKey) {
|
||||
initializeApp({
|
||||
credential: cert({
|
||||
projectId,
|
||||
clientEmail,
|
||||
privateKey: privateKey.replace(/\\n/g, "\n"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export const messaging = getApps().length ? getMessaging() : null;
|
||||
@@ -4,108 +4,131 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
const mockBroadcastToUser = vi.fn();
|
||||
|
||||
vi.mock("~/server/websocket", () => ({
|
||||
broadcastToUser: mockBroadcastToUser,
|
||||
broadcastToUser: mockBroadcastToUser,
|
||||
}));
|
||||
|
||||
const mockSendPush = vi.fn();
|
||||
const mockSendEmail = vi.fn();
|
||||
|
||||
vi.mock("~/server/services/notification.service", () => ({
|
||||
sendPush: mockSendPush,
|
||||
sendEmail: mockSendEmail,
|
||||
vi.mock("~/server/lib/email", () => ({
|
||||
sendEmail: mockSendEmail,
|
||||
}));
|
||||
|
||||
vi.mock("~/server/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("alert.publisher", () => {
|
||||
it("should send alert via WebSocket when user is connected", async () => {
|
||||
mockBroadcastToUser.mockReturnValue(true);
|
||||
it("should send alert via WebSocket when user is connected", async () => {
|
||||
mockBroadcastToUser.mockReturnValue(true);
|
||||
|
||||
const { publishAlert } = await import("./alert.publisher");
|
||||
await publishAlert("user-1", {
|
||||
id: "alert-1",
|
||||
title: "Test Alert",
|
||||
message: "Test message",
|
||||
severity: "HIGH",
|
||||
source: "DARKWATCH",
|
||||
category: "EXPOSURE_DETECTED",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
const { publishAlert } = await import("./alert.publisher");
|
||||
await publishAlert("user-1", {
|
||||
id: "alert-1",
|
||||
title: "Test Alert",
|
||||
message: "Test message",
|
||||
severity: "HIGH",
|
||||
source: "DARKWATCH",
|
||||
category: "EXPOSURE_DETECTED",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
expect(mockBroadcastToUser).toHaveBeenCalledWith("user-1", {
|
||||
type: "alert",
|
||||
alert: {
|
||||
id: "alert-1",
|
||||
title: "Test Alert",
|
||||
message: "Test message",
|
||||
severity: "HIGH",
|
||||
source: "DARKWATCH",
|
||||
category: "EXPOSURE_DETECTED",
|
||||
createdAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
expect(mockSendPush).not.toHaveBeenCalled();
|
||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(mockBroadcastToUser).toHaveBeenCalledWith("user-1", {
|
||||
type: "alert",
|
||||
alert: {
|
||||
id: "alert-1",
|
||||
title: "Test Alert",
|
||||
message: "Test message",
|
||||
severity: "HIGH",
|
||||
source: "DARKWATCH",
|
||||
category: "EXPOSURE_DETECTED",
|
||||
createdAt: expect.any(String),
|
||||
},
|
||||
});
|
||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fall back to push notification when user is not connected", async () => {
|
||||
mockBroadcastToUser.mockReturnValue(false);
|
||||
mockSendPush.mockResolvedValue({ successCount: 1 });
|
||||
it("should fall back to email when user is not connected and has email", async () => {
|
||||
mockBroadcastToUser.mockReturnValue(false);
|
||||
|
||||
const { publishAlert } = await import("./alert.publisher");
|
||||
await publishAlert("user-1", {
|
||||
id: "alert-2",
|
||||
title: "Offline Alert",
|
||||
message: "Offline message",
|
||||
severity: "WARNING",
|
||||
source: "VOICEPRINT",
|
||||
category: "SYNTHETIC_VOICE",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
const db = await import("~/server/db");
|
||||
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: "user-1", email: "user@example.com" }]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mockBroadcastToUser).toHaveBeenCalled();
|
||||
expect(mockSendPush).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"Offline Alert",
|
||||
"Offline message",
|
||||
{ alertId: "alert-2", source: "VOICEPRINT", severity: "WARNING" },
|
||||
);
|
||||
});
|
||||
const { publishAlert } = await import("./alert.publisher");
|
||||
await publishAlert("user-1", {
|
||||
id: "alert-2",
|
||||
title: "Offline Alert",
|
||||
message: "Offline message",
|
||||
severity: "WARNING",
|
||||
source: "VOICEPRINT",
|
||||
category: "SYNTHETIC_VOICE",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
it("should publish alert to multiple users", async () => {
|
||||
mockBroadcastToUser.mockReturnValue(false);
|
||||
mockSendPush.mockResolvedValue({ successCount: 0 });
|
||||
expect(mockBroadcastToUser).toHaveBeenCalled();
|
||||
expect(mockSendEmail).toHaveBeenCalledWith(
|
||||
"user@example.com",
|
||||
"[Kordant] Offline Alert",
|
||||
"<p>Offline message</p>",
|
||||
"Offline message",
|
||||
);
|
||||
});
|
||||
|
||||
const db = await import("~/server/db");
|
||||
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
it("should not send email when user has no email", async () => {
|
||||
mockBroadcastToUser.mockReturnValue(false);
|
||||
|
||||
const { publishToGroup } = await import("./alert.publisher");
|
||||
await publishToGroup(["user-1", "user-2"], {
|
||||
id: "alert-3",
|
||||
title: "Group Alert",
|
||||
message: "Group message",
|
||||
severity: "INFO",
|
||||
source: "HOME_TITLE",
|
||||
category: "HOME_TITLE",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
const db = await import("~/server/db");
|
||||
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(mockBroadcastToUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const { publishAlert } = await import("./alert.publisher");
|
||||
await publishAlert("user-1", {
|
||||
id: "alert-3",
|
||||
title: "No Email",
|
||||
message: "No email",
|
||||
severity: "INFO",
|
||||
source: "HOME_TITLE",
|
||||
category: "HOME_TITLE",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should publish alert to multiple users", async () => {
|
||||
mockBroadcastToUser.mockReturnValue(true);
|
||||
|
||||
const { publishToGroup } = await import("./alert.publisher");
|
||||
await publishToGroup(["user-1", "user-2"], {
|
||||
id: "alert-4",
|
||||
title: "Group Alert",
|
||||
message: "Group message",
|
||||
severity: "INFO",
|
||||
source: "HOME_TITLE",
|
||||
category: "HOME_TITLE",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
expect(mockBroadcastToUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,66 +1,64 @@
|
||||
import { broadcastToUser } from "~/server/websocket";
|
||||
import { sendPush, sendEmail } from "~/server/services/notification.service";
|
||||
import { sendEmail } from "~/server/lib/email";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export interface PublishableAlert {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
source: string;
|
||||
category: string;
|
||||
createdAt: Date;
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
severity: string;
|
||||
source: string;
|
||||
category: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export async function publishAlert(userId: string, alert: PublishableAlert): Promise<void> {
|
||||
const message = {
|
||||
type: "alert" as const,
|
||||
alert: {
|
||||
id: alert.id,
|
||||
title: alert.title,
|
||||
message: alert.message,
|
||||
severity: alert.severity,
|
||||
source: alert.source,
|
||||
category: alert.category,
|
||||
createdAt: alert.createdAt.toISOString(),
|
||||
},
|
||||
};
|
||||
export async function publishAlert(
|
||||
userId: string,
|
||||
alert: PublishableAlert,
|
||||
): Promise<void> {
|
||||
const message = {
|
||||
type: "alert" as const,
|
||||
alert: {
|
||||
id: alert.id,
|
||||
title: alert.title,
|
||||
message: alert.message,
|
||||
severity: alert.severity,
|
||||
source: alert.source,
|
||||
category: alert.category,
|
||||
createdAt: alert.createdAt.toISOString(),
|
||||
},
|
||||
};
|
||||
|
||||
const sent = broadcastToUser(userId, message);
|
||||
const sent = broadcastToUser(userId, message);
|
||||
|
||||
if (!sent) {
|
||||
try {
|
||||
const pushResult = await sendPush(userId, alert.title, alert.message, {
|
||||
alertId: alert.id,
|
||||
source: alert.source,
|
||||
severity: alert.severity,
|
||||
});
|
||||
if (!sent) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (pushResult.successCount === 0) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (user?.email) {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
`[Kordant] ${alert.title}`,
|
||||
`<p>${alert.message}</p>`,
|
||||
alert.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[alert.publisher] Fallback notification failed:", err);
|
||||
}
|
||||
}
|
||||
if (user?.email) {
|
||||
try {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
`[Kordant] ${alert.title}`,
|
||||
`<p>${alert.message}</p>`,
|
||||
alert.message,
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("[alert.publisher] Email notification failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishToGroup(userIds: string[], alert: PublishableAlert): Promise<void> {
|
||||
const promises = userIds.map((userId) => publishAlert(userId, alert));
|
||||
await Promise.allSettled(promises);
|
||||
export async function publishToGroup(
|
||||
userIds: string[],
|
||||
alert: PublishableAlert,
|
||||
): Promise<void> {
|
||||
const promises = userIds.map((userId) => publishAlert(userId, alert));
|
||||
await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { eq, and, asc } from "drizzle-orm";
|
||||
import { db } from "~/server/db";
|
||||
import { digestAlerts, notificationPreferences } from "~/server/db/schema";
|
||||
import { sendEmail } from "~/server/services/notification.service";
|
||||
import { sendEmail } from "~/server/lib/email";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Digest configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DigestConfig {
|
||||
/** Severity levels that get batched into digest (vs immediate) */
|
||||
batchedSeverities: string[];
|
||||
/** Digest frequency: "daily" or "weekly" */
|
||||
frequency: "daily" | "weekly";
|
||||
/** Time of day for daily digest (UTC hour) */
|
||||
dailyHour: number;
|
||||
/** Day of week for weekly digest (0=Sun) */
|
||||
weeklyDay: number;
|
||||
/** Severity levels that get batched into digest (vs immediate) */
|
||||
batchedSeverities: string[];
|
||||
/** Digest frequency: "daily" or "weekly" */
|
||||
frequency: "daily" | "weekly";
|
||||
/** Time of day for daily digest (UTC hour) */
|
||||
dailyHour: number;
|
||||
/** Day of week for weekly digest (0=Sun) */
|
||||
weeklyDay: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_DIGEST_CONFIG: DigestConfig = {
|
||||
batchedSeverities: ["info"],
|
||||
frequency: "daily",
|
||||
dailyHour: 9, // 9 AM UTC
|
||||
weeklyDay: 0, // Sunday
|
||||
batchedSeverities: ["info"],
|
||||
frequency: "daily",
|
||||
dailyHour: 9, // 9 AM UTC
|
||||
weeklyDay: 0, // Sunday
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,75 +30,77 @@ export const DEFAULT_DIGEST_CONFIG: DigestConfig = {
|
||||
* and user preferences.
|
||||
*/
|
||||
export async function shouldDigest(
|
||||
userId: string,
|
||||
severity: string,
|
||||
userId: string,
|
||||
severity: string,
|
||||
): Promise<boolean> {
|
||||
const [prefs] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1);
|
||||
const [prefs] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
// If user has no prefs, use defaults: info = digest, warning/critical = immediate
|
||||
if (!prefs) {
|
||||
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
|
||||
}
|
||||
// If user has no prefs, use defaults: info = digest, warning/critical = immediate
|
||||
if (!prefs) {
|
||||
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
|
||||
}
|
||||
|
||||
// If email is disabled entirely, don't digest (alert won't be delivered)
|
||||
if (!prefs.emailEnabled) {
|
||||
return false;
|
||||
}
|
||||
// If email is disabled entirely, don't digest (alert won't be delivered)
|
||||
if (!prefs.emailEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
|
||||
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the next scheduled digest date based on config.
|
||||
*/
|
||||
export function calculateNextDigestDate(config: DigestConfig = DEFAULT_DIGEST_CONFIG): Date {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
export function calculateNextDigestDate(
|
||||
config: DigestConfig = DEFAULT_DIGEST_CONFIG,
|
||||
): Date {
|
||||
const now = new Date();
|
||||
const next = new Date(now);
|
||||
|
||||
if (config.frequency === "daily") {
|
||||
next.setUTCHours(config.dailyHour, 0, 0, 0);
|
||||
if (next.getTime() <= now.getTime()) {
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
}
|
||||
} else {
|
||||
next.setUTCHours(config.dailyHour, 0, 0, 0);
|
||||
const currentDay = next.getUTCDay();
|
||||
const daysUntilTarget = (config.weeklyDay - currentDay + 7) % 7;
|
||||
if (daysUntilTarget === 0 && next.getTime() <= now.getTime()) {
|
||||
next.setUTCDate(next.getUTCDate() + 7);
|
||||
} else if (daysUntilTarget > 0 || next.getTime() <= now.getTime()) {
|
||||
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
|
||||
}
|
||||
}
|
||||
if (config.frequency === "daily") {
|
||||
next.setUTCHours(config.dailyHour, 0, 0, 0);
|
||||
if (next.getTime() <= now.getTime()) {
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
}
|
||||
} else {
|
||||
next.setUTCHours(config.dailyHour, 0, 0, 0);
|
||||
const currentDay = next.getUTCDay();
|
||||
const daysUntilTarget = (config.weeklyDay - currentDay + 7) % 7;
|
||||
if (daysUntilTarget === 0 && next.getTime() <= now.getTime()) {
|
||||
next.setUTCDate(next.getUTCDate() + 7);
|
||||
} else if (daysUntilTarget > 0 || next.getTime() <= now.getTime()) {
|
||||
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues an alert for the next digest email.
|
||||
*/
|
||||
export async function queueForDigest(
|
||||
userId: string,
|
||||
alertId: string,
|
||||
title: string,
|
||||
severity: string,
|
||||
source: string,
|
||||
userId: string,
|
||||
alertId: string,
|
||||
title: string,
|
||||
severity: string,
|
||||
source: string,
|
||||
): Promise<void> {
|
||||
const nextDigestDate = calculateNextDigestDate();
|
||||
const nextDigestDate = calculateNextDigestDate();
|
||||
|
||||
await db.insert(digestAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
title,
|
||||
severity,
|
||||
source,
|
||||
scheduledDigestDate: nextDigestDate,
|
||||
sent: false,
|
||||
});
|
||||
await db.insert(digestAlerts).values({
|
||||
userId,
|
||||
alertId,
|
||||
title,
|
||||
severity,
|
||||
source,
|
||||
scheduledDigestDate: nextDigestDate,
|
||||
sent: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,71 +108,75 @@ export async function queueForDigest(
|
||||
* Returns the number of alerts included in the digest.
|
||||
*/
|
||||
export async function sendDigestEmail(
|
||||
userId: string,
|
||||
scheduledDate: Date,
|
||||
userId: string,
|
||||
scheduledDate: Date,
|
||||
): Promise<number> {
|
||||
const pendingAlerts = await db
|
||||
.select()
|
||||
.from(digestAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(digestAlerts.userId, userId),
|
||||
eq(digestAlerts.sent, false),
|
||||
eq(digestAlerts.scheduledDigestDate, scheduledDate),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(digestAlerts.severity));
|
||||
const pendingAlerts = await db
|
||||
.select()
|
||||
.from(digestAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(digestAlerts.userId, userId),
|
||||
eq(digestAlerts.sent, false),
|
||||
eq(digestAlerts.scheduledDigestDate, scheduledDate),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(digestAlerts.severity));
|
||||
|
||||
if (!pendingAlerts.length) {
|
||||
return 0;
|
||||
}
|
||||
if (!pendingAlerts.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get user email
|
||||
const { users } = await import("~/server/db/schema/auth");
|
||||
const [user] = await db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
// Get user email
|
||||
const { users } = await import("~/server/db/schema/auth");
|
||||
const [user] = await db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!user?.email) {
|
||||
console.warn(`[digest] No email found for user ${userId}`);
|
||||
return 0;
|
||||
}
|
||||
if (!user?.email) {
|
||||
console.warn(`[digest] No email found for user ${userId}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Build digest email content
|
||||
const alertsBySeverity = groupBySeverity(pendingAlerts);
|
||||
const html = buildDigestEmailHTML(alertsBySeverity, pendingAlerts.length);
|
||||
// Build digest email content
|
||||
const alertsBySeverity = groupBySeverity(pendingAlerts);
|
||||
const html = buildDigestEmailHTML(alertsBySeverity, pendingAlerts.length);
|
||||
|
||||
try {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
`[Kordant] Security Digest — ${pendingAlerts.length} alert${pendingAlerts.length > 1 ? "s" : ""}`,
|
||||
html,
|
||||
buildDigestPlainText(alertsBySeverity, pendingAlerts.length),
|
||||
);
|
||||
try {
|
||||
await sendEmail(
|
||||
user.email,
|
||||
`[Kordant] Security Digest — ${pendingAlerts.length} alert${pendingAlerts.length > 1 ? "s" : ""}`,
|
||||
html,
|
||||
buildDigestPlainText(alertsBySeverity, pendingAlerts.length),
|
||||
);
|
||||
|
||||
// Mark alerts as sent
|
||||
const alertIds = pendingAlerts.map((a) => a.id);
|
||||
await db
|
||||
.update(digestAlerts)
|
||||
.set({ sent: true, sentAt: new Date() })
|
||||
.where(and(eq(digestAlerts.userId, userId), eq(digestAlerts.id, alertIds[0])));
|
||||
// Mark alerts as sent
|
||||
const alertIds = pendingAlerts.map((a) => a.id);
|
||||
await db
|
||||
.update(digestAlerts)
|
||||
.set({ sent: true, sentAt: new Date() })
|
||||
.where(
|
||||
and(eq(digestAlerts.userId, userId), eq(digestAlerts.id, alertIds[0])),
|
||||
);
|
||||
|
||||
// Update all matching alerts
|
||||
for (const alertId of alertIds) {
|
||||
await db
|
||||
.update(digestAlerts)
|
||||
.set({ sent: true, sentAt: new Date() })
|
||||
.where(eq(digestAlerts.id, alertId));
|
||||
}
|
||||
// Update all matching alerts
|
||||
for (const alertId of alertIds) {
|
||||
await db
|
||||
.update(digestAlerts)
|
||||
.set({ sent: true, sentAt: new Date() })
|
||||
.where(eq(digestAlerts.id, alertId));
|
||||
}
|
||||
|
||||
console.log(`[digest] Sent digest to ${user.email} with ${pendingAlerts.length} alerts`);
|
||||
return pendingAlerts.length;
|
||||
} catch (err) {
|
||||
console.error(`[digest] Failed to send digest for user ${userId}:`, err);
|
||||
return 0;
|
||||
}
|
||||
console.log(
|
||||
`[digest] Sent digest to ${user.email} with ${pendingAlerts.length} alerts`,
|
||||
);
|
||||
return pendingAlerts.length;
|
||||
} catch (err) {
|
||||
console.error(`[digest] Failed to send digest for user ${userId}:`, err);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,42 +184,38 @@ export async function sendDigestEmail(
|
||||
* Called by the digest job scheduler.
|
||||
*/
|
||||
export async function processDueDigests(): Promise<void> {
|
||||
const now = new Date();
|
||||
const today = new Date(now.toISOString().split("T")[0]);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||
const now = new Date();
|
||||
const today = new Date(now.toISOString().split("T")[0]);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||
|
||||
// Find all users with pending digests due today
|
||||
const { users } = await import("~/server/db/schema/auth");
|
||||
// Find all users with pending digests due today
|
||||
const { users } = await import("~/server/db/schema/auth");
|
||||
|
||||
// Get distinct userIds with pending digests
|
||||
const pendingDigests = await db
|
||||
.select({
|
||||
userId: digestAlerts.userId,
|
||||
scheduledDate: digestAlerts.scheduledDigestDate,
|
||||
})
|
||||
.from(digestAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(digestAlerts.sent, false),
|
||||
),
|
||||
);
|
||||
// Get distinct userIds with pending digests
|
||||
const pendingDigests = await db
|
||||
.select({
|
||||
userId: digestAlerts.userId,
|
||||
scheduledDate: digestAlerts.scheduledDigestDate,
|
||||
})
|
||||
.from(digestAlerts)
|
||||
.where(and(eq(digestAlerts.sent, false)));
|
||||
|
||||
// Group by user
|
||||
const userMap = new Map<string, Date[]>();
|
||||
for (const d of pendingDigests) {
|
||||
const dates = userMap.get(d.userId) ?? [];
|
||||
dates.push(d.scheduledDate);
|
||||
userMap.set(d.userId, dates);
|
||||
}
|
||||
// Group by user
|
||||
const userMap = new Map<string, Date[]>();
|
||||
for (const d of pendingDigests) {
|
||||
const dates = userMap.get(d.userId) ?? [];
|
||||
dates.push(d.scheduledDate);
|
||||
userMap.set(d.userId, dates);
|
||||
}
|
||||
|
||||
for (const [userId, dates] of userMap) {
|
||||
for (const date of [...new Set(dates)]) {
|
||||
if (date.getTime() <= now.getTime()) {
|
||||
await sendDigestEmail(userId, date);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [userId, dates] of userMap) {
|
||||
for (const date of [...new Set(dates)]) {
|
||||
if (date.getTime() <= now.getTime()) {
|
||||
await sendDigestEmail(userId, date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,62 +223,62 @@ export async function processDueDigests(): Promise<void> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function groupBySeverity(
|
||||
alerts: typeof digestAlerts.$InferInsert[],
|
||||
): Record<string, typeof digestAlerts.$InferInsert[]> {
|
||||
const groups: Record<string, typeof digestAlerts.$InferInsert[]> = {
|
||||
critical: [],
|
||||
warning: [],
|
||||
info: [],
|
||||
};
|
||||
alerts: (typeof digestAlerts.$InferInsert)[],
|
||||
): Record<string, (typeof digestAlerts.$InferInsert)[]> {
|
||||
const groups: Record<string, (typeof digestAlerts.$InferInsert)[]> = {
|
||||
critical: [],
|
||||
warning: [],
|
||||
info: [],
|
||||
};
|
||||
|
||||
for (const alert of alerts) {
|
||||
const key = alert.severity ?? "info";
|
||||
if (groups[key]) {
|
||||
groups[key].push(alert);
|
||||
} else {
|
||||
groups.info.push(alert);
|
||||
}
|
||||
}
|
||||
for (const alert of alerts) {
|
||||
const key = alert.severity ?? "info";
|
||||
if (groups[key]) {
|
||||
groups[key].push(alert);
|
||||
} else {
|
||||
groups.info.push(alert);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
return groups;
|
||||
}
|
||||
|
||||
function buildDigestEmailHTML(
|
||||
groups: Record<string, typeof digestAlerts.$InferInsert[]>,
|
||||
total: number,
|
||||
groups: Record<string, (typeof digestAlerts.$InferInsert)[]>,
|
||||
total: number,
|
||||
): string {
|
||||
const sections = [];
|
||||
const sections = [];
|
||||
|
||||
const severityConfig = [
|
||||
{ key: "critical", label: "Critical", color: "#dc2626", bg: "#fef2f2" },
|
||||
{ key: "warning", label: "Warning", color: "#d97706", bg: "#fffbeb" },
|
||||
{ key: "info", label: "Info", color: "#2563eb", bg: "#eff6ff" },
|
||||
];
|
||||
const severityConfig = [
|
||||
{ key: "critical", label: "Critical", color: "#dc2626", bg: "#fef2f2" },
|
||||
{ key: "warning", label: "Warning", color: "#d97706", bg: "#fffbeb" },
|
||||
{ key: "info", label: "Info", color: "#2563eb", bg: "#eff6ff" },
|
||||
];
|
||||
|
||||
for (const { key, label, color, bg } of severityConfig) {
|
||||
const alerts = groups[key];
|
||||
if (!alerts.length) continue;
|
||||
for (const { key, label, color, bg } of severityConfig) {
|
||||
const alerts = groups[key];
|
||||
if (!alerts.length) continue;
|
||||
|
||||
const rows = alerts
|
||||
.map(
|
||||
(a) =>
|
||||
`<tr style="border-bottom:1px solid #eee">
|
||||
const rows = alerts
|
||||
.map(
|
||||
(a) =>
|
||||
`<tr style="border-bottom:1px solid #eee">
|
||||
<td style="padding:8px 12px"><span style="color:${color};font-weight:600;text-transform:uppercase;font-size:11px">${a.severity}</span></td>
|
||||
<td style="padding:8px 12px">${escapeHtml(a.title)}</td>
|
||||
<td style="padding:8px 12px;color:#666;font-size:12px">${escapeHtml(a.source)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("");
|
||||
)
|
||||
.join("");
|
||||
|
||||
sections.push(`
|
||||
sections.push(`
|
||||
<div style="margin:16px 0;padding:12px;background:${bg};border-radius:8px;border-left:4px solid ${color}">
|
||||
<h3 style="margin:0 0 8px 0;color:${color}">${label} (${alerts.length})</h3>
|
||||
<table style="width:100%;border-collapse:collapse">${rows}</table>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
return `
|
||||
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px">
|
||||
<h2 style="margin:0 0 4px 0">🛡️ Kordant Security Digest</h2>
|
||||
<p style="color:#666;margin:0 0 24px 0">${total} alert${total > 1 ? "s" : ""} since your last digest</p>
|
||||
@@ -289,45 +291,42 @@ function buildDigestEmailHTML(
|
||||
}
|
||||
|
||||
function buildDigestPlainText(
|
||||
groups: Record<string, typeof digestAlerts.$InferInsert[]>,
|
||||
total: number,
|
||||
groups: Record<string, (typeof digestAlerts.$InferInsert)[]>,
|
||||
total: number,
|
||||
): string {
|
||||
const lines = [`Kordant Security Digest — ${total} alert${total > 1 ? "s" : ""}`, ""];
|
||||
const lines = [
|
||||
`Kordant Security Digest — ${total} alert${total > 1 ? "s" : ""}`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (const [key, alerts] of Object.entries(groups)) {
|
||||
if (!alerts.length) continue;
|
||||
lines.push(`${key.toUpperCase()} (${alerts.length}):`);
|
||||
for (const a of alerts) {
|
||||
lines.push(` - ${a.title} [${a.source}]`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
for (const [key, alerts] of Object.entries(groups)) {
|
||||
if (!alerts.length) continue;
|
||||
lines.push(`${key.toUpperCase()} (${alerts.length}):`);
|
||||
for (const a of alerts) {
|
||||
lines.push(` - ${a.title} [${a.source}]`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("This is an automated digest from Kordant.");
|
||||
return lines.join("\n");
|
||||
lines.push("This is an automated digest from Kordant.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up old digest records (older than 30 days).
|
||||
*/
|
||||
export async function cleanupOldDigests(): Promise<void> {
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db
|
||||
.delete(digestAlerts)
|
||||
.where(
|
||||
and(
|
||||
eq(digestAlerts.sent, true),
|
||||
),
|
||||
);
|
||||
await db.delete(digestAlerts).where(and(eq(digestAlerts.sent, true)));
|
||||
|
||||
console.log(`[digest] Cleaned up old digest records`);
|
||||
console.log(`[digest] Cleaned up old digest records`);
|
||||
}
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
const mockResendSend = vi.fn();
|
||||
const mockMessagingSend = vi.fn();
|
||||
const mockTwilioCreate = vi.fn();
|
||||
|
||||
vi.mock("~/server/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/server/lib/resend", () => ({
|
||||
resend: { emails: { send: mockResendSend } },
|
||||
}));
|
||||
|
||||
vi.mock("~/server/lib/fcm", () => ({
|
||||
messaging: { send: mockMessagingSend },
|
||||
}));
|
||||
|
||||
vi.mock("~/server/lib/twilio", () => ({
|
||||
twilioClient: { messages: { create: mockTwilioCreate } },
|
||||
}));
|
||||
|
||||
import { db } from "~/server/db";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("sendEmail", () => {
|
||||
it("calls Resend with correct parameters", async () => {
|
||||
process.env.RESEND_API_KEY = "test-key";
|
||||
mockResendSend.mockResolvedValue({
|
||||
data: { id: "email-1" },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { sendEmail } = await import("./notification.service");
|
||||
const result = await sendEmail("test@example.com", "Subject", "<p>Body</p>", "Text body");
|
||||
|
||||
expect(mockResendSend).toHaveBeenCalledWith({
|
||||
from: "noreply@kordant.ai",
|
||||
to: "test@example.com",
|
||||
subject: "Subject",
|
||||
html: "<p>Body</p>",
|
||||
text: "Text body",
|
||||
});
|
||||
expect(result).toEqual({ id: "email-1" });
|
||||
});
|
||||
|
||||
it("skips sending when Resend API key is not configured", async () => {
|
||||
delete process.env.RESEND_API_KEY;
|
||||
|
||||
const { sendEmail } = await import("./notification.service");
|
||||
const result = await sendEmail("test@example.com", "Subject", "<p>Body</p>");
|
||||
|
||||
expect(result).toEqual({ id: null });
|
||||
expect(mockResendSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws INTERNAL_SERVER_ERROR when Resend returns an error", async () => {
|
||||
process.env.RESEND_API_KEY = "test-key";
|
||||
mockResendSend.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: "API error" },
|
||||
});
|
||||
|
||||
const { sendEmail } = await import("./notification.service");
|
||||
await expect(sendEmail("test@example.com", "Subject", "<p>Body</p>")).rejects.toThrow(TRPCError);
|
||||
await expect(sendEmail("test@example.com", "Subject", "<p>Body</p>")).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendPush", () => {
|
||||
it("sends FCM message to all active devices", async () => {
|
||||
const devices = [
|
||||
{ id: "d1", userId: "u1", token: "token-1", platform: "android", deviceType: "mobile", isActive: true },
|
||||
{ id: "d2", userId: "u1", token: "token-2", platform: "ios", deviceType: "mobile", isActive: true },
|
||||
];
|
||||
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(devices),
|
||||
}),
|
||||
});
|
||||
|
||||
mockMessagingSend.mockResolvedValue({});
|
||||
|
||||
const { sendPush } = await import("./notification.service");
|
||||
const result = await sendPush("u1", "Title", "Body", { key: "val" });
|
||||
|
||||
expect(result).toEqual({ successCount: 2 });
|
||||
expect(mockMessagingSend).toHaveBeenCalledTimes(2);
|
||||
expect(mockMessagingSend).toHaveBeenCalledWith({
|
||||
token: "token-1",
|
||||
notification: { title: "Title", body: "Body" },
|
||||
data: { key: "val" },
|
||||
android: { priority: "high" },
|
||||
apns: { payload: { aps: { alert: { title: "Title", body: "Body" }, sound: "default", badge: 1 } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 0 success when no active devices", async () => {
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { sendPush } = await import("./notification.service");
|
||||
const result = await sendPush("u1", "Title", "Body");
|
||||
|
||||
expect(result).toEqual({ successCount: 0 });
|
||||
expect(mockMessagingSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues sending if one push fails", async () => {
|
||||
const devices = [
|
||||
{ id: "d1", userId: "u1", token: "token-1", platform: "android", deviceType: "mobile", isActive: true },
|
||||
{ id: "d2", userId: "u1", token: "token-2", platform: "ios", deviceType: "mobile", isActive: true },
|
||||
];
|
||||
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(devices),
|
||||
}),
|
||||
});
|
||||
|
||||
mockMessagingSend
|
||||
.mockRejectedValueOnce(new Error("FCM error"))
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
const { sendPush } = await import("./notification.service");
|
||||
const result = await sendPush("u1", "Title", "Body");
|
||||
|
||||
expect(result).toEqual({ successCount: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendSMS", () => {
|
||||
it("calls Twilio with correct parameters", async () => {
|
||||
process.env.TWILIO_MESSAGING_SERVICE_SID = "MGxxx";
|
||||
mockTwilioCreate.mockResolvedValue({ sid: "SMxxx" });
|
||||
|
||||
const { sendSMS } = await import("./notification.service");
|
||||
const result = await sendSMS("+1234567890", "Hello");
|
||||
|
||||
expect(mockTwilioCreate).toHaveBeenCalledWith({
|
||||
body: "Hello",
|
||||
to: "+1234567890",
|
||||
messagingServiceSid: "MGxxx",
|
||||
});
|
||||
expect(result).toEqual({ sid: "SMxxx" });
|
||||
});
|
||||
|
||||
it("throws BAD_REQUEST for non-E.164 phone numbers", async () => {
|
||||
const { sendSMS } = await import("./notification.service");
|
||||
await expect(sendSMS("1234567890", "Hello")).rejects.toThrow(TRPCError);
|
||||
await expect(sendSMS("1234567890", "Hello")).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
await expect(sendSMS("+12", "Hello")).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts valid E.164 phone numbers", async () => {
|
||||
mockTwilioCreate.mockResolvedValue({ sid: "SMxxx" });
|
||||
|
||||
const { sendSMS } = await import("./notification.service");
|
||||
await expect(sendSMS("+1234567890", "Hello")).resolves.toEqual({ sid: "SMxxx" });
|
||||
await expect(sendSMS("+447911123456", "Hello")).resolves.toEqual({ sid: "SMxxx" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("registerDevice", () => {
|
||||
it("creates a new device token record", async () => {
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const newDevice = {
|
||||
id: "d-new",
|
||||
userId: "u1",
|
||||
token: "new-token",
|
||||
platform: "android",
|
||||
deviceType: "mobile",
|
||||
isActive: true,
|
||||
lastUsedAt: new Date(),
|
||||
};
|
||||
|
||||
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([newDevice]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { registerDevice } = await import("./notification.service");
|
||||
const result = await registerDevice("u1", "new-token", "android", "mobile");
|
||||
|
||||
expect(result).toEqual(newDevice);
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reactivates an existing token for the same user", async () => {
|
||||
const existing = {
|
||||
id: "d1",
|
||||
userId: "u1",
|
||||
token: "existing-token",
|
||||
platform: "android",
|
||||
deviceType: "mobile",
|
||||
isActive: false,
|
||||
lastUsedAt: new Date(0),
|
||||
};
|
||||
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([existing]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const updated = { ...existing, isActive: true, lastUsedAt: expect.any(Date) };
|
||||
|
||||
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([updated]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { registerDevice } = await import("./notification.service");
|
||||
const result = await registerDevice("u1", "existing-token", "android", "mobile");
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
expect(db.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws CONFLICT when token belongs to another user", async () => {
|
||||
const existing = {
|
||||
id: "d1",
|
||||
userId: "u2",
|
||||
token: "other-user-token",
|
||||
platform: "android",
|
||||
deviceType: "mobile",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([existing]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { registerDevice } = await import("./notification.service");
|
||||
await expect(registerDevice("u1", "other-user-token", "android", "mobile")).rejects.toThrow(TRPCError);
|
||||
await expect(registerDevice("u1", "other-user-token", "android", "mobile")).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unregisterDevice", () => {
|
||||
it("marks a device token as inactive", async () => {
|
||||
const existing = {
|
||||
id: "d1",
|
||||
userId: "u1",
|
||||
token: "token-1",
|
||||
platform: "android",
|
||||
deviceType: "mobile",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([existing]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const deactivated = { ...existing, isActive: false };
|
||||
|
||||
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([deactivated]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { unregisterDevice } = await import("./notification.service");
|
||||
const result = await unregisterDevice("u1", "token-1");
|
||||
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when token does not exist", async () => {
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { unregisterDevice } = await import("./notification.service");
|
||||
await expect(unregisterDevice("u1", "nonexistent")).rejects.toThrow(TRPCError);
|
||||
await expect(unregisterDevice("u1", "nonexistent")).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("listDevices", () => {
|
||||
it("returns all devices for a user ordered by creation date", async () => {
|
||||
const devices = [
|
||||
{ id: "d1", userId: "u1", token: "token-1", platform: "android", createdAt: new Date("2024-01-01") },
|
||||
{ id: "d2", userId: "u1", token: "token-2", platform: "ios", createdAt: new Date("2024-01-02") },
|
||||
];
|
||||
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
orderBy: vi.fn().mockResolvedValue(devices),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { listDevices } = await import("./notification.service");
|
||||
const result = await listDevices("u1");
|
||||
|
||||
expect(result).toEqual(devices);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPreferences", () => {
|
||||
it("returns existing preferences from DB", async () => {
|
||||
const prefs = {
|
||||
id: "p1",
|
||||
userId: "u1",
|
||||
emailEnabled: false,
|
||||
pushEnabled: true,
|
||||
smsEnabled: false,
|
||||
};
|
||||
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([prefs]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { getPreferences } = await import("./notification.service");
|
||||
const result = await getPreferences("u1");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
emailEnabled: false,
|
||||
pushEnabled: true,
|
||||
smsEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns default preferences when no record exists", async () => {
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { getPreferences } = await import("./notification.service");
|
||||
const result = await getPreferences("u1");
|
||||
|
||||
expect(result).toEqual({
|
||||
emailEnabled: true,
|
||||
pushEnabled: true,
|
||||
smsEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePreferences", () => {
|
||||
it("updates existing preferences", async () => {
|
||||
const existing = {
|
||||
id: "p1",
|
||||
userId: "u1",
|
||||
emailEnabled: true,
|
||||
pushEnabled: true,
|
||||
smsEnabled: true,
|
||||
};
|
||||
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([existing]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const updated = { ...existing, smsEnabled: false };
|
||||
|
||||
(db.update as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
set: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([updated]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const { updatePreferences } = await import("./notification.service");
|
||||
const result = await updatePreferences("u1", { smsEnabled: false });
|
||||
|
||||
expect(result.smsEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("creates new preferences record when none exists", async () => {
|
||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const created = {
|
||||
id: "p-new",
|
||||
userId: "u1",
|
||||
emailEnabled: false,
|
||||
pushEnabled: true,
|
||||
smsEnabled: true,
|
||||
};
|
||||
|
||||
(db.insert as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
values: vi.fn().mockReturnValue({
|
||||
returning: vi.fn().mockResolvedValue([created]),
|
||||
}),
|
||||
});
|
||||
|
||||
const { updatePreferences } = await import("./notification.service");
|
||||
const result = await updatePreferences("u1", { emailEnabled: false });
|
||||
|
||||
expect(result).toEqual(created);
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { db } from "~/server/db";
|
||||
import { deviceTokens } from "~/server/db/schema/auth";
|
||||
import { notificationPreferences } from "~/server/db/schema/notifications";
|
||||
import { resend } from "~/server/lib/resend";
|
||||
import { messaging } from "~/server/lib/fcm";
|
||||
import { twilioClient } from "~/server/lib/twilio";
|
||||
|
||||
export async function sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
html: string,
|
||||
text?: string,
|
||||
) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn("[notifications] Resend not configured, skipping email");
|
||||
return { id: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL ?? "noreply@kordant.ai",
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text: text ?? "",
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("[notifications] Resend error:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to send email",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[notifications] Email sent:", data?.id);
|
||||
return { id: data?.id ?? null };
|
||||
} catch (err) {
|
||||
if (err instanceof TRPCError) throw err;
|
||||
console.error("[notifications] Email send error:", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to send email",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPush(
|
||||
userId: string,
|
||||
title: string,
|
||||
body: string,
|
||||
data?: Record<string, string>,
|
||||
) {
|
||||
const tokens = await db
|
||||
.select()
|
||||
.from(deviceTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(deviceTokens.userId, userId),
|
||||
eq(deviceTokens.isActive, true),
|
||||
),
|
||||
);
|
||||
|
||||
if (!tokens.length) {
|
||||
console.warn("[notifications] No active devices for user", userId);
|
||||
return { successCount: 0 };
|
||||
}
|
||||
|
||||
if (!messaging) {
|
||||
console.warn("[notifications] FCM not configured, skipping push");
|
||||
return { successCount: 0 };
|
||||
}
|
||||
|
||||
const tokenStrings = tokens.map((t) => t.token);
|
||||
let successCount = 0;
|
||||
|
||||
for (const token of tokenStrings) {
|
||||
try {
|
||||
await messaging.send({
|
||||
token,
|
||||
notification: { title, body },
|
||||
data,
|
||||
android: { priority: "high" },
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
alert: { title, body },
|
||||
sound: "default",
|
||||
badge: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
console.error("[notifications] Push send error for token:", err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[notifications] Push sent to", successCount, "/", tokens.length, "devices");
|
||||
return { successCount };
|
||||
}
|
||||
|
||||
export async function sendSMS(phoneNumber: string, message: string) {
|
||||
const e164Regex = /^\+[1-9]\d{6,14}$/;
|
||||
if (!e164Regex.test(phoneNumber)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Phone number must be in E.164 format (e.g. +1234567890)",
|
||||
});
|
||||
}
|
||||
|
||||
if (!twilioClient) {
|
||||
console.warn("[notifications] Twilio not configured, skipping SMS");
|
||||
return { sid: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await twilioClient.messages.create({
|
||||
body: message,
|
||||
to: phoneNumber,
|
||||
messagingServiceSid: process.env.TWILIO_MESSAGING_SERVICE_SID,
|
||||
});
|
||||
|
||||
console.log("[notifications] SMS sent:", result.sid);
|
||||
return { sid: result.sid };
|
||||
} catch (err) {
|
||||
console.error("[notifications] SMS send error:", err);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to send SMS",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerDevice(
|
||||
userId: string,
|
||||
token: string,
|
||||
platform: "ios" | "android" | "web",
|
||||
deviceType: "mobile" | "web" | "desktop",
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(deviceTokens)
|
||||
.where(eq(deviceTokens.token, token))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
if (existing.userId !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Device token already registered to another user",
|
||||
});
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(deviceTokens)
|
||||
.set({ isActive: true, lastUsedAt: new Date() })
|
||||
.where(eq(deviceTokens.id, existing.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(deviceTokens)
|
||||
.values({ userId, token, platform, deviceType })
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function unregisterDevice(userId: string, token: string) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(deviceTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(deviceTokens.token, token),
|
||||
eq(deviceTokens.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Device token not found",
|
||||
});
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(deviceTokens)
|
||||
.set({ isActive: false })
|
||||
.where(eq(deviceTokens.id, existing.id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function listDevices(userId: string) {
|
||||
const devices = await db
|
||||
.select()
|
||||
.from(deviceTokens)
|
||||
.where(eq(deviceTokens.userId, userId))
|
||||
.orderBy(deviceTokens.createdAt);
|
||||
|
||||
return devices;
|
||||
}
|
||||
|
||||
export async function getPreferences(userId: string) {
|
||||
const [prefs] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (!prefs) {
|
||||
return {
|
||||
emailEnabled: true,
|
||||
pushEnabled: true,
|
||||
smsEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
return prefs;
|
||||
}
|
||||
|
||||
export async function updatePreferences(
|
||||
userId: string,
|
||||
prefs: { emailEnabled?: boolean; pushEnabled?: boolean; smsEnabled?: boolean },
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(notificationPreferences)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(notificationPreferences)
|
||||
.set(prefs)
|
||||
.where(eq(notificationPreferences.userId, userId))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(notificationPreferences)
|
||||
.values({ userId, ...prefs })
|
||||
.returning();
|
||||
|
||||
return created;
|
||||
}
|
||||
@@ -17,67 +17,67 @@ const __dirname = path.dirname(__filename);
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface TextClassification {
|
||||
isSpam: boolean;
|
||||
confidence: number;
|
||||
score: number;
|
||||
modelVersion?: string;
|
||||
isSpam: boolean;
|
||||
confidence: number;
|
||||
score: number;
|
||||
modelVersion?: string;
|
||||
}
|
||||
|
||||
export interface ClassificationThresholds {
|
||||
strict: number; // 0.3 - flag more aggressively
|
||||
moderate: number; // 0.5 - balanced
|
||||
lenient: number; // 0.7 - fewer false positives
|
||||
strict: number; // 0.3 - flag more aggressively
|
||||
moderate: number; // 0.5 - balanced
|
||||
lenient: number; // 0.7 - fewer false positives
|
||||
}
|
||||
|
||||
export type ThresholdMode = "strict" | "moderate" | "lenient";
|
||||
|
||||
const DEFAULT_THRESHOLDS: ClassificationThresholds = {
|
||||
strict: 0.3,
|
||||
moderate: 0.5,
|
||||
lenient: 0.7,
|
||||
strict: 0.3,
|
||||
moderate: 0.5,
|
||||
lenient: 0.7,
|
||||
};
|
||||
|
||||
// ── Model Singleton ────────────────────────────────────────────────────────
|
||||
|
||||
interface ModelState {
|
||||
session: InferenceSession | null;
|
||||
tokenizer: BertTokenizer;
|
||||
metadata: ModelMetadata;
|
||||
loaded: boolean;
|
||||
loadError: Error | null;
|
||||
session: InferenceSession | null;
|
||||
tokenizer: BertTokenizer;
|
||||
metadata: ModelMetadata;
|
||||
loaded: boolean;
|
||||
loadError: Error | null;
|
||||
}
|
||||
|
||||
interface ModelMetadata {
|
||||
version: string;
|
||||
model_name: string;
|
||||
task: string;
|
||||
max_length: number;
|
||||
num_labels: number;
|
||||
label2id: Record<string, number>;
|
||||
id2label: Record<number, string>;
|
||||
version: string;
|
||||
model_name: string;
|
||||
task: string;
|
||||
max_length: number;
|
||||
num_labels: number;
|
||||
label2id: Record<string, number>;
|
||||
id2label: Record<number, string>;
|
||||
}
|
||||
|
||||
const modelState: ModelState = {
|
||||
session: null,
|
||||
tokenizer: null as unknown as BertTokenizer,
|
||||
metadata: {
|
||||
version: "0.0.0",
|
||||
model_name: "",
|
||||
task: "",
|
||||
max_length: 128,
|
||||
num_labels: 2,
|
||||
label2id: {},
|
||||
id2label: {},
|
||||
},
|
||||
loaded: false,
|
||||
loadError: null,
|
||||
session: null,
|
||||
tokenizer: null as unknown as BertTokenizer,
|
||||
metadata: {
|
||||
version: "0.0.0",
|
||||
model_name: "",
|
||||
task: "",
|
||||
max_length: 128,
|
||||
num_labels: 2,
|
||||
label2id: {},
|
||||
id2label: {},
|
||||
},
|
||||
loaded: false,
|
||||
loadError: null,
|
||||
};
|
||||
|
||||
// ── Result Cache ───────────────────────────────────────────────────────────
|
||||
|
||||
interface CacheEntry {
|
||||
result: TextClassification;
|
||||
timestamp: number;
|
||||
result: TextClassification;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const resultCache = new Map<string, CacheEntry>();
|
||||
@@ -85,305 +85,471 @@ const CACHE_MAX_SIZE = 1000;
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
function cacheKey(text: string): string {
|
||||
// Simple hash of normalized text
|
||||
const normalized = text.toLowerCase().trim();
|
||||
let hash = 0;
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
const char = normalized.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
return String(hash);
|
||||
// Simple hash of normalized text
|
||||
const normalized = text.toLowerCase().trim();
|
||||
let hash = 0;
|
||||
for (let i = 0; i < normalized.length; i++) {
|
||||
const char = normalized.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash |= 0; // Convert to 32bit integer
|
||||
}
|
||||
return String(hash);
|
||||
}
|
||||
|
||||
function getCached(text: string): TextClassification | null {
|
||||
const key = cacheKey(text);
|
||||
const entry = resultCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
|
||||
resultCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.result;
|
||||
const key = cacheKey(text);
|
||||
const entry = resultCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
|
||||
resultCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
function setCache(text: string, result: TextClassification): void {
|
||||
if (resultCache.size >= CACHE_MAX_SIZE) {
|
||||
// Evict oldest entry
|
||||
const oldestKey = resultCache.keys().next().value;
|
||||
resultCache.delete(oldestKey);
|
||||
}
|
||||
resultCache.set(cacheKey(text), { result, timestamp: Date.now() });
|
||||
if (resultCache.size >= CACHE_MAX_SIZE) {
|
||||
// Evict oldest entry
|
||||
const oldestKey = resultCache.keys().next().value;
|
||||
resultCache.delete(oldestKey);
|
||||
}
|
||||
resultCache.set(cacheKey(text), { result, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
// ── BertTokenizer (JavaScript implementation) ──────────────────────────────
|
||||
|
||||
interface TokenizerConfig {
|
||||
vocab: Map<string, number>;
|
||||
inv_vocab: Map<number, string>;
|
||||
max_len: number;
|
||||
do_lower_case: boolean;
|
||||
tokenizers: Record<string, unknown>;
|
||||
model_max_length: number;
|
||||
vocab: Map<string, number>;
|
||||
inv_vocab: Map<number, string>;
|
||||
max_len: number;
|
||||
do_lower_case: boolean;
|
||||
tokenizers: Record<string, unknown>;
|
||||
model_max_length: number;
|
||||
}
|
||||
|
||||
class BertTokenizer {
|
||||
private config: TokenizerConfig;
|
||||
private config: TokenizerConfig;
|
||||
|
||||
constructor(configPath: string) {
|
||||
this.config = this.loadConfig(configPath);
|
||||
}
|
||||
constructor(configPath: string) {
|
||||
this.config = this.loadConfig(configPath);
|
||||
}
|
||||
|
||||
private loadConfig(configPath: string): TokenizerConfig {
|
||||
const vocabPath = path.join(configPath, "vocab.txt");
|
||||
const tokenizerConfigPath = path.join(configPath, "tokenizer_config.json");
|
||||
private loadConfig(configPath: string): TokenizerConfig {
|
||||
const vocabPath = path.join(configPath, "vocab.txt");
|
||||
const tokenizerConfigPath = path.join(configPath, "tokenizer_config.json");
|
||||
|
||||
// Load vocabulary
|
||||
const vocab = new Map<string, number>();
|
||||
const inv_vocab = new Map<number, string>();
|
||||
const vocabText = fs.readFileSync(vocabPath, "utf-8");
|
||||
const lines = vocabText.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const token = lines[i].trim();
|
||||
if (token) {
|
||||
vocab.set(token, i);
|
||||
inv_vocab.set(i, token);
|
||||
}
|
||||
}
|
||||
// Load vocabulary
|
||||
const vocab = new Map<string, number>();
|
||||
const inv_vocab = new Map<number, string>();
|
||||
const vocabText = fs.readFileSync(vocabPath, "utf-8");
|
||||
const lines = vocabText.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const token = lines[i].trim();
|
||||
if (token) {
|
||||
vocab.set(token, i);
|
||||
inv_vocab.set(i, token);
|
||||
}
|
||||
}
|
||||
|
||||
// Load tokenizer config
|
||||
let doLowercase = true;
|
||||
let modelMaxLength = 512;
|
||||
try {
|
||||
const configData = JSON.parse(fs.readFileSync(tokenizerConfigPath, "utf-8"));
|
||||
doLowercase = configData.do_lower_case ?? true;
|
||||
modelMaxLength = configData.model_max_length ?? 512;
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
// Load tokenizer config
|
||||
let doLowercase = true;
|
||||
let modelMaxLength = 512;
|
||||
try {
|
||||
const configData = JSON.parse(
|
||||
fs.readFileSync(tokenizerConfigPath, "utf-8"),
|
||||
);
|
||||
doLowercase = configData.do_lower_case ?? true;
|
||||
modelMaxLength = configData.model_max_length ?? 512;
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
|
||||
return {
|
||||
vocab,
|
||||
inv_vocab,
|
||||
max_len: 512,
|
||||
do_lower_case: doLowercase,
|
||||
tokenizers: {},
|
||||
model_max_length: modelMaxLength,
|
||||
};
|
||||
}
|
||||
return {
|
||||
vocab,
|
||||
inv_vocab,
|
||||
max_len: 512,
|
||||
do_lower_case: doLowercase,
|
||||
tokenizers: {},
|
||||
model_max_length: modelMaxLength,
|
||||
};
|
||||
}
|
||||
|
||||
private whitespace_tokenize(text: string): string[] {
|
||||
if (this.config.do_lower_case) {
|
||||
text = text.toLowerCase();
|
||||
}
|
||||
// Split on whitespace, keeping punctuation attached
|
||||
return text.split(/\s+/).filter((t) => t.length > 0);
|
||||
}
|
||||
private whitespace_tokenize(text: string): string[] {
|
||||
if (this.config.do_lower_case) {
|
||||
text = text.toLowerCase();
|
||||
}
|
||||
// Split on whitespace, keeping punctuation attached
|
||||
return text.split(/\s+/).filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
private wordpiece_tokenize(token: string, maxOutputTokens: number = 20): string[] {
|
||||
const outputTokens: string[] = [];
|
||||
let isBad = false;
|
||||
let start = 0;
|
||||
let subToken: string | null = null;
|
||||
private wordpiece_tokenize(
|
||||
token: string,
|
||||
maxOutputTokens: number = 20,
|
||||
): string[] {
|
||||
const outputTokens: string[] = [];
|
||||
let isBad = false;
|
||||
let start = 0;
|
||||
let subToken: string | null = null;
|
||||
|
||||
while (start < token.length && !isBad && outputTokens.length < maxOutputTokens) {
|
||||
let found = false;
|
||||
while (
|
||||
start < token.length &&
|
||||
!isBad &&
|
||||
outputTokens.length < maxOutputTokens
|
||||
) {
|
||||
let found = false;
|
||||
|
||||
for (let end = token.length; end > start; end--) {
|
||||
let substr = token.substring(start, end);
|
||||
if (start > 0) {
|
||||
substr = "##" + substr;
|
||||
}
|
||||
for (let end = token.length; end > start; end--) {
|
||||
let substr = token.substring(start, end);
|
||||
if (start > 0) {
|
||||
substr = "##" + substr;
|
||||
}
|
||||
|
||||
if (this.config.vocab.has(substr)) {
|
||||
outputTokens.push(substr);
|
||||
subToken = substr;
|
||||
start = end;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (this.config.vocab.has(substr)) {
|
||||
outputTokens.push(substr);
|
||||
subToken = substr;
|
||||
start = end;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
isBad = true;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
isBad = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBad) {
|
||||
outputTokens.push("[UNK]");
|
||||
} else if (subToken === null) {
|
||||
outputTokens.push("[UNK]");
|
||||
}
|
||||
if (isBad) {
|
||||
outputTokens.push("[UNK]");
|
||||
} else if (subToken === null) {
|
||||
outputTokens.push("[UNK]");
|
||||
}
|
||||
|
||||
return outputTokens;
|
||||
}
|
||||
return outputTokens;
|
||||
}
|
||||
|
||||
private tokenize(text: string): string[] {
|
||||
const tokens = [];
|
||||
const whitespaceTokens = this.whitespace_tokenize(text);
|
||||
private tokenize(text: string): string[] {
|
||||
const tokens = [];
|
||||
const whitespaceTokens = this.whitespace_tokenize(text);
|
||||
|
||||
for (const token of whitespaceTokens) {
|
||||
const subTokens = this.wordpiece_tokenize(token);
|
||||
tokens.push(...subTokens);
|
||||
}
|
||||
for (const token of whitespaceTokens) {
|
||||
const subTokens = this.wordpiece_tokenize(token);
|
||||
tokens.push(...subTokens);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
encode(text: string, maxLen: number = 128): { inputIds: number[]; attentionMask: number[] } {
|
||||
const tokens = this.tokenize(text);
|
||||
encode(
|
||||
text: string,
|
||||
maxLen: number = 128,
|
||||
): { inputIds: number[]; attentionMask: number[] } {
|
||||
const tokens = this.tokenize(text);
|
||||
|
||||
// Add [CLS] and [SEP]
|
||||
const allTokens = ["[CLS]", ...tokens.slice(0, maxLen - 2), "[SEP]"];
|
||||
// Add [CLS] and [SEP]
|
||||
const allTokens = ["[CLS]", ...tokens.slice(0, maxLen - 2), "[SEP]"];
|
||||
|
||||
const inputIds = allTokens.map((t) => this.config.vocab.get(t) ?? 100); // 100 = [UNK]
|
||||
const attentionMask = new Array(inputIds.length).fill(1);
|
||||
const inputIds = allTokens.map((t) => this.config.vocab.get(t) ?? 100); // 100 = [UNK]
|
||||
const attentionMask = new Array(inputIds.length).fill(1);
|
||||
|
||||
// Pad to maxLen if needed
|
||||
while (inputIds.length < maxLen) {
|
||||
inputIds.push(0);
|
||||
attentionMask.push(0);
|
||||
}
|
||||
// Pad to maxLen if needed
|
||||
while (inputIds.length < maxLen) {
|
||||
inputIds.push(0);
|
||||
attentionMask.push(0);
|
||||
}
|
||||
|
||||
return { inputIds, attentionMask };
|
||||
}
|
||||
return { inputIds, attentionMask };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Model Loading ──────────────────────────────────────────────────────────
|
||||
|
||||
const MODEL_DIR_ENV = "SPAM_MODEL_DIR";
|
||||
const DEFAULT_MODEL_DIR = path.join(__dirname, "..", "..", "models", "spam-classifier");
|
||||
const DEFAULT_MODEL_DIR = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"models",
|
||||
"spam-classifier",
|
||||
);
|
||||
|
||||
function getModelDir(): string {
|
||||
return process.env[MODEL_DIR_ENV] || DEFAULT_MODEL_DIR;
|
||||
return process.env[MODEL_DIR_ENV] || DEFAULT_MODEL_DIR;
|
||||
}
|
||||
|
||||
// ── Remote Model Download ────────────────────────────────────────────────────
|
||||
|
||||
const MODEL_DOWNLOAD_URL_ENV = "SPAM_MODEL_URL_BASE";
|
||||
|
||||
/** Model files that need to be available in the model directory. */
|
||||
const MODEL_FILES = [
|
||||
"model.onnx",
|
||||
"model.onnx.data",
|
||||
"tokenizer.json",
|
||||
"vocab.txt",
|
||||
"tokenizer_config.json",
|
||||
"special_tokens_map.json",
|
||||
"model_metadata.json",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Check if all required model files exist in the given directory.
|
||||
*/
|
||||
function modelFilesExist(dir: string): boolean {
|
||||
try {
|
||||
return MODEL_FILES.every((f) => fs.existsSync(path.join(dir, f)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single model file from a remote URL to a local path.
|
||||
* Uses streaming to handle large files (e.g., model.onnx.data at 255MB).
|
||||
*/
|
||||
async function downloadModelFile(url: string, destPath: string): Promise<void> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to download ${url}: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
throw new Error(`No response body stream for ${url}`);
|
||||
}
|
||||
|
||||
// Ensure parent directory exists
|
||||
const dir = path.dirname(destPath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
// Stream to file
|
||||
const writer = fs.createWriteStream(destPath);
|
||||
try {
|
||||
let totalBytes = 0;
|
||||
let lastLog = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
writer.write(value);
|
||||
totalBytes += value.length;
|
||||
|
||||
// Log progress every ~10MB
|
||||
if (totalBytes - lastLog > 10 * 1024 * 1024) {
|
||||
lastLog = totalBytes;
|
||||
const mb = (totalBytes / (1024 * 1024)).toFixed(1);
|
||||
console.log(
|
||||
`[spamshield] Downloaded ${path.basename(destPath)}: ${mb}MB`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
writer.end();
|
||||
await new Promise<void>((resolve) => writer.on("finish", resolve));
|
||||
}
|
||||
|
||||
const totalMB = (fs.statSync(destPath).size / (1024 * 1024)).toFixed(1);
|
||||
console.log(
|
||||
`[spamshield] Downloaded ${path.basename(destPath)} (${totalMB}MB)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all model files from a remote URL base to the model directory.
|
||||
* Falls back gracefully — if the URL is not configured, returns false.
|
||||
*/
|
||||
async function downloadModelIfMissing(modelDir: string): Promise<boolean> {
|
||||
// If model files already exist locally, nothing to do
|
||||
if (modelFilesExist(modelDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const baseUrl = process.env[MODEL_DOWNLOAD_URL_ENV];
|
||||
if (!baseUrl) {
|
||||
console.log(
|
||||
"[spamshield] Model files not found locally and SPAM_MODEL_URL_BASE not set — " +
|
||||
"will use rule-engine fallback",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
console.log(`[spamshield] Downloading model from: ${normalizedBase}`);
|
||||
|
||||
// Ensure model directory exists
|
||||
fs.mkdirSync(modelDir, { recursive: true });
|
||||
|
||||
// Track which files we already have (for caching across cold starts)
|
||||
const existing = new Set<string>();
|
||||
for (const file of MODEL_FILES) {
|
||||
const filePath = path.join(modelDir, file);
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).size > 0) {
|
||||
existing.add(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Download missing files
|
||||
for (const file of MODEL_FILES) {
|
||||
if (existing.has(file)) {
|
||||
console.log(`[spamshield] Already have ${file}, skipping download`);
|
||||
continue;
|
||||
}
|
||||
const url = `${normalizedBase}${file}`;
|
||||
const destPath = path.join(modelDir, file);
|
||||
console.log(`[spamshield] Downloading ${file}...`);
|
||||
try {
|
||||
await downloadModelFile(url, destPath);
|
||||
} catch (err) {
|
||||
console.error(`[spamshield] Failed to download ${file}:`, err);
|
||||
// If the main model files fail, we can't use the model
|
||||
if (file === "model.onnx" || file === "model.onnx.data") {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modelFilesExist(modelDir);
|
||||
}
|
||||
|
||||
async function loadModel(): Promise<void> {
|
||||
if (modelState.loaded) return;
|
||||
if (modelState.loaded) return;
|
||||
|
||||
try {
|
||||
const modelDir = getModelDir();
|
||||
console.log(`[spamshield] Loading ONNX model from: ${modelDir}`);
|
||||
try {
|
||||
const modelDir = getModelDir();
|
||||
console.log(`[spamshield] Loading ONNX model from: ${modelDir}`);
|
||||
|
||||
// Load metadata
|
||||
const metadataPath = path.join(modelDir, "model_metadata.json");
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
modelState.metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
||||
console.log(`[spamshield] Model version: ${modelState.metadata.version}`);
|
||||
}
|
||||
// Download model files if missing (production/Vercel path)
|
||||
await downloadModelIfMissing(modelDir);
|
||||
|
||||
// Load tokenizer
|
||||
modelState.tokenizer = new BertTokenizer(modelDir);
|
||||
console.log("[spamshield] Tokenizer loaded");
|
||||
// Load metadata
|
||||
const metadataPath = path.join(modelDir, "model_metadata.json");
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
modelState.metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
|
||||
console.log(`[spamshield] Model version: ${modelState.metadata.version}`);
|
||||
}
|
||||
|
||||
// Load ONNX model
|
||||
const modelPath = path.join(modelDir, "model.onnx");
|
||||
if (!fs.existsSync(modelPath)) {
|
||||
// Check for external data file
|
||||
const modelDataPath = path.join(modelDir, "model.onnx.data");
|
||||
if (!fs.existsSync(modelDataPath)) {
|
||||
throw new Error(`ONNX model not found at ${modelPath}`);
|
||||
}
|
||||
}
|
||||
// Load tokenizer
|
||||
modelState.tokenizer = new BertTokenizer(modelDir);
|
||||
console.log("[spamshield] Tokenizer loaded");
|
||||
|
||||
modelState.session = await ort.InferenceSession.create(modelPath);
|
||||
console.log("[spamshield] ONNX session created");
|
||||
console.log(`[spamshield] Inputs: ${modelState.session.inputNames.join(", ")}`);
|
||||
console.log(`[spamshield] Outputs: ${modelState.session.outputNames.join(", ")}`);
|
||||
// Load ONNX model
|
||||
const modelPath = path.join(modelDir, "model.onnx");
|
||||
if (!fs.existsSync(modelPath)) {
|
||||
// Check for external data file
|
||||
const modelDataPath = path.join(modelDir, "model.onnx.data");
|
||||
if (!fs.existsSync(modelDataPath)) {
|
||||
throw new Error(`ONNX model not found at ${modelPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
modelState.loaded = true;
|
||||
console.log("[spamshield] Model loaded successfully");
|
||||
} catch (err) {
|
||||
modelState.loadError = err instanceof Error ? err : new Error(String(err));
|
||||
console.error("[spamshield] Failed to load ONNX model:", modelState.loadError);
|
||||
console.log("[spamshield] Falling back to rule engine for classification");
|
||||
}
|
||||
modelState.session = await ort.InferenceSession.create(modelPath);
|
||||
console.log("[spamshield] ONNX session created");
|
||||
console.log(
|
||||
`[spamshield] Inputs: ${modelState.session.inputNames.join(", ")}`,
|
||||
);
|
||||
console.log(
|
||||
`[spamshield] Outputs: ${modelState.session.outputNames.join(", ")}`,
|
||||
);
|
||||
|
||||
modelState.loaded = true;
|
||||
console.log("[spamshield] Model loaded successfully");
|
||||
} catch (err) {
|
||||
modelState.loadError = err instanceof Error ? err : new Error(String(err));
|
||||
console.error(
|
||||
"[spamshield] Failed to load ONNX model:",
|
||||
modelState.loadError,
|
||||
);
|
||||
console.log("[spamshield] Falling back to rule engine for classification");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Inference ──────────────────────────────────────────────────────────────
|
||||
|
||||
function sigmoid(x: number): number {
|
||||
return 1 / (1 + Math.exp(-x));
|
||||
return 1 / (1 + Math.exp(-x));
|
||||
}
|
||||
|
||||
async function runInference(
|
||||
text: string,
|
||||
thresholdMode: ThresholdMode = "moderate",
|
||||
text: string,
|
||||
thresholdMode: ThresholdMode = "moderate",
|
||||
): Promise<TextClassification> {
|
||||
const thresholds = DEFAULT_THRESHOLDS;
|
||||
const threshold = thresholds[thresholdMode];
|
||||
const thresholds = DEFAULT_THRESHOLDS;
|
||||
const threshold = thresholds[thresholdMode];
|
||||
|
||||
// Check cache first
|
||||
const cached = getCached(text);
|
||||
if (cached) {
|
||||
return { ...cached, modelVersion: modelState.metadata.version };
|
||||
}
|
||||
// Check cache first
|
||||
const cached = getCached(text);
|
||||
if (cached) {
|
||||
return { ...cached, modelVersion: modelState.metadata.version };
|
||||
}
|
||||
|
||||
// Ensure model is loaded
|
||||
if (!modelState.loaded || !modelState.session) {
|
||||
await loadModel();
|
||||
}
|
||||
// Ensure model is loaded
|
||||
if (!modelState.loaded || !modelState.session) {
|
||||
await loadModel();
|
||||
}
|
||||
|
||||
// If model still not loaded, return fallback
|
||||
if (!modelState.loaded || !modelState.session) {
|
||||
const fallback: TextClassification = {
|
||||
isSpam: false,
|
||||
confidence: 0,
|
||||
score: 0,
|
||||
modelVersion: "fallback",
|
||||
};
|
||||
setCache(text, fallback);
|
||||
return fallback;
|
||||
}
|
||||
// If model still not loaded, return fallback
|
||||
if (!modelState.loaded || !modelState.session) {
|
||||
const fallback: TextClassification = {
|
||||
isSpam: false,
|
||||
confidence: 0,
|
||||
score: 0,
|
||||
modelVersion: "fallback",
|
||||
};
|
||||
setCache(text, fallback);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Tokenize
|
||||
const maxLen = modelState.metadata.max_length || 128;
|
||||
const { inputIds, attentionMask } = modelState.tokenizer.encode(text, maxLen);
|
||||
// Tokenize
|
||||
const maxLen = modelState.metadata.max_length || 128;
|
||||
const { inputIds, attentionMask } = modelState.tokenizer.encode(text, maxLen);
|
||||
|
||||
// Create ONNX tensors (int64 requires BigInt values)
|
||||
const inputIdsBigInt = new BigInt64Array(inputIds.length);
|
||||
for (let i = 0; i < inputIds.length; i++) {
|
||||
inputIdsBigInt[i] = BigInt(inputIds[i]);
|
||||
}
|
||||
const attentionMaskBigInt = new BigInt64Array(attentionMask.length);
|
||||
for (let i = 0; i < attentionMask.length; i++) {
|
||||
attentionMaskBigInt[i] = BigInt(attentionMask[i]);
|
||||
}
|
||||
// Create ONNX tensors (int64 requires BigInt values)
|
||||
const inputIdsBigInt = new BigInt64Array(inputIds.length);
|
||||
for (let i = 0; i < inputIds.length; i++) {
|
||||
inputIdsBigInt[i] = BigInt(inputIds[i]);
|
||||
}
|
||||
const attentionMaskBigInt = new BigInt64Array(attentionMask.length);
|
||||
for (let i = 0; i < attentionMask.length; i++) {
|
||||
attentionMaskBigInt[i] = BigInt(attentionMask[i]);
|
||||
}
|
||||
|
||||
const inputIdsTensor = new ort.Tensor("int64", inputIdsBigInt, [1, maxLen]);
|
||||
const attentionMaskTensor = new ort.Tensor("int64", attentionMaskBigInt, [1, maxLen]);
|
||||
const inputIdsTensor = new ort.Tensor("int64", inputIdsBigInt, [1, maxLen]);
|
||||
const attentionMaskTensor = new ort.Tensor("int64", attentionMaskBigInt, [
|
||||
1,
|
||||
maxLen,
|
||||
]);
|
||||
|
||||
// Run inference
|
||||
const feeds: Record<string, Tensor> = {
|
||||
input_ids: inputIdsTensor,
|
||||
attention_mask: attentionMaskTensor,
|
||||
};
|
||||
// Run inference
|
||||
const feeds: Record<string, Tensor> = {
|
||||
input_ids: inputIdsTensor,
|
||||
attention_mask: attentionMaskTensor,
|
||||
};
|
||||
|
||||
const outputs = await modelState.session.run(feeds);
|
||||
const logits = outputs[modelState.session.outputNames[0]];
|
||||
const outputs = await modelState.session.run(feeds);
|
||||
const logits = outputs[modelState.session.outputNames[0]];
|
||||
|
||||
// Extract logits (shape: [1, num_labels])
|
||||
const logitsData = logits.data as Float32Array | number[];
|
||||
const spamLogit = logitsData[1] ?? 0;
|
||||
const hamLogit = logitsData[0] ?? 0;
|
||||
// Extract logits (shape: [1, num_labels])
|
||||
const logitsData = logits.data as Float32Array | number[];
|
||||
const spamLogit = logitsData[1] ?? 0;
|
||||
const hamLogit = logitsData[0] ?? 0;
|
||||
|
||||
// Apply sigmoid to get probability
|
||||
const spamProb = sigmoid(spamLogit);
|
||||
const hamProb = sigmoid(hamLogit);
|
||||
// Apply sigmoid to get probability
|
||||
const spamProb = sigmoid(spamLogit);
|
||||
const hamProb = sigmoid(hamLogit);
|
||||
|
||||
// Binary decision based on threshold
|
||||
const isSpam = spamProb >= threshold;
|
||||
const confidence = isSpam ? spamProb : 1 - spamProb;
|
||||
// Binary decision based on threshold
|
||||
const isSpam = spamProb >= threshold;
|
||||
const confidence = isSpam ? spamProb : 1 - spamProb;
|
||||
|
||||
const result: TextClassification = {
|
||||
isSpam,
|
||||
confidence: Math.round(confidence * 10000) / 10000,
|
||||
score: Math.round(spamProb * 10000) / 10000,
|
||||
modelVersion: modelState.metadata.version,
|
||||
};
|
||||
const result: TextClassification = {
|
||||
isSpam,
|
||||
confidence: Math.round(confidence * 10000) / 10000,
|
||||
score: Math.round(spamProb * 10000) / 10000,
|
||||
modelVersion: modelState.metadata.version,
|
||||
};
|
||||
|
||||
setCache(text, result);
|
||||
return result;
|
||||
setCache(text, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
@@ -393,21 +559,21 @@ async function runInference(
|
||||
* Falls back to returning a safe default if the model fails to load.
|
||||
*/
|
||||
export async function classifyTextBERT(
|
||||
text: string,
|
||||
thresholdMode: ThresholdMode = "moderate",
|
||||
text: string,
|
||||
thresholdMode: ThresholdMode = "moderate",
|
||||
): Promise<TextClassification> {
|
||||
try {
|
||||
return await runInference(text, thresholdMode);
|
||||
} catch (err) {
|
||||
console.error("[spamshield] ONNX inference error:", err);
|
||||
// Graceful fallback: return non-spam with low confidence
|
||||
return {
|
||||
isSpam: false,
|
||||
confidence: 0,
|
||||
score: 0,
|
||||
modelVersion: "error",
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await runInference(text, thresholdMode);
|
||||
} catch (err) {
|
||||
console.error("[spamshield] ONNX inference error:", err);
|
||||
// Graceful fallback: return non-spam with low confidence
|
||||
return {
|
||||
isSpam: false,
|
||||
confidence: 0,
|
||||
score: 0,
|
||||
modelVersion: "error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -415,41 +581,41 @@ export async function classifyTextBERT(
|
||||
* Call this once during server initialization.
|
||||
*/
|
||||
export async function initSpamModel(): Promise<boolean> {
|
||||
await loadModel();
|
||||
return modelState.loaded;
|
||||
await loadModel();
|
||||
return modelState.loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the model is loaded and ready.
|
||||
*/
|
||||
export function isModelLoaded(): boolean {
|
||||
return modelState.loaded && modelState.session !== null;
|
||||
return modelState.loaded && modelState.session !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model metadata.
|
||||
*/
|
||||
export function getModelInfo(): ModelMetadata {
|
||||
return { ...modelState.metadata };
|
||||
return { ...modelState.metadata };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current cache stats.
|
||||
*/
|
||||
export function getCacheStats(): { size: number; max: number } {
|
||||
return { size: resultCache.size, max: CACHE_MAX_SIZE };
|
||||
return { size: resultCache.size, max: CACHE_MAX_SIZE };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the result cache.
|
||||
*/
|
||||
export function clearCache(): void {
|
||||
resultCache.clear();
|
||||
resultCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available threshold modes and their values.
|
||||
*/
|
||||
export function getThresholds(): ClassificationThresholds {
|
||||
return { ...DEFAULT_THRESHOLDS };
|
||||
return { ...DEFAULT_THRESHOLDS };
|
||||
}
|
||||
|
||||
@@ -8,539 +8,417 @@ import { createSession } from "~/server/auth/session";
|
||||
import { signJWT } from "~/server/auth/jwt";
|
||||
|
||||
export async function createUserWithPassword(
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
name: string,
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email already in use",
|
||||
});
|
||||
}
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email already in use",
|
||||
});
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ name, email, passwordHash })
|
||||
.returning();
|
||||
const passwordHash = await hashPassword(password);
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ name, email, passwordHash })
|
||||
.returning();
|
||||
|
||||
const session = await createSession(user.id);
|
||||
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
|
||||
const session = await createSession(user.id);
|
||||
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
|
||||
|
||||
return { user, sessionToken: session.sessionToken, accessToken };
|
||||
return { user, sessionToken: session.sessionToken, accessToken };
|
||||
}
|
||||
|
||||
export async function authenticateUser(
|
||||
email: string,
|
||||
password: string,
|
||||
) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
export async function authenticateUser(email: string, password: string) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid email or password",
|
||||
});
|
||||
}
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid email or password",
|
||||
});
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid email or password",
|
||||
});
|
||||
}
|
||||
const valid = await verifyPassword(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid email or password",
|
||||
});
|
||||
}
|
||||
|
||||
const session = await createSession(user.id);
|
||||
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
|
||||
return { user, sessionToken: session.sessionToken, accessToken };
|
||||
const session = await createSession(user.id);
|
||||
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
|
||||
return { user, sessionToken: session.sessionToken, accessToken };
|
||||
}
|
||||
|
||||
const GOOGLE_ISSUER = "https://accounts.google.com";
|
||||
const APPLE_ISSUER = "https://appleid.apple.com";
|
||||
const APPLE_JWKS_URL = new URL("https://appleid.apple.com/auth/keys");
|
||||
|
||||
/**
|
||||
* Verifies a Google ID token using firebase-admin and returns the user.
|
||||
* If the user does not exist, creates a new account.
|
||||
* If the user exists but has not linked Google, links the provider.
|
||||
*/
|
||||
export async function authenticateWithGoogle(idToken: string) {
|
||||
const { initializeApp, cert, getApps } = await import("firebase-admin/app");
|
||||
|
||||
// Initialize Firebase Admin if not already done
|
||||
if (getApps().length === 0) {
|
||||
// Try to load from environment or use application default credentials
|
||||
const projectId = process.env.FIREBASE_PROJECT_ID;
|
||||
const clientEmail = process.env.FIREBASE_CLIENT_EMAIL;
|
||||
const privateKey = process.env.FIREBASE_PRIVATE_KEY;
|
||||
|
||||
if (projectId && clientEmail && privateKey) {
|
||||
initializeApp({
|
||||
credential: cert({
|
||||
projectId,
|
||||
clientEmail,
|
||||
privateKey: privateKey.replace(/\\n/g, "\n"),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
// Fall back to application default credentials
|
||||
initializeApp({ projectId: projectId ?? "kordant" });
|
||||
}
|
||||
}
|
||||
|
||||
let decodedToken: { uid: string; email?: string; name?: string; picture?: string };
|
||||
try {
|
||||
const authModule = await import("firebase-admin/auth");
|
||||
decodedToken = await authModule.getAuth().verifyIdToken(idToken);
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Google ID token",
|
||||
});
|
||||
}
|
||||
|
||||
const googleUserId = decodedToken.uid;
|
||||
const email = decodedToken.email;
|
||||
const name = decodedToken.name ?? email?.split("@")[0] ?? "User";
|
||||
const avatarUrl = decodedToken.picture ?? null;
|
||||
|
||||
if (!email) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Google account has no email address",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this Google account is already linked
|
||||
const [existingAccount] = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.provider, "google"),
|
||||
eq(accounts.providerAccountId, googleUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
let userId: string;
|
||||
let isNewUser = false;
|
||||
|
||||
if (existingAccount) {
|
||||
// Already linked — use the existing user
|
||||
userId = existingAccount.userId;
|
||||
isNewUser = false;
|
||||
|
||||
// Update the access token if provided
|
||||
await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
accessToken: idToken,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(accounts.id, existingAccount.id));
|
||||
} else {
|
||||
// Not linked — check if a user with this email exists
|
||||
const [existingUserByEmail] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (existingUserByEmail) {
|
||||
// Link Google to existing user
|
||||
userId = existingUserByEmail.id;
|
||||
isNewUser = false;
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "google",
|
||||
providerAccountId: googleUserId,
|
||||
accessToken: idToken,
|
||||
});
|
||||
|
||||
// Update avatar if not set
|
||||
if (!existingUserByEmail.image && avatarUrl) {
|
||||
await db.update(users).set({ image: avatarUrl }).where(eq(users.id, userId));
|
||||
}
|
||||
} else {
|
||||
// Create new user with Google
|
||||
isNewUser = true;
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name,
|
||||
email,
|
||||
image: avatarUrl,
|
||||
emailVerified: new Date(),
|
||||
})
|
||||
.returning();
|
||||
userId = newUser.id;
|
||||
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "google",
|
||||
providerAccountId: googleUserId,
|
||||
accessToken: idToken,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create session and JWT
|
||||
const session = await createSession(userId);
|
||||
const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||
const refreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found after creation" });
|
||||
}
|
||||
|
||||
return { user, sessionToken: session.sessionToken, accessToken, refreshToken, isNewUser };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an Apple identity token and authenticates the user.
|
||||
* If the user does not exist, creates a new account.
|
||||
* If the user exists but has not linked Apple, links the provider.
|
||||
*/
|
||||
export async function authenticateWithApple(
|
||||
identityToken: string,
|
||||
authorizationCode: string,
|
||||
userIdentifier?: string | null,
|
||||
identityToken: string,
|
||||
authorizationCode: string,
|
||||
userIdentifier?: string | null,
|
||||
) {
|
||||
if (!identityToken) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Missing identity token",
|
||||
});
|
||||
}
|
||||
if (!identityToken) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Missing identity token",
|
||||
});
|
||||
}
|
||||
|
||||
// Verify Apple ID token using Apple's JWKS
|
||||
let payload: { sub: string; email?: string; is_private_email?: string; };
|
||||
try {
|
||||
const JWKS = createRemoteJWKSet(APPLE_JWKS_URL);
|
||||
const result = await jwtVerify(identityToken, JWKS, {
|
||||
issuer: APPLE_ISSUER,
|
||||
audience: process.env.IOS_BUNDLE_ID ?? "com.frenocorp.kordant",
|
||||
});
|
||||
payload = result.payload as unknown as { sub: string; email?: string; is_private_email?: string; };
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Apple identity token",
|
||||
});
|
||||
}
|
||||
// Verify Apple ID token using Apple's JWKS
|
||||
let payload: { sub: string; email?: string; is_private_email?: string };
|
||||
try {
|
||||
const JWKS = createRemoteJWKSet(APPLE_JWKS_URL);
|
||||
const result = await jwtVerify(identityToken, JWKS, {
|
||||
issuer: APPLE_ISSUER,
|
||||
audience: process.env.IOS_BUNDLE_ID ?? "com.frenocorp.kordant",
|
||||
});
|
||||
payload = result.payload as unknown as {
|
||||
sub: string;
|
||||
email?: string;
|
||||
is_private_email?: string;
|
||||
};
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid Apple identity token",
|
||||
});
|
||||
}
|
||||
|
||||
const appleUserId = payload.sub;
|
||||
const email = payload.email ?? null;
|
||||
const appleUserId = payload.sub;
|
||||
const email = payload.email ?? null;
|
||||
|
||||
if (!email) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Apple account has no email address",
|
||||
});
|
||||
}
|
||||
if (!email) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Apple account has no email address",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if this Apple account is already linked
|
||||
const [existingAccount] = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.provider, "apple"),
|
||||
eq(accounts.providerAccountId, appleUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
// Check if this Apple account is already linked
|
||||
const [existingAccount] = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
eq(accounts.provider, "apple"),
|
||||
eq(accounts.providerAccountId, appleUserId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
let userId: string;
|
||||
let isNewUser = false;
|
||||
let userId: string;
|
||||
let isNewUser = false;
|
||||
|
||||
if (existingAccount) {
|
||||
// Already linked — use the existing user
|
||||
userId = existingAccount.userId;
|
||||
isNewUser = false;
|
||||
if (existingAccount) {
|
||||
// Already linked — use the existing user
|
||||
userId = existingAccount.userId;
|
||||
isNewUser = false;
|
||||
|
||||
// Update tokens
|
||||
await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
accessToken: identityToken,
|
||||
refreshToken: authorizationCode,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(accounts.id, existingAccount.id));
|
||||
} else {
|
||||
// Not linked — check if a user with this email exists
|
||||
const [existingUserByEmail] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
// Update tokens
|
||||
await db
|
||||
.update(accounts)
|
||||
.set({
|
||||
accessToken: identityToken,
|
||||
refreshToken: authorizationCode,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(accounts.id, existingAccount.id));
|
||||
} else {
|
||||
// Not linked — check if a user with this email exists
|
||||
const [existingUserByEmail] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
// Apple provides the user's first name and last name only on the initial sign-up
|
||||
// We derive a display name from email if userIdentifier-based lookup doesn't work
|
||||
const displayName = email.split("@")[0] ?? "User";
|
||||
// Apple provides the user's first name and last name only on the initial sign-up
|
||||
// We derive a display name from email if userIdentifier-based lookup doesn't work
|
||||
const displayName = email.split("@")[0] ?? "User";
|
||||
|
||||
if (existingUserByEmail) {
|
||||
// Link Apple to existing user
|
||||
userId = existingUserByEmail.id;
|
||||
isNewUser = false;
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "apple",
|
||||
providerAccountId: appleUserId,
|
||||
accessToken: identityToken,
|
||||
refreshToken: authorizationCode,
|
||||
});
|
||||
} else {
|
||||
// Create new user with Apple
|
||||
isNewUser = true;
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: displayName,
|
||||
email,
|
||||
emailVerified: new Date(),
|
||||
})
|
||||
.returning();
|
||||
userId = newUser.id;
|
||||
if (existingUserByEmail) {
|
||||
// Link Apple to existing user
|
||||
userId = existingUserByEmail.id;
|
||||
isNewUser = false;
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "apple",
|
||||
providerAccountId: appleUserId,
|
||||
accessToken: identityToken,
|
||||
refreshToken: authorizationCode,
|
||||
});
|
||||
} else {
|
||||
// Create new user with Apple
|
||||
isNewUser = true;
|
||||
const [newUser] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: displayName,
|
||||
email,
|
||||
emailVerified: new Date(),
|
||||
})
|
||||
.returning();
|
||||
userId = newUser.id;
|
||||
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "apple",
|
||||
providerAccountId: appleUserId,
|
||||
accessToken: identityToken,
|
||||
refreshToken: authorizationCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
provider: "apple",
|
||||
providerAccountId: appleUserId,
|
||||
accessToken: identityToken,
|
||||
refreshToken: authorizationCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create session and JWT
|
||||
const session = await createSession(userId);
|
||||
const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||
const refreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
|
||||
// Create session and JWT
|
||||
const session = await createSession(userId);
|
||||
const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||
const refreshToken = await signJWT(
|
||||
{ sub: userId, type: "refresh" },
|
||||
{ expiresIn: "30d" },
|
||||
);
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found after creation" });
|
||||
}
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found after creation",
|
||||
});
|
||||
}
|
||||
|
||||
return { user, sessionToken: session.sessionToken, accessToken, refreshToken, isNewUser };
|
||||
return {
|
||||
user,
|
||||
sessionToken: session.sessionToken,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isNewUser,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes an access token using a valid refresh token.
|
||||
*/
|
||||
export async function refreshAccessToken(refreshToken: string) {
|
||||
const { verifyJWT, signJWT } = await import("~/server/auth/jwt");
|
||||
const { verifyJWT, signJWT } = await import("~/server/auth/jwt");
|
||||
|
||||
let payload: { sub?: string; type?: string };
|
||||
try {
|
||||
payload = await verifyJWT<{ sub: string; type: string }>(refreshToken);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid or expired refresh token",
|
||||
});
|
||||
}
|
||||
let payload: { sub?: string; type?: string };
|
||||
try {
|
||||
payload = await verifyJWT<{ sub: string; type: string }>(refreshToken);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid or expired refresh token",
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.type !== "refresh") {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid token type",
|
||||
});
|
||||
}
|
||||
if (payload.type !== "refresh") {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid token type",
|
||||
});
|
||||
}
|
||||
|
||||
const userId = payload.sub!;
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.id, userId), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
const userId = payload.sub!;
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.id, userId), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||
const newRefreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
|
||||
const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||
const newRefreshToken = await signJWT(
|
||||
{ sub: userId, type: "refresh" },
|
||||
{ expiresIn: "30d" },
|
||||
);
|
||||
|
||||
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
|
||||
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a password reset email.
|
||||
*/
|
||||
export async function forgotPassword(email: string) {
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
// Don't reveal whether the email exists
|
||||
return { success: true };
|
||||
}
|
||||
if (!user) {
|
||||
// Don't reveal whether the email exists
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Generate a reset token (valid for 1 hour)
|
||||
const resetToken = await signJWT(
|
||||
{ sub: user.id, type: "password-reset" },
|
||||
{ expiresIn: "1h" },
|
||||
);
|
||||
// Generate a reset token (valid for 1 hour)
|
||||
const resetToken = await signJWT(
|
||||
{ sub: user.id, type: "password-reset" },
|
||||
{ expiresIn: "1h" },
|
||||
);
|
||||
|
||||
// In production, send via email service (Resend, SendGrid, etc.)
|
||||
// For now, we log it and return success
|
||||
console.log(`Password reset token for ${email}: ${resetToken}`);
|
||||
// In production, send via email service (Resend, SendGrid, etc.)
|
||||
// For now, we log it and return success
|
||||
console.log(`Password reset token for ${email}: ${resetToken}`);
|
||||
|
||||
// TODO: Send email via Resend
|
||||
// const { Resend } = await import("resend");
|
||||
// const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
// await resend.emails.send({
|
||||
// from: "Kordant <support@kordant.com>",
|
||||
// to: email,
|
||||
// subject: "Reset your password",
|
||||
// html: `<a href="${process.env.APP_URL}/reset-password?token=${resetToken}">Reset password</a>`,
|
||||
// });
|
||||
// TODO: Send email via Resend
|
||||
// const { Resend } = await import("resend");
|
||||
// const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
// await resend.emails.send({
|
||||
// from: "Kordant <support@kordant.com>",
|
||||
// to: email,
|
||||
// subject: "Reset your password",
|
||||
// html: `<a href="${process.env.APP_URL}/reset-password?token=${resetToken}">Reset password</a>`,
|
||||
// });
|
||||
|
||||
return { success: true };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets a user's password using a valid reset token.
|
||||
*/
|
||||
export async function resetPassword(token: string, newPassword: string) {
|
||||
const { verifyJWT } = await import("~/server/auth/jwt");
|
||||
const { verifyJWT } = await import("~/server/auth/jwt");
|
||||
|
||||
let payload: { sub?: string; type?: string };
|
||||
try {
|
||||
payload = await verifyJWT<{ sub: string; type: string }>(token);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid or expired reset token",
|
||||
});
|
||||
}
|
||||
let payload: { sub?: string; type?: string };
|
||||
try {
|
||||
payload = await verifyJWT<{ sub: string; type: string }>(token);
|
||||
} catch {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid or expired reset token",
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.type !== "password-reset") {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid token type",
|
||||
});
|
||||
}
|
||||
if (payload.type !== "password-reset") {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "Invalid token type",
|
||||
});
|
||||
}
|
||||
|
||||
const userId = payload.sub!;
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
const userId = payload.sub!;
|
||||
const passwordHash = await hashPassword(newPassword);
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash, updatedAt: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
await db
|
||||
.update(users)
|
||||
.set({ passwordHash, updatedAt: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return { success: true };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes all sessions for a user (logout everywhere).
|
||||
*/
|
||||
export async function revokeUserSessions(userId: string) {
|
||||
const { sessions } = await import("~/server/db/schema/auth");
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(eq(sessions.userId, userId));
|
||||
return { success: true };
|
||||
const { sessions } = await import("~/server/db/schema/auth");
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
with: {
|
||||
accounts: true,
|
||||
sessions: true,
|
||||
deviceTokens: true,
|
||||
familyGroups: true,
|
||||
familyGroupOwned: true,
|
||||
subscriptions: true,
|
||||
},
|
||||
});
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
with: {
|
||||
accounts: true,
|
||||
sessions: true,
|
||||
deviceTokens: true,
|
||||
familyGroups: true,
|
||||
familyGroupOwned: true,
|
||||
subscriptions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||
}
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||
}
|
||||
|
||||
return user;
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function updateUser(
|
||||
id: string,
|
||||
data: { name?: string; email?: string; image?: string },
|
||||
id: string,
|
||||
data: { name?: string; email?: string; image?: string },
|
||||
) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||
}
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||
}
|
||||
|
||||
if (data.email && data.email !== existing.email) {
|
||||
const [duplicate] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, data.email))
|
||||
.limit(1);
|
||||
if (data.email && data.email !== existing.email) {
|
||||
const [duplicate] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, data.email))
|
||||
.limit(1);
|
||||
|
||||
if (duplicate) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email already in use",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (duplicate) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email already in use",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set(data)
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set(data)
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
return updated;
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteUser(id: string) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||
}
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.update(users)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
const [deleted] = await db
|
||||
.update(users)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
return deleted;
|
||||
return deleted;
|
||||
}
|
||||
|
||||
8
web/vercel.json
Normal file
8
web/vercel.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"framework": "solidstart",
|
||||
"buildCommand": "npm run build",
|
||||
"installCommand": "bun install",
|
||||
"outputDirectory": ".output/public",
|
||||
"regions": ["iad1"]
|
||||
}
|
||||
Reference in New Issue
Block a user