Compare commits

...

7 Commits

Author SHA1 Message Date
1bc9307c29 beep boop 2026-06-03 14:45:49 -04:00
a5dabe7faf um 2026-06-03 14:18:22 -04:00
d17229735f playwright 2026-06-03 14:08:27 -04:00
8e953cdd7c fmt 2026-06-03 14:05:49 -04:00
a07c004f2d drop notification, reget deps 2026-06-03 14:05:27 -04:00
203591ca05 resetting 2026-06-03 13:54:53 -04:00
61d48d3648 onnx, fix depl issue 2026-06-03 13:35:37 -04:00
28 changed files with 2381 additions and 14011 deletions

1628
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,15 @@
"browser-ext" "browser-ext"
], ],
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "bun run --cwd web dev",
"build": "pnpm --filter web build", "build": "bun run --cwd web build",
"build:ext": "pnpm --filter @kordant/browser-ext build", "build:ext": "bun run --cwd browser-ext build",
"test": "pnpm --filter web test", "test": "bun run --cwd web test",
"test:ext": "pnpm --filter @kordant/browser-ext test", "test:ext": "bun run --cwd browser-ext test",
"lint": "pnpm --filter web lint", "lint": "bun run --cwd web lint",
"lint:ext": "pnpm --filter @kordant/browser-ext lint", "lint:ext": "bun run --cwd browser-ext lint",
"db:migrate": "pnpm --filter web db:migrate", "db:migrate": "bun run --cwd web db:migrate",
"db:seed": "pnpm --filter web db:seed" "db:seed": "bun run --cwd web db:seed"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.6.0", "@types/node": "^25.6.0",
@@ -27,5 +27,5 @@
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"
}, },
"packageManager": "pnpm@9.0.0" "packageManager": "bun@1.3.8"
} }

10232
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
packages:
- "web"
- "browser-ext"

View File

@@ -21,7 +21,7 @@ Status legend: [ ] todo, [~] in-progress, [x] done
### Performance Optimization ### Performance Optimization
- [x] 09 — Image Caching & Lazy Loading → `09-image-caching.md` - [x] 09 — Image Caching & Lazy Loading → `09-image-caching.md`
- [x] 10 — Memory Management & Leak Audit → `10-memory-leak-audit.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` - [x] 12 — App Launch Time Optimization → `12-launch-time.md`
### Native Features ### Native Features

1
web/.gitignore vendored
View File

@@ -5,6 +5,7 @@ dist
.netlify .netlify
.vinxi .vinxi
app.config.timestamp_*.js app.config.timestamp_*.js
.pi-lens
# Environment # Environment
.env* .env*

File diff suppressed because one or more lines are too long

51
web/.vercelignore Normal file
View 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/

View File

@@ -15,56 +15,45 @@
"benchmark:spamshield": "tsx src/server/services/spamshield/benchmark.ts" "benchmark:spamshield": "tsx src/server/services/spamshield/benchmark.ts"
}, },
"dependencies": { "dependencies": {
"@libsql/client": "^0.15.0", "@libsql/client": "^0.17.3",
"@sentry/solidstart": "^10.54.0", "@sentry/solidstart": "^10.56.0",
"@solidjs/meta": "^0.29.4", "@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0", "@solidjs/router": "^0.15.0",
"@solidjs/start": "2.0.0-alpha.2", "@solidjs/start": "2.0.0-alpha.2",
"@solidjs/vite-plugin-nitro-2": "^0.1.0", "@solidjs/vite-plugin-nitro-2": "^0.1.0",
"@stripe/stripe-js": "^9.6.0", "@stripe/stripe-js": "^9.7.0",
"@tailwindcss/vite": "^4.0.0", "@trpc/client": "^11.17.0",
"@trpc/client": "^10.45.2", "@trpc/server": "^11.17.0",
"@trpc/server": "^10.45.2", "@typeschema/valibot": "^0.14.0",
"@types/three": "^0.184.1",
"@typeschema/valibot": "^0.13.4",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"bullmq": "^5.77.3",
"clerk-solidjs": "^2.0.10", "clerk-solidjs": "^2.0.10",
"dompurify": "^3.4.7",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"firebase-admin": "^13.10.0", "imapflow": "^1.3.5",
"imapflow": "^1.3.4", "ioredis": "^5.11.0",
"ioredis": "^5.10.1",
"isomorphic-dompurify": "^3.15.0", "isomorphic-dompurify": "^3.15.0",
"jose": "^5", "jose": "^6.2.3",
"marked": "^18.0.4", "marked": "^18.0.4",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"onnxruntime-node": "^1.26.0", "onnxruntime-node": "^1.26.0",
"pino": "^10.3.1", "playwright": "^1.60.0",
"pino-pretty": "^13.1.3", "puppeteer": "^25.1.0",
"puppeteer": "^25.0.4",
"resend": "^6.12.4", "resend": "^6.12.4",
"solid-js": "^1.9.5", "solid-js": "^1.9.5",
"stripe": "^22.1.1", "pino": "^10.3.1",
"tailwindcss": "^4.0.0", "stripe": "^22.2.0",
"three": "^0.184.0", "three": "^0.184.0",
"twilio": "^6.0.2", "twilio": "^6.0.2",
"valibot": "^0.29.0", "valibot": "^1.4.1",
"vite": "^7.0.0", "vite": "^7.0.0",
"ws": "^8.21.0" "ws": "^8.21.0",
"zod": "^3.25.76"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"
}, },
"packageManager": "bun@1.3.8",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@tailwindcss/vite": "^4.0.7",
"@types/node-cron": "^3.0.11", "tailwindcss": "^4.0.7"
"@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"
} }
} }

View File

@@ -1,13 +1,12 @@
import type { APIEvent } from "@solidjs/start/server"; import type { APIEvent } from "@solidjs/start/server";
import { import {
authenticateUser, authenticateUser,
authenticateWithGoogle, authenticateWithApple,
authenticateWithApple, createUserWithPassword,
createUserWithPassword, forgotPassword,
forgotPassword, resetPassword,
resetPassword, refreshAccessToken,
refreshAccessToken, revokeUserSessions,
revokeUserSessions,
} from "~/server/services/user.service"; } from "~/server/services/user.service";
import { verifyJWT } from "~/server/auth/jwt"; import { verifyJWT } from "~/server/auth/jwt";
@@ -27,178 +26,166 @@ import { verifyJWT } from "~/server/auth/jwt";
*/ */
export async function POST(event: APIEvent) { export async function POST(event: APIEvent) {
const action = event.params.action; const action = event.params.action;
const body = await event.request.json().catch(() => ({})); const body = await event.request.json().catch(() => ({}));
try { try {
switch (action) { switch (action) {
case "login": { case "login": {
const { email, password } = body; const { email, password } = body;
if (!email || !password) { if (!email || !password) {
return new Response( return new Response(
JSON.stringify({ message: "Email and password are required" }), JSON.stringify({ message: "Email and password are required" }),
{ status: 400, headers: { "Content-Type": "application/json" } }, { status: 400, headers: { "Content-Type": "application/json" } },
); );
} }
const result = await authenticateUser(email, password); const result = await authenticateUser(email, password);
return Response.json({ return Response.json({
id: result.user.id, id: result.user.id,
name: result.user.name ?? "", name: result.user.name ?? "",
email: result.user.email, email: result.user.email,
accessToken: result.accessToken, accessToken: result.accessToken,
sessionToken: result.sessionToken, sessionToken: result.sessionToken,
isNewUser: false, isNewUser: false,
}); });
} }
case "signup": { case "signup": {
const { name, email, password } = body; const { name, email, password } = body;
if (!email || !password) { if (!email || !password) {
return new Response( return new Response(
JSON.stringify({ message: "Name, email, and password are required" }), JSON.stringify({
{ status: 400, headers: { "Content-Type": "application/json" } }, message: "Name, email, and password are required",
); }),
} { status: 400, headers: { "Content-Type": "application/json" } },
const result = await createUserWithPassword( );
name ?? email.split("@")[0], }
email, const result = await createUserWithPassword(
password, name ?? email.split("@")[0],
); email,
return Response.json({ password,
id: result.user.id, );
name: result.user.name ?? "", return Response.json({
email: result.user.email, id: result.user.id,
accessToken: result.accessToken, name: result.user.name ?? "",
sessionToken: result.sessionToken, email: result.user.email,
isNewUser: true, accessToken: result.accessToken,
}); sessionToken: result.sessionToken,
} isNewUser: true,
});
}
case "google": { case "apple": {
const { idToken } = body; const { identityToken, authorizationCode, userIdentifier } = body;
if (!idToken) { if (!identityToken || !authorizationCode) {
return new Response( return new Response(
JSON.stringify({ message: "idToken is required" }), JSON.stringify({
{ status: 400, headers: { "Content-Type": "application/json" } }, message: "identityToken and authorizationCode are required",
); }),
} { status: 400, headers: { "Content-Type": "application/json" } },
const result = await authenticateWithGoogle(idToken); );
return Response.json({ }
id: result.user.id, const result = await authenticateWithApple(
name: result.user.name ?? "", identityToken,
email: result.user.email, authorizationCode,
image: result.user.image, userIdentifier ?? null,
accessToken: result.accessToken, );
refreshToken: result.refreshToken, return Response.json({
sessionToken: result.sessionToken, id: result.user.id,
isNewUser: result.isNewUser ?? false, 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": { case "refresh": {
const { identityToken, authorizationCode, userIdentifier } = body; const { refreshToken } = body;
if (!identityToken || !authorizationCode) { if (!refreshToken) {
return new Response( return new Response(
JSON.stringify({ message: "identityToken and authorizationCode are required" }), JSON.stringify({ message: "refreshToken is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } }, { status: 400, headers: { "Content-Type": "application/json" } },
); );
} }
const result = await authenticateWithApple( const result = await refreshAccessToken(refreshToken);
identityToken, return Response.json({
authorizationCode, accessToken: result.accessToken,
userIdentifier ?? null, refreshToken: result.refreshToken,
); });
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": { case "logout": {
const { refreshToken } = body; // Extract user from Bearer token
if (!refreshToken) { const authHeader = event.request.headers.get("authorization");
return new Response( if (authHeader?.startsWith("Bearer ")) {
JSON.stringify({ message: "refreshToken is required" }), const token = authHeader.slice(7);
{ status: 400, headers: { "Content-Type": "application/json" } }, try {
); const payload = await verifyJWT<{ sub: string }>(token);
} await revokeUserSessions(payload.sub);
const result = await refreshAccessToken(refreshToken); } catch {
return Response.json({ // Invalid token — still return success
accessToken: result.accessToken, }
refreshToken: result.refreshToken, }
}); return Response.json({ success: true });
} }
case "logout": { case "forgot-password": {
// Extract user from Bearer token const { email } = body;
const authHeader = event.request.headers.get("authorization"); if (!email) {
if (authHeader?.startsWith("Bearer ")) { return new Response(
const token = authHeader.slice(7); JSON.stringify({ message: "Email is required" }),
try { { status: 400, headers: { "Content-Type": "application/json" } },
const payload = await verifyJWT<{ sub: string }>(token); );
await revokeUserSessions(payload.sub); }
} catch { await forgotPassword(email);
// Invalid token — still return success return Response.json({ success: true });
} }
}
return Response.json({ success: true });
}
case "forgot-password": { case "reset-password": {
const { email } = body; const { code, password } = body;
if (!email) { if (!code || !password) {
return new Response( return new Response(
JSON.stringify({ message: "Email is required" }), JSON.stringify({ message: "Code and password are required" }),
{ status: 400, headers: { "Content-Type": "application/json" } }, { status: 400, headers: { "Content-Type": "application/json" } },
); );
} }
await forgotPassword(email); // The mobile app sends "code" but the service expects "token"
return Response.json({ success: true }); // We accept both for backward compatibility
} const token = code;
await resetPassword(token, password);
return Response.json({ success: true });
}
case "reset-password": { default:
const { code, password } = body; return new Response(
if (!code || !password) { JSON.stringify({ message: `Unknown action: ${action}` }),
return new Response( { status: 404, headers: { "Content-Type": "application/json" } },
JSON.stringify({ message: "Code and password are required" }), );
{ status: 400, headers: { "Content-Type": "application/json" } }, }
); } catch (error: any) {
} const statusCode =
// The mobile app sends "code" but the service expects "token" error.code === "UNAUTHORIZED"
// We accept both for backward compatibility ? 401
const token = code; : error.code === "CONFLICT"
await resetPassword(token, password); ? 409
return Response.json({ success: true }); : error.code === "NOT_FOUND"
} ? 404
: error.code === "FORBIDDEN"
? 403
: 500;
default: return new Response(
return new Response( JSON.stringify({
JSON.stringify({ message: `Unknown action: ${action}` }), message: error.message ?? "Internal server error",
{ status: 404, headers: { "Content-Type": "application/json" } }, code: error.code ?? "INTERNAL_ERROR",
); }),
} {
} catch (error: any) { status: statusCode,
const statusCode = error.code === "UNAUTHORIZED" ? 401 headers: { "Content-Type": "application/json" },
: 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" },
},
);
}
} }

View File

@@ -1,7 +1,6 @@
import { exampleRouter } from "./routers/example"; import { exampleRouter } from "./routers/example";
import { userRouter } from "./routers/user"; import { userRouter } from "./routers/user";
import { billingRouter } from "./routers/billing"; import { billingRouter } from "./routers/billing";
import { notificationRouter } from "./routers/notification";
import { darkwatchRouter } from "./routers/darkwatch"; import { darkwatchRouter } from "./routers/darkwatch";
import { voiceprintRouter } from "./routers/voiceprint"; import { voiceprintRouter } from "./routers/voiceprint";
import { spamshieldRouter } from "./routers/spamshield"; import { spamshieldRouter } from "./routers/spamshield";
@@ -17,22 +16,21 @@ import { familyRouter } from "./routers/family";
import { createTRPCRouter } from "./utils"; import { createTRPCRouter } from "./utils";
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
example: exampleRouter, example: exampleRouter,
user: userRouter, user: userRouter,
billing: billingRouter, billing: billingRouter,
notification: notificationRouter, darkwatch: darkwatchRouter,
darkwatch: darkwatchRouter, voiceprint: voiceprintRouter,
voiceprint: voiceprintRouter, spamshield: spamshieldRouter,
spamshield: spamshieldRouter, hometitle: hometitleRouter,
hometitle: hometitleRouter, removebrokers: removebrokersRouter,
removebrokers: removebrokersRouter, correlation: correlationRouter,
correlation: correlationRouter, reports: reportsRouter,
reports: reportsRouter, scheduler: schedulerRouter,
scheduler: schedulerRouter, extension: extensionRouter,
extension: extensionRouter, blog: blogRouter,
blog: blogRouter, admin: adminRouter,
admin: adminRouter, family: familyRouter,
family: familyRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@@ -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);
}),
});

View File

@@ -1,185 +1,179 @@
import { wrap } from "@typeschema/valibot"; import { wrap } from "@typeschema/valibot";
import { object, string, minLength, email as emailVal } from "valibot"; import { object, string, minLength, email as emailVal } from "valibot";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../utils";
import { import {
UpdateUserSchema, createTRPCRouter,
InviteMemberSchema, publicProcedure,
RemoveMemberSchema, protectedProcedure,
UpdateRoleSchema, } from "../utils";
import {
UpdateUserSchema,
InviteMemberSchema,
RemoveMemberSchema,
UpdateRoleSchema,
} from "../schemas/user"; } from "../schemas/user";
import { import {
getUserById, getUserById,
updateUser, updateUser,
deleteUser, deleteUser,
createUserWithPassword, createUserWithPassword,
authenticateUser, authenticateUser,
authenticateWithGoogle, authenticateWithApple,
authenticateWithApple, refreshAccessToken,
refreshAccessToken, forgotPassword,
forgotPassword, resetPassword,
resetPassword, revokeUserSessions,
revokeUserSessions,
} from "~/server/services/user.service"; } from "~/server/services/user.service";
import { import {
getFamilyGroup, getFamilyGroup,
inviteMember, inviteMember,
removeMember, removeMember,
updateMemberRole, updateMemberRole,
} from "~/server/services/family.service"; } from "~/server/services/family.service";
const LoginSchema = object({ const LoginSchema = object({
email: string([emailVal()]), email: string([emailVal()]),
password: string([minLength(1)]), password: string([minLength(1)]),
}); });
const SignupSchema = object({ const SignupSchema = object({
name: string([minLength(1)]), name: string([minLength(1)]),
email: string([emailVal()]), email: string([emailVal()]),
password: string([minLength(8)]), password: string([minLength(8)]),
});
const GoogleAuthSchema = object({
idToken: string([minLength(1)]),
}); });
const AppleAuthSchema = object({ const AppleAuthSchema = object({
identityToken: string([minLength(1)]), identityToken: string([minLength(1)]),
authorizationCode: string([minLength(1)]), authorizationCode: string([minLength(1)]),
userIdentifier: string(), userIdentifier: string(),
}); });
const RefreshTokenSchema = object({ const RefreshTokenSchema = object({
refreshToken: string([minLength(1)]), refreshToken: string([minLength(1)]),
}); });
const ForgotPasswordSchema = object({ const ForgotPasswordSchema = object({
email: string([emailVal()]), email: string([emailVal()]),
}); });
const ResetPasswordSchema = object({ const ResetPasswordSchema = object({
token: string([minLength(1)]), token: string([minLength(1)]),
password: string([minLength(8)]), password: string([minLength(8)]),
}); });
export const userRouter = createTRPCRouter({ export const userRouter = createTRPCRouter({
login: publicProcedure login: publicProcedure
.input(wrap(LoginSchema)) .input(wrap(LoginSchema))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return authenticateUser(input.email, input.password); return authenticateUser(input.email, input.password);
}), }),
signup: publicProcedure signup: publicProcedure
.input(wrap(SignupSchema)) .input(wrap(SignupSchema))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return createUserWithPassword(input.name, input.email, input.password); return createUserWithPassword(input.name, input.email, input.password);
}), }),
googleAuth: publicProcedure appleAuth: publicProcedure
.input(wrap(GoogleAuthSchema)) .input(wrap(AppleAuthSchema))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return authenticateWithGoogle(input.idToken); return authenticateWithApple(
}), input.identityToken,
input.authorizationCode,
input.userIdentifier || null,
);
}),
appleAuth: publicProcedure refreshToken: publicProcedure
.input(wrap(AppleAuthSchema)) .input(wrap(RefreshTokenSchema))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return authenticateWithApple( return refreshAccessToken(input.refreshToken);
input.identityToken, }),
input.authorizationCode,
input.userIdentifier || null,
);
}),
refreshToken: publicProcedure forgotPassword: publicProcedure
.input(wrap(RefreshTokenSchema)) .input(wrap(ForgotPasswordSchema))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return refreshAccessToken(input.refreshToken); return forgotPassword(input.email);
}), }),
forgotPassword: publicProcedure resetPassword: publicProcedure
.input(wrap(ForgotPasswordSchema)) .input(wrap(ResetPasswordSchema))
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return forgotPassword(input.email); return resetPassword(input.token, input.password);
}), }),
resetPassword: publicProcedure me: protectedProcedure.query(async ({ ctx }) => {
.input(wrap(ResetPasswordSchema)) const user = await getUserById(ctx.user.id);
.mutation(async ({ input }) => { return user;
return resetPassword(input.token, input.password); }),
}),
me: protectedProcedure.query(async ({ ctx }) => { update: protectedProcedure
const user = await getUserById(ctx.user.id); .input(wrap(UpdateUserSchema))
return user; .mutation(async ({ ctx, input }) => {
}), const updated = await updateUser(ctx.user.id, input);
return updated;
}),
update: protectedProcedure delete: protectedProcedure.mutation(async ({ ctx }) => {
.input(wrap(UpdateUserSchema)) await deleteUser(ctx.user.id);
.mutation(async ({ ctx, input }) => { return { success: true };
const updated = await updateUser(ctx.user.id, input); }),
return updated;
}),
delete: protectedProcedure.mutation(async ({ ctx }) => { logout: protectedProcedure.mutation(async ({ ctx }) => {
await deleteUser(ctx.user.id); await revokeUserSessions(ctx.user.id);
return { success: true }; return { success: true };
}), }),
logout: protectedProcedure.mutation(async ({ ctx }) => { listFamilyMembers: protectedProcedure.query(async ({ ctx }) => {
await revokeUserSessions(ctx.user.id); const group = await getFamilyGroup(ctx.user.id);
return { success: true }; return group.members;
}), }),
listFamilyMembers: protectedProcedure.query(async ({ ctx }) => { inviteFamilyMember: protectedProcedure
const group = await getFamilyGroup(ctx.user.id); .input(wrap(InviteMemberSchema))
return group.members; .mutation(async ({ ctx, input }) => {
}), const group = await getFamilyGroup(ctx.user.id);
inviteFamilyMember: protectedProcedure const callerMember = group.members.find((m) => m.userId === ctx.user.id);
.input(wrap(InviteMemberSchema))
.mutation(async ({ ctx, input }) => {
const group = await getFamilyGroup(ctx.user.id);
const callerMember = group.members.find( if (
(m) => m.userId === ctx.user.id, !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")) { const invitation = await inviteMember(
throw new TRPCError({ group.id,
code: "FORBIDDEN", input.email,
message: "Only owner or admin can invite members", ctx.user.id,
}); input.role,
} );
const invitation = await inviteMember( return invitation;
group.id, }),
input.email,
ctx.user.id,
input.role,
);
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 updateFamilyMemberRole: protectedProcedure
.input(wrap(RemoveMemberSchema)) .input(wrap(UpdateRoleSchema))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const group = await getFamilyGroup(ctx.user.id); const group = await getFamilyGroup(ctx.user.id);
await removeMember(group.id, input.userId, ctx.user.id); const updated = await updateMemberRole(
return { success: true }; group.id,
}), input.userId,
input.role,
updateFamilyMemberRole: protectedProcedure ctx.user.id,
.input(wrap(UpdateRoleSchema)) );
.mutation(async ({ ctx, input }) => { return updated;
const group = await getFamilyGroup(ctx.user.id); }),
const updated = await updateMemberRole(
group.id,
input.userId,
input.role,
ctx.user.id,
);
return updated;
}),
}); });

View File

@@ -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"; import { familyGroupMembers } from "./subscription";
export { familyGroupMembers };
/** /**
* Per-member service configuration. * Per-member service configuration.
* The primary account holder assigns which services each member gets. * The primary account holder assigns which services each member gets.
* Default: all members get darkwatch + spamshield + removebrokers. * Default: all members get darkwatch + spamshield + removebrokers.
* HomeTitle and VoicePrint limited by property/voice enrollment slots. * HomeTitle and VoicePrint limited by property/voice enrollment slots.
*/ */
export const familyMemberServices = sqliteTable("family_member_services", { export const familyMemberServices = sqliteTable(
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), "family_member_services",
memberId: text("member_id").notNull().references(() => familyGroupMembers.id, { onDelete: "cascade" }), {
service: text("service").notNull(), id: text("id")
enabled: integer("enabled", { mode: "boolean" }).default(true).notNull(), .primaryKey()
configuredBy: text("configured_by").notNull(), .$defaultFn(() => crypto.randomUUID()),
configuredAt: integer("configured_at", { mode: "timestamp_ms" }).defaultNow().notNull(), memberId: text("member_id")
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), .notNull()
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()), .references(() => familyGroupMembers.id, { onDelete: "cascade" }),
}, (table) => ({ service: text("service").notNull(),
memberServiceUnique: uniqueIndex("family_member_services_member_service_unique").on(table.memberId, table.service), enabled: integer("enabled", { mode: "boolean" }).default(true).notNull(),
memberIdIdx: index("family_member_services_member_id_idx").on(table.memberId), configuredBy: text("configured_by").notNull(),
serviceIdx: index("family_member_services_service_idx").on(table.service), 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. * Per-member alert notification preferences.
* Members can opt into/off specific alert types and channels. * Members can opt into/off specific alert types and channels.
*/ */
export const familyMemberAlertPreferences = sqliteTable("family_member_alert_preferences", { export const familyMemberAlertPreferences = sqliteTable(
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), "family_member_alert_preferences",
memberId: text("member_id").notNull().references(() => familyGroupMembers.id, { onDelete: "cascade" }), {
alertType: text("alert_type").notNull(), id: text("id")
channel: text("channel").notNull(), .primaryKey()
enabled: integer("enabled", { mode: "boolean" }).default(true).notNull(), .$defaultFn(() => crypto.randomUUID()),
createdAt: integer("created_at", { mode: "timestamp_ms" }).defaultNow().notNull(), memberId: text("member_id")
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).defaultNow().notNull().$onUpdate(() => new Date()), .notNull()
}, (table) => ({ .references(() => familyGroupMembers.id, { onDelete: "cascade" }),
memberChannelTypeUnique: uniqueIndex("family_member_alert_prefs_member_channel_type_unique").on(table.memberId, table.channel, table.alertType), alertType: text("alert_type").notNull(),
memberIdIdx: index("family_member_alert_prefs_member_id_idx").on(table.memberId), 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,
),
}),
);

View File

@@ -1,36 +1,36 @@
import type { JobPayload, JobType } from "../queue"; 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 = { export type HandlerMap = {
[K in JobType]: JobHandler<K>; [K in JobType]: JobHandler<K>;
}; };
let handlers: HandlerMap | null = null; let handlers: HandlerMap | null = null;
export function getHandlers(): HandlerMap { export function getHandlers(): HandlerMap {
if (!handlers) { if (!handlers) {
handlers = { handlers = {
"darkwatch.scan": require("./darkwatch.scan").handler, "darkwatch.scan": require("./darkwatch.scan").handler,
"darkwatch.digest": require("./darkwatch.digest").handler, "darkwatch.digest": require("./darkwatch.digest").handler,
"voiceprint.batch": require("./voiceprint.batch").handler, "voiceprint.batch": require("./voiceprint.batch").handler,
"hometitle.scan": require("./hometitle.scan").handler, "hometitle.scan": require("./hometitle.scan").handler,
"removebrokers.process": require("./removebrokers.process").handler, "removebrokers.process": require("./removebrokers.process").handler,
"reports.generate": require("./reports.generate").handler, "reports.generate": require("./reports.generate").handler,
"notifications.send": require("./notifications.send").handler, };
}; }
} return handlers;
return handlers;
} }
export function setHandlers(mock: Partial<HandlerMap>): void { export function setHandlers(mock: Partial<HandlerMap>): void {
handlers = { handlers = {
"darkwatch.scan": mock["darkwatch.scan"] ?? (async () => {}), "darkwatch.scan": mock["darkwatch.scan"] ?? (async () => {}),
"darkwatch.digest": mock["darkwatch.digest"] ?? (async () => {}), "darkwatch.digest": mock["darkwatch.digest"] ?? (async () => {}),
"voiceprint.batch": mock["voiceprint.batch"] ?? (async () => {}), "voiceprint.batch": mock["voiceprint.batch"] ?? (async () => {}),
"hometitle.scan": mock["hometitle.scan"] ?? (async () => {}), "hometitle.scan": mock["hometitle.scan"] ?? (async () => {}),
"removebrokers.process": mock["removebrokers.process"] ?? (async () => {}), "removebrokers.process": mock["removebrokers.process"] ?? (async () => {}),
"reports.generate": mock["reports.generate"] ?? (async () => {}), "reports.generate": mock["reports.generate"] ?? (async () => {}),
"notifications.send": mock["notifications.send"] ?? (async () => {}), };
};
} }

View File

@@ -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);
}
}

View File

@@ -1,222 +1,243 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
export const JOB_TYPES = [ export const JOB_TYPES = [
"darkwatch.scan", "darkwatch.scan",
"darkwatch.digest", "darkwatch.digest",
"voiceprint.batch", "voiceprint.batch",
"hometitle.scan", "hometitle.scan",
"removebrokers.process", "removebrokers.process",
"reports.generate", "reports.generate",
"notifications.send",
] as const; ] as const;
export type JobType = (typeof JOB_TYPES)[number]; export type JobType = (typeof JOB_TYPES)[number];
export type JobPayload = { export type JobPayload = {
"darkwatch.scan": { userId: string; subscriptionId: string }; "darkwatch.scan": { userId: string; subscriptionId: string };
"darkwatch.digest": { userId: string }; "darkwatch.digest": { userId: string };
"voiceprint.batch": { userId?: string; jobId?: string }; "voiceprint.batch": { userId?: string; jobId?: string };
"hometitle.scan": { userId: string; subscriptionId: string }; "hometitle.scan": { userId: string; subscriptionId: string };
"removebrokers.process": { subscriptionId?: string; requestId?: string }; "removebrokers.process": { subscriptionId?: string; requestId?: string };
"reports.generate": { userId: string; reportScheduleId?: string; reportType: string }; "reports.generate": {
"notifications.send": { userId: string; alertId?: string; channel: string }; userId: string;
reportScheduleId?: string;
reportType: string;
};
}; };
export type JobStatus = "pending" | "running" | "completed" | "failed"; export type JobStatus = "pending" | "running" | "completed" | "failed";
export interface Job<T extends JobType = JobType> { export interface Job<T extends JobType = JobType> {
id: string; id: string;
type: T; type: T;
payload: JobPayload[T]; payload: JobPayload[T];
status: JobStatus; status: JobStatus;
attempts: number; attempts: number;
maxAttempts: number; maxAttempts: number;
error?: string; error?: string;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
export interface EnqueueOptions { export interface EnqueueOptions {
delay?: number; delay?: number;
maxAttempts?: number; maxAttempts?: number;
} }
export interface QueueAdapter { export interface QueueAdapter {
enqueue<T extends JobType>(type: T, payload: JobPayload[T], options?: EnqueueOptions): Promise<Job<T>>; enqueue<T extends JobType>(
dequeue(): Promise<Job | null>; type: T,
markComplete(jobId: string): Promise<void>; payload: JobPayload[T],
markFailed(jobId: string, error: string): Promise<void>; options?: EnqueueOptions,
scheduleRetry(job: Job, delayMs: number): Promise<void>; ): Promise<Job<T>>;
getJob(jobId: string): Promise<Job | null>; dequeue(): Promise<Job | null>;
getJobs(status?: JobStatus): Promise<Job[]>; 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 { export class InMemoryQueue implements QueueAdapter {
private jobs = new Map<string, Job>(); private jobs = new Map<string, Job>();
private pendingQueue: string[] = []; private pendingQueue: string[] = [];
async enqueue<T extends JobType>(type: T, payload: JobPayload[T], options?: EnqueueOptions): Promise<Job<T>> { async enqueue<T extends JobType>(
const id = randomUUID(); type: T,
const job: Job<T> = { payload: JobPayload[T],
id, options?: EnqueueOptions,
type, ): Promise<Job<T>> {
payload, const id = randomUUID();
status: "pending", const job: Job<T> = {
attempts: 0, id,
maxAttempts: options?.maxAttempts ?? 3, type,
createdAt: new Date(), payload,
updatedAt: new Date(), status: "pending",
}; attempts: 0,
this.jobs.set(id, job as Job); maxAttempts: options?.maxAttempts ?? 3,
if (options?.delay) { createdAt: new Date(),
setTimeout(() => { updatedAt: new Date(),
this.pendingQueue.push(id); };
}, options.delay); this.jobs.set(id, job as Job);
} else { if (options?.delay) {
this.pendingQueue.push(id); setTimeout(() => {
} this.pendingQueue.push(id);
return job; }, options.delay);
} } else {
this.pendingQueue.push(id);
}
return job;
}
async scheduleRetry(job: Job, delayMs: number): Promise<void> { async scheduleRetry(job: Job, delayMs: number): Promise<void> {
job.status = "pending"; job.status = "pending";
job.attempts++; job.attempts++;
job.updatedAt = new Date(); job.updatedAt = new Date();
setTimeout(() => { setTimeout(() => {
this.pendingQueue.push(job.id); this.pendingQueue.push(job.id);
}, delayMs); }, delayMs);
} }
async dequeue(): Promise<Job | null> { async dequeue(): Promise<Job | null> {
while (this.pendingQueue.length > 0) { while (this.pendingQueue.length > 0) {
const id = this.pendingQueue.shift()!; const id = this.pendingQueue.shift()!;
const job = this.jobs.get(id); const job = this.jobs.get(id);
if (!job || job.status !== "pending") continue; if (!job || job.status !== "pending") continue;
job.status = "running"; job.status = "running";
job.updatedAt = new Date(); job.updatedAt = new Date();
return job; return job;
} }
return null; return null;
} }
async markComplete(jobId: string): Promise<void> { async markComplete(jobId: string): Promise<void> {
const job = this.jobs.get(jobId); const job = this.jobs.get(jobId);
if (job) { if (job) {
job.status = "completed"; job.status = "completed";
job.updatedAt = new Date(); job.updatedAt = new Date();
} }
} }
async markFailed(jobId: string, error: string): Promise<void> { async markFailed(jobId: string, error: string): Promise<void> {
const job = this.jobs.get(jobId); const job = this.jobs.get(jobId);
if (job) { if (job) {
job.status = "failed"; job.status = "failed";
job.error = error; job.error = error;
job.updatedAt = new Date(); job.updatedAt = new Date();
} }
} }
async getJob(jobId: string): Promise<Job | null> { async getJob(jobId: string): Promise<Job | null> {
return this.jobs.get(jobId) ?? null; return this.jobs.get(jobId) ?? null;
} }
async getJobs(status?: JobStatus): Promise<Job[]> { async getJobs(status?: JobStatus): Promise<Job[]> {
const all = Array.from(this.jobs.values()); const all = Array.from(this.jobs.values());
if (status) return all.filter((j) => j.status === status); if (status) return all.filter((j) => j.status === status);
return all; return all;
} }
} }
function createRedisAdapter(): QueueAdapter { function createRedisAdapter(): QueueAdapter {
// Lazy imports so this module works without Redis // Lazy imports so this module works without Redis
const BullMQ = require("bullmq"); const BullMQ = require("bullmq");
const IORedis = require("ioredis"); const IORedis = require("ioredis");
const connection = new IORedis.default(process.env.REDIS_URL ?? "redis://localhost:6379", { const connection = new IORedis.default(
maxRetriesPerRequest: null, process.env.REDIS_URL ?? "redis://localhost:6379",
}); {
maxRetriesPerRequest: null,
},
);
const queue = new BullMQ.Queue("kordant-jobs", { connection }); const queue = new BullMQ.Queue("kordant-jobs", { connection });
let bullJobs = new Map<string, any>(); const bullJobs = new Map<string, any>();
async function toJob(bullJob: any): Promise<Job> { async function toJob(bullJob: any): Promise<Job> {
return { return {
id: bullJob.id, id: bullJob.id,
type: bullJob.name as JobType, type: bullJob.name as JobType,
payload: bullJob.data, payload: bullJob.data,
status: (await bullJob.getState()) as JobStatus, status: (await bullJob.getState()) as JobStatus,
attempts: bullJob.attemptsMade, attempts: bullJob.attemptsMade,
maxAttempts: bullJob.opts?.attempts ?? 3, maxAttempts: bullJob.opts?.attempts ?? 3,
error: bullJob.failedReason ?? undefined, error: bullJob.failedReason ?? undefined,
createdAt: bullJob.timestamp ? new Date(bullJob.timestamp) : new Date(), createdAt: bullJob.timestamp ? new Date(bullJob.timestamp) : new Date(),
updatedAt: bullJob.processedOn ? new Date(bullJob.processedOn) : new Date(), updatedAt: bullJob.processedOn
}; ? new Date(bullJob.processedOn)
} : new Date(),
};
}
return { return {
async enqueue<T extends JobType>(type: T, payload: JobPayload[T], options?: EnqueueOptions) { async enqueue<T extends JobType>(
const bullJob = await queue.add(type, payload, { type: T,
attempts: options?.maxAttempts ?? 3, payload: JobPayload[T],
delay: options?.delay, options?: EnqueueOptions,
backoff: { type: "exponential", delay: 60_000 }, ) {
}); const bullJob = await queue.add(type, payload, {
return toJob(bullJob) as Promise<Job<T>>; attempts: options?.maxAttempts ?? 3,
}, delay: options?.delay,
backoff: { type: "exponential", delay: 60_000 },
});
return toJob(bullJob) as Promise<Job<T>>;
},
async dequeue() { async dequeue() {
// BullMQ Worker handles dequeue automatically; this is for the polling worker // BullMQ Worker handles dequeue automatically; this is for the polling worker
return null; return null;
}, },
async markComplete(jobId) { async markComplete(jobId) {
// Handled by BullMQ Worker // Handled by BullMQ Worker
}, },
async markFailed(jobId, error) { async markFailed(jobId, error) {
// Handled by BullMQ Worker // Handled by BullMQ Worker
}, },
async scheduleRetry(job, delayMs) { async scheduleRetry(job, delayMs) {
// BullMQ handles retries via backoff // BullMQ handles retries via backoff
}, },
async getJob(jobId) { async getJob(jobId) {
const bullJob = await queue.getJob(jobId); const bullJob = await queue.getJob(jobId);
if (!bullJob) return null; if (!bullJob) return null;
return toJob(bullJob); return toJob(bullJob);
}, },
async getJobs(status) { async getJobs(status) {
const states = status ? [status] : ["waiting", "active", "completed", "failed"]; const states = status
const allJobs: Job[] = []; ? [status]
for (const state of states) { : ["waiting", "active", "completed", "failed"];
const jobs = await queue.getJobs(state); const allJobs: Job[] = [];
for (const j of jobs) { for (const state of states) {
allJobs.push(await toJob(j)); const jobs = await queue.getJobs(state);
} for (const j of jobs) {
} allJobs.push(await toJob(j));
return allJobs; }
}, }
}; return allJobs;
},
};
} }
let adapter: QueueAdapter; let adapter: QueueAdapter;
export function getQueue(): QueueAdapter { export function getQueue(): QueueAdapter {
if (!adapter) { if (!adapter) {
if (process.env.REDIS_URL) { if (process.env.REDIS_URL) {
adapter = createRedisAdapter(); adapter = createRedisAdapter();
} else { } else {
adapter = new InMemoryQueue(); adapter = new InMemoryQueue();
} }
} }
return adapter; return adapter;
} }
export function setQueue(mock: QueueAdapter): void { export function setQueue(mock: QueueAdapter): void {
adapter = mock; adapter = mock;
} }
export function resetQueue(): void { export function resetQueue(): void {
adapter = undefined as unknown as QueueAdapter; adapter = undefined as unknown as QueueAdapter;
} }

View 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",
});
}
}

View File

@@ -1,77 +1,72 @@
import { object, string, optional, parse, safeParse } from "valibot"; import { object, string, optional, parse, safeParse } from "valibot";
const envSchema = object({ const envSchema = object({
// Database // Database
DATABASE_URL: string(), DATABASE_URL: string(),
DATABASE_AUTH_TOKEN: optional(string()), DATABASE_AUTH_TOKEN: optional(string()),
// Server // Server
PORT: optional(string()), PORT: optional(string()),
NODE_ENV: optional(string()), NODE_ENV: optional(string()),
LOG_LEVEL: optional(string()), LOG_LEVEL: optional(string()),
APP_URL: optional(string()), APP_URL: optional(string()),
// Auth // Auth
JWT_SECRET: string(), JWT_SECRET: string(),
SESSION_SECRET: optional(string()), SESSION_SECRET: optional(string()),
// Clerk // Clerk
CLERK_SECRET_KEY: string(), CLERK_SECRET_KEY: string(),
VITE_CLERK_PUBLISHABLE_KEY: string(), VITE_CLERK_PUBLISHABLE_KEY: string(),
// Stripe // Stripe
STRIPE_SECRET_KEY: string(), STRIPE_SECRET_KEY: string(),
STRIPE_WEBHOOK_SECRET: string(), STRIPE_WEBHOOK_SECRET: string(),
// Redis (for BullMQ) // Redis (for BullMQ)
REDIS_URL: optional(string()), REDIS_URL: optional(string()),
// Sentry // Sentry
VITE_SENTRY_DSN: optional(string()), VITE_SENTRY_DSN: optional(string()),
// Email // Email
RESEND_API_KEY: optional(string()), RESEND_API_KEY: optional(string()),
// Push // SMS
FCM_PROJECT_ID: optional(string()), TWILIO_ACCOUNT_SID: optional(string()),
FCM_CLIENT_EMAIL: optional(string()), TWILIO_AUTH_TOKEN: optional(string()),
FCM_PRIVATE_KEY: optional(string()), TWILIO_MESSAGING_SERVICE_SID: optional(string()),
// SMS // External APIs
TWILIO_ACCOUNT_SID: optional(string()), ATTOM_API_KEY: optional(string()),
TWILIO_AUTH_TOKEN: optional(string()), HIBP_API_KEY: optional(string()),
TWILIO_MESSAGING_SERVICE_SID: 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 // WebSocket
ATTOM_API_KEY: optional(string()), WS_PORT: 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()),
}); });
export function validateEnv() { export function validateEnv() {
const result = safeParse(envSchema, { const result = safeParse(envSchema, {
...process.env, ...process.env,
}); });
if (!result.success) { if (!result.success) {
const missingKeys = result.issues const missingKeys = result.issues
.map((issue) => issue.path?.[0]?.key as string | undefined) .map((issue) => issue.path?.[0]?.key as string | undefined)
.filter((k): k is string => k !== undefined); .filter((k): k is string => k !== undefined);
console.error("Environment validation failed:"); console.error("Environment validation failed:");
console.error("Missing required variables:", missingKeys.join(", ")); console.error("Missing required variables:", missingKeys.join(", "));
console.error("\nPlease check .env.example for all required variables."); console.error("\nPlease check .env.example for all required variables.");
process.exit(1); process.exit(1);
} }
return parse(envSchema, { ...process.env }); return parse(envSchema, { ...process.env });
} }
export const env = validateEnv(); export const env = validateEnv();

View File

@@ -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;

View File

@@ -4,108 +4,131 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
const mockBroadcastToUser = vi.fn(); const mockBroadcastToUser = vi.fn();
vi.mock("~/server/websocket", () => ({ vi.mock("~/server/websocket", () => ({
broadcastToUser: mockBroadcastToUser, broadcastToUser: mockBroadcastToUser,
})); }));
const mockSendPush = vi.fn();
const mockSendEmail = vi.fn(); const mockSendEmail = vi.fn();
vi.mock("~/server/services/notification.service", () => ({ vi.mock("~/server/lib/email", () => ({
sendPush: mockSendPush, sendEmail: mockSendEmail,
sendEmail: mockSendEmail,
})); }));
vi.mock("~/server/db", () => ({ vi.mock("~/server/db", () => ({
db: { db: {
select: vi.fn(), select: vi.fn(),
insert: vi.fn(), insert: vi.fn(),
update: vi.fn(), update: vi.fn(),
}, },
})); }));
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe("alert.publisher", () => { describe("alert.publisher", () => {
it("should send alert via WebSocket when user is connected", async () => { it("should send alert via WebSocket when user is connected", async () => {
mockBroadcastToUser.mockReturnValue(true); mockBroadcastToUser.mockReturnValue(true);
const { publishAlert } = await import("./alert.publisher"); const { publishAlert } = await import("./alert.publisher");
await publishAlert("user-1", { await publishAlert("user-1", {
id: "alert-1", id: "alert-1",
title: "Test Alert", title: "Test Alert",
message: "Test message", message: "Test message",
severity: "HIGH", severity: "HIGH",
source: "DARKWATCH", source: "DARKWATCH",
category: "EXPOSURE_DETECTED", category: "EXPOSURE_DETECTED",
createdAt: new Date(), createdAt: new Date(),
}); });
expect(mockBroadcastToUser).toHaveBeenCalledWith("user-1", { expect(mockBroadcastToUser).toHaveBeenCalledWith("user-1", {
type: "alert", type: "alert",
alert: { alert: {
id: "alert-1", id: "alert-1",
title: "Test Alert", title: "Test Alert",
message: "Test message", message: "Test message",
severity: "HIGH", severity: "HIGH",
source: "DARKWATCH", source: "DARKWATCH",
category: "EXPOSURE_DETECTED", category: "EXPOSURE_DETECTED",
createdAt: expect.any(String), createdAt: expect.any(String),
}, },
}); });
expect(mockSendPush).not.toHaveBeenCalled(); expect(mockSendEmail).not.toHaveBeenCalled();
expect(mockSendEmail).not.toHaveBeenCalled(); });
});
it("should fall back to push notification when user is not connected", async () => { it("should fall back to email when user is not connected and has email", async () => {
mockBroadcastToUser.mockReturnValue(false); mockBroadcastToUser.mockReturnValue(false);
mockSendPush.mockResolvedValue({ successCount: 1 });
const { publishAlert } = await import("./alert.publisher"); const db = await import("~/server/db");
await publishAlert("user-1", { (db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
id: "alert-2", from: vi.fn().mockReturnValue({
title: "Offline Alert", where: vi.fn().mockReturnValue({
message: "Offline message", limit: vi
severity: "WARNING", .fn()
source: "VOICEPRINT", .mockResolvedValue([{ id: "user-1", email: "user@example.com" }]),
category: "SYNTHETIC_VOICE", }),
createdAt: new Date(), }),
}); });
expect(mockBroadcastToUser).toHaveBeenCalled(); const { publishAlert } = await import("./alert.publisher");
expect(mockSendPush).toHaveBeenCalledWith( await publishAlert("user-1", {
"user-1", id: "alert-2",
"Offline Alert", title: "Offline Alert",
"Offline message", message: "Offline message",
{ alertId: "alert-2", source: "VOICEPRINT", severity: "WARNING" }, severity: "WARNING",
); source: "VOICEPRINT",
}); category: "SYNTHETIC_VOICE",
createdAt: new Date(),
});
it("should publish alert to multiple users", async () => { expect(mockBroadcastToUser).toHaveBeenCalled();
mockBroadcastToUser.mockReturnValue(false); expect(mockSendEmail).toHaveBeenCalledWith(
mockSendPush.mockResolvedValue({ successCount: 0 }); "user@example.com",
"[Kordant] Offline Alert",
"<p>Offline message</p>",
"Offline message",
);
});
const db = await import("~/server/db"); it("should not send email when user has no email", async () => {
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({ mockBroadcastToUser.mockReturnValue(false);
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
});
const { publishToGroup } = await import("./alert.publisher"); const db = await import("~/server/db");
await publishToGroup(["user-1", "user-2"], { (db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
id: "alert-3", from: vi.fn().mockReturnValue({
title: "Group Alert", where: vi.fn().mockReturnValue({
message: "Group message", limit: vi.fn().mockResolvedValue([]),
severity: "INFO", }),
source: "HOME_TITLE", }),
category: "HOME_TITLE", });
createdAt: new Date(),
});
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);
});
}); });

View File

@@ -1,66 +1,64 @@
import { broadcastToUser } from "~/server/websocket"; 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 { db } from "~/server/db";
import { users } from "~/server/db/schema/auth"; import { users } from "~/server/db/schema/auth";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
export interface PublishableAlert { export interface PublishableAlert {
id: string; id: string;
title: string; title: string;
message: string; message: string;
severity: string; severity: string;
source: string; source: string;
category: string; category: string;
createdAt: Date; createdAt: Date;
} }
export async function publishAlert(userId: string, alert: PublishableAlert): Promise<void> { export async function publishAlert(
const message = { userId: string,
type: "alert" as const, alert: PublishableAlert,
alert: { ): Promise<void> {
id: alert.id, const message = {
title: alert.title, type: "alert" as const,
message: alert.message, alert: {
severity: alert.severity, id: alert.id,
source: alert.source, title: alert.title,
category: alert.category, message: alert.message,
createdAt: alert.createdAt.toISOString(), 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) { if (!sent) {
try { const [user] = await db
const pushResult = await sendPush(userId, alert.title, alert.message, { .select()
alertId: alert.id, .from(users)
source: alert.source, .where(eq(users.id, userId))
severity: alert.severity, .limit(1);
});
if (pushResult.successCount === 0) { if (user?.email) {
const [user] = await db try {
.select() await sendEmail(
.from(users) user.email,
.where(eq(users.id, userId)) `[Kordant] ${alert.title}`,
.limit(1); `<p>${alert.message}</p>`,
alert.message,
if (user?.email) { );
await sendEmail( } catch (err) {
user.email, console.error("[alert.publisher] Email notification failed:", err);
`[Kordant] ${alert.title}`, }
`<p>${alert.message}</p>`, }
alert.message, }
);
}
}
} catch (err) {
console.error("[alert.publisher] Fallback notification failed:", err);
}
}
} }
export async function publishToGroup(userIds: string[], alert: PublishableAlert): Promise<void> { export async function publishToGroup(
const promises = userIds.map((userId) => publishAlert(userId, alert)); userIds: string[],
await Promise.allSettled(promises); alert: PublishableAlert,
): Promise<void> {
const promises = userIds.map((userId) => publishAlert(userId, alert));
await Promise.allSettled(promises);
} }

View File

@@ -1,28 +1,28 @@
import { eq, and, asc } from "drizzle-orm"; import { eq, and, asc } from "drizzle-orm";
import { db } from "~/server/db"; import { db } from "~/server/db";
import { digestAlerts, notificationPreferences } from "~/server/db/schema"; import { digestAlerts, notificationPreferences } from "~/server/db/schema";
import { sendEmail } from "~/server/services/notification.service"; import { sendEmail } from "~/server/lib/email";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Digest configuration // Digest configuration
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface DigestConfig { export interface DigestConfig {
/** Severity levels that get batched into digest (vs immediate) */ /** Severity levels that get batched into digest (vs immediate) */
batchedSeverities: string[]; batchedSeverities: string[];
/** Digest frequency: "daily" or "weekly" */ /** Digest frequency: "daily" or "weekly" */
frequency: "daily" | "weekly"; frequency: "daily" | "weekly";
/** Time of day for daily digest (UTC hour) */ /** Time of day for daily digest (UTC hour) */
dailyHour: number; dailyHour: number;
/** Day of week for weekly digest (0=Sun) */ /** Day of week for weekly digest (0=Sun) */
weeklyDay: number; weeklyDay: number;
} }
export const DEFAULT_DIGEST_CONFIG: DigestConfig = { export const DEFAULT_DIGEST_CONFIG: DigestConfig = {
batchedSeverities: ["info"], batchedSeverities: ["info"],
frequency: "daily", frequency: "daily",
dailyHour: 9, // 9 AM UTC dailyHour: 9, // 9 AM UTC
weeklyDay: 0, // Sunday weeklyDay: 0, // Sunday
}; };
/** /**
@@ -30,75 +30,77 @@ export const DEFAULT_DIGEST_CONFIG: DigestConfig = {
* and user preferences. * and user preferences.
*/ */
export async function shouldDigest( export async function shouldDigest(
userId: string, userId: string,
severity: string, severity: string,
): Promise<boolean> { ): Promise<boolean> {
const [prefs] = await db const [prefs] = await db
.select() .select()
.from(notificationPreferences) .from(notificationPreferences)
.where(eq(notificationPreferences.userId, userId)) .where(eq(notificationPreferences.userId, userId))
.limit(1); .limit(1);
// If user has no prefs, use defaults: info = digest, warning/critical = immediate // If user has no prefs, use defaults: info = digest, warning/critical = immediate
if (!prefs) { if (!prefs) {
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity); return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
} }
// If email is disabled entirely, don't digest (alert won't be delivered) // If email is disabled entirely, don't digest (alert won't be delivered)
if (!prefs.emailEnabled) { if (!prefs.emailEnabled) {
return false; 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. * Calculates the next scheduled digest date based on config.
*/ */
export function calculateNextDigestDate(config: DigestConfig = DEFAULT_DIGEST_CONFIG): Date { export function calculateNextDigestDate(
const now = new Date(); config: DigestConfig = DEFAULT_DIGEST_CONFIG,
const next = new Date(now); ): Date {
const now = new Date();
const next = new Date(now);
if (config.frequency === "daily") { if (config.frequency === "daily") {
next.setUTCHours(config.dailyHour, 0, 0, 0); next.setUTCHours(config.dailyHour, 0, 0, 0);
if (next.getTime() <= now.getTime()) { if (next.getTime() <= now.getTime()) {
next.setUTCDate(next.getUTCDate() + 1); next.setUTCDate(next.getUTCDate() + 1);
} }
} else { } else {
next.setUTCHours(config.dailyHour, 0, 0, 0); next.setUTCHours(config.dailyHour, 0, 0, 0);
const currentDay = next.getUTCDay(); const currentDay = next.getUTCDay();
const daysUntilTarget = (config.weeklyDay - currentDay + 7) % 7; const daysUntilTarget = (config.weeklyDay - currentDay + 7) % 7;
if (daysUntilTarget === 0 && next.getTime() <= now.getTime()) { if (daysUntilTarget === 0 && next.getTime() <= now.getTime()) {
next.setUTCDate(next.getUTCDate() + 7); next.setUTCDate(next.getUTCDate() + 7);
} else if (daysUntilTarget > 0 || next.getTime() <= now.getTime()) { } else if (daysUntilTarget > 0 || next.getTime() <= now.getTime()) {
next.setUTCDate(next.getUTCDate() + daysUntilTarget); next.setUTCDate(next.getUTCDate() + daysUntilTarget);
} }
} }
return next; return next;
} }
/** /**
* Queues an alert for the next digest email. * Queues an alert for the next digest email.
*/ */
export async function queueForDigest( export async function queueForDigest(
userId: string, userId: string,
alertId: string, alertId: string,
title: string, title: string,
severity: string, severity: string,
source: string, source: string,
): Promise<void> { ): Promise<void> {
const nextDigestDate = calculateNextDigestDate(); const nextDigestDate = calculateNextDigestDate();
await db.insert(digestAlerts).values({ await db.insert(digestAlerts).values({
userId, userId,
alertId, alertId,
title, title,
severity, severity,
source, source,
scheduledDigestDate: nextDigestDate, scheduledDigestDate: nextDigestDate,
sent: false, sent: false,
}); });
} }
/** /**
@@ -106,71 +108,75 @@ export async function queueForDigest(
* Returns the number of alerts included in the digest. * Returns the number of alerts included in the digest.
*/ */
export async function sendDigestEmail( export async function sendDigestEmail(
userId: string, userId: string,
scheduledDate: Date, scheduledDate: Date,
): Promise<number> { ): Promise<number> {
const pendingAlerts = await db const pendingAlerts = await db
.select() .select()
.from(digestAlerts) .from(digestAlerts)
.where( .where(
and( and(
eq(digestAlerts.userId, userId), eq(digestAlerts.userId, userId),
eq(digestAlerts.sent, false), eq(digestAlerts.sent, false),
eq(digestAlerts.scheduledDigestDate, scheduledDate), eq(digestAlerts.scheduledDigestDate, scheduledDate),
), ),
) )
.orderBy(asc(digestAlerts.severity)); .orderBy(asc(digestAlerts.severity));
if (!pendingAlerts.length) { if (!pendingAlerts.length) {
return 0; return 0;
} }
// Get user email // Get user email
const { users } = await import("~/server/db/schema/auth"); const { users } = await import("~/server/db/schema/auth");
const [user] = await db const [user] = await db
.select({ email: users.email }) .select({ email: users.email })
.from(users) .from(users)
.where(eq(users.id, userId)) .where(eq(users.id, userId))
.limit(1); .limit(1);
if (!user?.email) { if (!user?.email) {
console.warn(`[digest] No email found for user ${userId}`); console.warn(`[digest] No email found for user ${userId}`);
return 0; return 0;
} }
// Build digest email content // Build digest email content
const alertsBySeverity = groupBySeverity(pendingAlerts); const alertsBySeverity = groupBySeverity(pendingAlerts);
const html = buildDigestEmailHTML(alertsBySeverity, pendingAlerts.length); const html = buildDigestEmailHTML(alertsBySeverity, pendingAlerts.length);
try { try {
await sendEmail( await sendEmail(
user.email, user.email,
`[Kordant] Security Digest — ${pendingAlerts.length} alert${pendingAlerts.length > 1 ? "s" : ""}`, `[Kordant] Security Digest — ${pendingAlerts.length} alert${pendingAlerts.length > 1 ? "s" : ""}`,
html, html,
buildDigestPlainText(alertsBySeverity, pendingAlerts.length), buildDigestPlainText(alertsBySeverity, pendingAlerts.length),
); );
// Mark alerts as sent // Mark alerts as sent
const alertIds = pendingAlerts.map((a) => a.id); const alertIds = pendingAlerts.map((a) => a.id);
await db await db
.update(digestAlerts) .update(digestAlerts)
.set({ sent: true, sentAt: new Date() }) .set({ sent: true, sentAt: new Date() })
.where(and(eq(digestAlerts.userId, userId), eq(digestAlerts.id, alertIds[0]))); .where(
and(eq(digestAlerts.userId, userId), eq(digestAlerts.id, alertIds[0])),
);
// Update all matching alerts // Update all matching alerts
for (const alertId of alertIds) { for (const alertId of alertIds) {
await db await db
.update(digestAlerts) .update(digestAlerts)
.set({ sent: true, sentAt: new Date() }) .set({ sent: true, sentAt: new Date() })
.where(eq(digestAlerts.id, alertId)); .where(eq(digestAlerts.id, alertId));
} }
console.log(`[digest] Sent digest to ${user.email} with ${pendingAlerts.length} alerts`); console.log(
return pendingAlerts.length; `[digest] Sent digest to ${user.email} with ${pendingAlerts.length} alerts`,
} catch (err) { );
console.error(`[digest] Failed to send digest for user ${userId}:`, err); return pendingAlerts.length;
return 0; } 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. * Called by the digest job scheduler.
*/ */
export async function processDueDigests(): Promise<void> { export async function processDueDigests(): Promise<void> {
const now = new Date(); const now = new Date();
const today = new Date(now.toISOString().split("T")[0]); const today = new Date(now.toISOString().split("T")[0]);
const tomorrow = new Date(today); const tomorrow = new Date(today);
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
// Find all users with pending digests due today // Find all users with pending digests due today
const { users } = await import("~/server/db/schema/auth"); const { users } = await import("~/server/db/schema/auth");
// Get distinct userIds with pending digests // Get distinct userIds with pending digests
const pendingDigests = await db const pendingDigests = await db
.select({ .select({
userId: digestAlerts.userId, userId: digestAlerts.userId,
scheduledDate: digestAlerts.scheduledDigestDate, scheduledDate: digestAlerts.scheduledDigestDate,
}) })
.from(digestAlerts) .from(digestAlerts)
.where( .where(and(eq(digestAlerts.sent, false)));
and(
eq(digestAlerts.sent, false),
),
);
// Group by user // Group by user
const userMap = new Map<string, Date[]>(); const userMap = new Map<string, Date[]>();
for (const d of pendingDigests) { for (const d of pendingDigests) {
const dates = userMap.get(d.userId) ?? []; const dates = userMap.get(d.userId) ?? [];
dates.push(d.scheduledDate); dates.push(d.scheduledDate);
userMap.set(d.userId, dates); userMap.set(d.userId, dates);
} }
for (const [userId, dates] of userMap) { for (const [userId, dates] of userMap) {
for (const date of [...new Set(dates)]) { for (const date of [...new Set(dates)]) {
if (date.getTime() <= now.getTime()) { if (date.getTime() <= now.getTime()) {
await sendDigestEmail(userId, date); await sendDigestEmail(userId, date);
} }
} }
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -221,62 +223,62 @@ export async function processDueDigests(): Promise<void> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function groupBySeverity( function groupBySeverity(
alerts: typeof digestAlerts.$InferInsert[], alerts: (typeof digestAlerts.$InferInsert)[],
): Record<string, typeof digestAlerts.$InferInsert[]> { ): Record<string, (typeof digestAlerts.$InferInsert)[]> {
const groups: Record<string, typeof digestAlerts.$InferInsert[]> = { const groups: Record<string, (typeof digestAlerts.$InferInsert)[]> = {
critical: [], critical: [],
warning: [], warning: [],
info: [], info: [],
}; };
for (const alert of alerts) { for (const alert of alerts) {
const key = alert.severity ?? "info"; const key = alert.severity ?? "info";
if (groups[key]) { if (groups[key]) {
groups[key].push(alert); groups[key].push(alert);
} else { } else {
groups.info.push(alert); groups.info.push(alert);
} }
} }
return groups; return groups;
} }
function buildDigestEmailHTML( function buildDigestEmailHTML(
groups: Record<string, typeof digestAlerts.$InferInsert[]>, groups: Record<string, (typeof digestAlerts.$InferInsert)[]>,
total: number, total: number,
): string { ): string {
const sections = []; const sections = [];
const severityConfig = [ const severityConfig = [
{ key: "critical", label: "Critical", color: "#dc2626", bg: "#fef2f2" }, { key: "critical", label: "Critical", color: "#dc2626", bg: "#fef2f2" },
{ key: "warning", label: "Warning", color: "#d97706", bg: "#fffbeb" }, { key: "warning", label: "Warning", color: "#d97706", bg: "#fffbeb" },
{ key: "info", label: "Info", color: "#2563eb", bg: "#eff6ff" }, { key: "info", label: "Info", color: "#2563eb", bg: "#eff6ff" },
]; ];
for (const { key, label, color, bg } of severityConfig) { for (const { key, label, color, bg } of severityConfig) {
const alerts = groups[key]; const alerts = groups[key];
if (!alerts.length) continue; if (!alerts.length) continue;
const rows = alerts const rows = alerts
.map( .map(
(a) => (a) =>
`<tr style="border-bottom:1px solid #eee"> `<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"><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">${escapeHtml(a.title)}</td>
<td style="padding:8px 12px;color:#666;font-size:12px">${escapeHtml(a.source)}</td> <td style="padding:8px 12px;color:#666;font-size:12px">${escapeHtml(a.source)}</td>
</tr>`, </tr>`,
) )
.join(""); .join("");
sections.push(` sections.push(`
<div style="margin:16px 0;padding:12px;background:${bg};border-radius:8px;border-left:4px solid ${color}"> <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> <h3 style="margin:0 0 8px 0;color:${color}">${label} (${alerts.length})</h3>
<table style="width:100%;border-collapse:collapse">${rows}</table> <table style="width:100%;border-collapse:collapse">${rows}</table>
</div> </div>
`); `);
} }
return ` return `
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px"> <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> <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> <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( function buildDigestPlainText(
groups: Record<string, typeof digestAlerts.$InferInsert[]>, groups: Record<string, (typeof digestAlerts.$InferInsert)[]>,
total: number, total: number,
): string { ): 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)) { for (const [key, alerts] of Object.entries(groups)) {
if (!alerts.length) continue; if (!alerts.length) continue;
lines.push(`${key.toUpperCase()} (${alerts.length}):`); lines.push(`${key.toUpperCase()} (${alerts.length}):`);
for (const a of alerts) { for (const a of alerts) {
lines.push(` - ${a.title} [${a.source}]`); lines.push(` - ${a.title} [${a.source}]`);
} }
lines.push(""); lines.push("");
} }
lines.push("This is an automated digest from Kordant."); lines.push("This is an automated digest from Kordant.");
return lines.join("\n"); return lines.join("\n");
} }
function escapeHtml(str: string): string { function escapeHtml(str: string): string {
return str return str
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
/** /**
* Cleans up old digest records (older than 30 days). * Cleans up old digest records (older than 30 days).
*/ */
export async function cleanupOldDigests(): Promise<void> { 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 await db.delete(digestAlerts).where(and(eq(digestAlerts.sent, true)));
.delete(digestAlerts)
.where(
and(
eq(digestAlerts.sent, true),
),
);
console.log(`[digest] Cleaned up old digest records`); console.log(`[digest] Cleaned up old digest records`);
} }

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -17,67 +17,67 @@ const __dirname = path.dirname(__filename);
// ── Types ────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────
export interface TextClassification { export interface TextClassification {
isSpam: boolean; isSpam: boolean;
confidence: number; confidence: number;
score: number; score: number;
modelVersion?: string; modelVersion?: string;
} }
export interface ClassificationThresholds { export interface ClassificationThresholds {
strict: number; // 0.3 - flag more aggressively strict: number; // 0.3 - flag more aggressively
moderate: number; // 0.5 - balanced moderate: number; // 0.5 - balanced
lenient: number; // 0.7 - fewer false positives lenient: number; // 0.7 - fewer false positives
} }
export type ThresholdMode = "strict" | "moderate" | "lenient"; export type ThresholdMode = "strict" | "moderate" | "lenient";
const DEFAULT_THRESHOLDS: ClassificationThresholds = { const DEFAULT_THRESHOLDS: ClassificationThresholds = {
strict: 0.3, strict: 0.3,
moderate: 0.5, moderate: 0.5,
lenient: 0.7, lenient: 0.7,
}; };
// ── Model Singleton ──────────────────────────────────────────────────────── // ── Model Singleton ────────────────────────────────────────────────────────
interface ModelState { interface ModelState {
session: InferenceSession | null; session: InferenceSession | null;
tokenizer: BertTokenizer; tokenizer: BertTokenizer;
metadata: ModelMetadata; metadata: ModelMetadata;
loaded: boolean; loaded: boolean;
loadError: Error | null; loadError: Error | null;
} }
interface ModelMetadata { interface ModelMetadata {
version: string; version: string;
model_name: string; model_name: string;
task: string; task: string;
max_length: number; max_length: number;
num_labels: number; num_labels: number;
label2id: Record<string, number>; label2id: Record<string, number>;
id2label: Record<number, string>; id2label: Record<number, string>;
} }
const modelState: ModelState = { const modelState: ModelState = {
session: null, session: null,
tokenizer: null as unknown as BertTokenizer, tokenizer: null as unknown as BertTokenizer,
metadata: { metadata: {
version: "0.0.0", version: "0.0.0",
model_name: "", model_name: "",
task: "", task: "",
max_length: 128, max_length: 128,
num_labels: 2, num_labels: 2,
label2id: {}, label2id: {},
id2label: {}, id2label: {},
}, },
loaded: false, loaded: false,
loadError: null, loadError: null,
}; };
// ── Result Cache ─────────────────────────────────────────────────────────── // ── Result Cache ───────────────────────────────────────────────────────────
interface CacheEntry { interface CacheEntry {
result: TextClassification; result: TextClassification;
timestamp: number; timestamp: number;
} }
const resultCache = new Map<string, CacheEntry>(); const resultCache = new Map<string, CacheEntry>();
@@ -85,305 +85,471 @@ const CACHE_MAX_SIZE = 1000;
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
function cacheKey(text: string): string { function cacheKey(text: string): string {
// Simple hash of normalized text // Simple hash of normalized text
const normalized = text.toLowerCase().trim(); const normalized = text.toLowerCase().trim();
let hash = 0; let hash = 0;
for (let i = 0; i < normalized.length; i++) { for (let i = 0; i < normalized.length; i++) {
const char = normalized.charCodeAt(i); const char = normalized.charCodeAt(i);
hash = ((hash << 5) - hash) + char; hash = (hash << 5) - hash + char;
hash |= 0; // Convert to 32bit integer hash |= 0; // Convert to 32bit integer
} }
return String(hash); return String(hash);
} }
function getCached(text: string): TextClassification | null { function getCached(text: string): TextClassification | null {
const key = cacheKey(text); const key = cacheKey(text);
const entry = resultCache.get(key); const entry = resultCache.get(key);
if (!entry) return null; if (!entry) return null;
if (Date.now() - entry.timestamp > CACHE_TTL_MS) { if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
resultCache.delete(key); resultCache.delete(key);
return null; return null;
} }
return entry.result; return entry.result;
} }
function setCache(text: string, result: TextClassification): void { function setCache(text: string, result: TextClassification): void {
if (resultCache.size >= CACHE_MAX_SIZE) { if (resultCache.size >= CACHE_MAX_SIZE) {
// Evict oldest entry // Evict oldest entry
const oldestKey = resultCache.keys().next().value; const oldestKey = resultCache.keys().next().value;
resultCache.delete(oldestKey); resultCache.delete(oldestKey);
} }
resultCache.set(cacheKey(text), { result, timestamp: Date.now() }); resultCache.set(cacheKey(text), { result, timestamp: Date.now() });
} }
// ── BertTokenizer (JavaScript implementation) ────────────────────────────── // ── BertTokenizer (JavaScript implementation) ──────────────────────────────
interface TokenizerConfig { interface TokenizerConfig {
vocab: Map<string, number>; vocab: Map<string, number>;
inv_vocab: Map<number, string>; inv_vocab: Map<number, string>;
max_len: number; max_len: number;
do_lower_case: boolean; do_lower_case: boolean;
tokenizers: Record<string, unknown>; tokenizers: Record<string, unknown>;
model_max_length: number; model_max_length: number;
} }
class BertTokenizer { class BertTokenizer {
private config: TokenizerConfig; private config: TokenizerConfig;
constructor(configPath: string) { constructor(configPath: string) {
this.config = this.loadConfig(configPath); this.config = this.loadConfig(configPath);
} }
private loadConfig(configPath: string): TokenizerConfig { private loadConfig(configPath: string): TokenizerConfig {
const vocabPath = path.join(configPath, "vocab.txt"); const vocabPath = path.join(configPath, "vocab.txt");
const tokenizerConfigPath = path.join(configPath, "tokenizer_config.json"); const tokenizerConfigPath = path.join(configPath, "tokenizer_config.json");
// Load vocabulary // Load vocabulary
const vocab = new Map<string, number>(); const vocab = new Map<string, number>();
const inv_vocab = new Map<number, string>(); const inv_vocab = new Map<number, string>();
const vocabText = fs.readFileSync(vocabPath, "utf-8"); const vocabText = fs.readFileSync(vocabPath, "utf-8");
const lines = vocabText.split("\n"); const lines = vocabText.split("\n");
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const token = lines[i].trim(); const token = lines[i].trim();
if (token) { if (token) {
vocab.set(token, i); vocab.set(token, i);
inv_vocab.set(i, token); inv_vocab.set(i, token);
} }
} }
// Load tokenizer config // Load tokenizer config
let doLowercase = true; let doLowercase = true;
let modelMaxLength = 512; let modelMaxLength = 512;
try { try {
const configData = JSON.parse(fs.readFileSync(tokenizerConfigPath, "utf-8")); const configData = JSON.parse(
doLowercase = configData.do_lower_case ?? true; fs.readFileSync(tokenizerConfigPath, "utf-8"),
modelMaxLength = configData.model_max_length ?? 512; );
} catch { doLowercase = configData.do_lower_case ?? true;
// Use defaults modelMaxLength = configData.model_max_length ?? 512;
} } catch {
// Use defaults
}
return { return {
vocab, vocab,
inv_vocab, inv_vocab,
max_len: 512, max_len: 512,
do_lower_case: doLowercase, do_lower_case: doLowercase,
tokenizers: {}, tokenizers: {},
model_max_length: modelMaxLength, model_max_length: modelMaxLength,
}; };
} }
private whitespace_tokenize(text: string): string[] { private whitespace_tokenize(text: string): string[] {
if (this.config.do_lower_case) { if (this.config.do_lower_case) {
text = text.toLowerCase(); text = text.toLowerCase();
} }
// Split on whitespace, keeping punctuation attached // Split on whitespace, keeping punctuation attached
return text.split(/\s+/).filter((t) => t.length > 0); return text.split(/\s+/).filter((t) => t.length > 0);
} }
private wordpiece_tokenize(token: string, maxOutputTokens: number = 20): string[] { private wordpiece_tokenize(
const outputTokens: string[] = []; token: string,
let isBad = false; maxOutputTokens: number = 20,
let start = 0; ): string[] {
let subToken: string | null = null; const outputTokens: string[] = [];
let isBad = false;
let start = 0;
let subToken: string | null = null;
while (start < token.length && !isBad && outputTokens.length < maxOutputTokens) { while (
let found = false; start < token.length &&
!isBad &&
outputTokens.length < maxOutputTokens
) {
let found = false;
for (let end = token.length; end > start; end--) { for (let end = token.length; end > start; end--) {
let substr = token.substring(start, end); let substr = token.substring(start, end);
if (start > 0) { if (start > 0) {
substr = "##" + substr; substr = "##" + substr;
} }
if (this.config.vocab.has(substr)) { if (this.config.vocab.has(substr)) {
outputTokens.push(substr); outputTokens.push(substr);
subToken = substr; subToken = substr;
start = end; start = end;
found = true; found = true;
break; break;
} }
} }
if (!found) { if (!found) {
isBad = true; isBad = true;
} }
} }
if (isBad) { if (isBad) {
outputTokens.push("[UNK]"); outputTokens.push("[UNK]");
} else if (subToken === null) { } else if (subToken === null) {
outputTokens.push("[UNK]"); outputTokens.push("[UNK]");
} }
return outputTokens; return outputTokens;
} }
private tokenize(text: string): string[] { private tokenize(text: string): string[] {
const tokens = []; const tokens = [];
const whitespaceTokens = this.whitespace_tokenize(text); const whitespaceTokens = this.whitespace_tokenize(text);
for (const token of whitespaceTokens) { for (const token of whitespaceTokens) {
const subTokens = this.wordpiece_tokenize(token); const subTokens = this.wordpiece_tokenize(token);
tokens.push(...subTokens); tokens.push(...subTokens);
} }
return tokens; return tokens;
} }
encode(text: string, maxLen: number = 128): { inputIds: number[]; attentionMask: number[] } { encode(
const tokens = this.tokenize(text); text: string,
maxLen: number = 128,
): { inputIds: number[]; attentionMask: number[] } {
const tokens = this.tokenize(text);
// Add [CLS] and [SEP] // Add [CLS] and [SEP]
const allTokens = ["[CLS]", ...tokens.slice(0, maxLen - 2), "[SEP]"]; const allTokens = ["[CLS]", ...tokens.slice(0, maxLen - 2), "[SEP]"];
const inputIds = allTokens.map((t) => this.config.vocab.get(t) ?? 100); // 100 = [UNK] const inputIds = allTokens.map((t) => this.config.vocab.get(t) ?? 100); // 100 = [UNK]
const attentionMask = new Array(inputIds.length).fill(1); const attentionMask = new Array(inputIds.length).fill(1);
// Pad to maxLen if needed // Pad to maxLen if needed
while (inputIds.length < maxLen) { while (inputIds.length < maxLen) {
inputIds.push(0); inputIds.push(0);
attentionMask.push(0); attentionMask.push(0);
} }
return { inputIds, attentionMask }; return { inputIds, attentionMask };
} }
} }
// ── Model Loading ────────────────────────────────────────────────────────── // ── Model Loading ──────────────────────────────────────────────────────────
const MODEL_DIR_ENV = "SPAM_MODEL_DIR"; 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 { 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> { async function loadModel(): Promise<void> {
if (modelState.loaded) return; if (modelState.loaded) return;
try { try {
const modelDir = getModelDir(); const modelDir = getModelDir();
console.log(`[spamshield] Loading ONNX model from: ${modelDir}`); console.log(`[spamshield] Loading ONNX model from: ${modelDir}`);
// Load metadata // Download model files if missing (production/Vercel path)
const metadataPath = path.join(modelDir, "model_metadata.json"); await downloadModelIfMissing(modelDir);
if (fs.existsSync(metadataPath)) {
modelState.metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
console.log(`[spamshield] Model version: ${modelState.metadata.version}`);
}
// Load tokenizer // Load metadata
modelState.tokenizer = new BertTokenizer(modelDir); const metadataPath = path.join(modelDir, "model_metadata.json");
console.log("[spamshield] Tokenizer loaded"); if (fs.existsSync(metadataPath)) {
modelState.metadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8"));
console.log(`[spamshield] Model version: ${modelState.metadata.version}`);
}
// Load ONNX model // Load tokenizer
const modelPath = path.join(modelDir, "model.onnx"); modelState.tokenizer = new BertTokenizer(modelDir);
if (!fs.existsSync(modelPath)) { console.log("[spamshield] Tokenizer loaded");
// 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.session = await ort.InferenceSession.create(modelPath); // Load ONNX model
console.log("[spamshield] ONNX session created"); const modelPath = path.join(modelDir, "model.onnx");
console.log(`[spamshield] Inputs: ${modelState.session.inputNames.join(", ")}`); if (!fs.existsSync(modelPath)) {
console.log(`[spamshield] Outputs: ${modelState.session.outputNames.join(", ")}`); // 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; modelState.session = await ort.InferenceSession.create(modelPath);
console.log("[spamshield] Model loaded successfully"); console.log("[spamshield] ONNX session created");
} catch (err) { console.log(
modelState.loadError = err instanceof Error ? err : new Error(String(err)); `[spamshield] Inputs: ${modelState.session.inputNames.join(", ")}`,
console.error("[spamshield] Failed to load ONNX model:", modelState.loadError); );
console.log("[spamshield] Falling back to rule engine for classification"); 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 ────────────────────────────────────────────────────────────── // ── Inference ──────────────────────────────────────────────────────────────
function sigmoid(x: number): number { function sigmoid(x: number): number {
return 1 / (1 + Math.exp(-x)); return 1 / (1 + Math.exp(-x));
} }
async function runInference( async function runInference(
text: string, text: string,
thresholdMode: ThresholdMode = "moderate", thresholdMode: ThresholdMode = "moderate",
): Promise<TextClassification> { ): Promise<TextClassification> {
const thresholds = DEFAULT_THRESHOLDS; const thresholds = DEFAULT_THRESHOLDS;
const threshold = thresholds[thresholdMode]; const threshold = thresholds[thresholdMode];
// Check cache first // Check cache first
const cached = getCached(text); const cached = getCached(text);
if (cached) { if (cached) {
return { ...cached, modelVersion: modelState.metadata.version }; return { ...cached, modelVersion: modelState.metadata.version };
} }
// Ensure model is loaded // Ensure model is loaded
if (!modelState.loaded || !modelState.session) { if (!modelState.loaded || !modelState.session) {
await loadModel(); await loadModel();
} }
// If model still not loaded, return fallback // If model still not loaded, return fallback
if (!modelState.loaded || !modelState.session) { if (!modelState.loaded || !modelState.session) {
const fallback: TextClassification = { const fallback: TextClassification = {
isSpam: false, isSpam: false,
confidence: 0, confidence: 0,
score: 0, score: 0,
modelVersion: "fallback", modelVersion: "fallback",
}; };
setCache(text, fallback); setCache(text, fallback);
return fallback; return fallback;
} }
// Tokenize // Tokenize
const maxLen = modelState.metadata.max_length || 128; const maxLen = modelState.metadata.max_length || 128;
const { inputIds, attentionMask } = modelState.tokenizer.encode(text, maxLen); const { inputIds, attentionMask } = modelState.tokenizer.encode(text, maxLen);
// Create ONNX tensors (int64 requires BigInt values) // Create ONNX tensors (int64 requires BigInt values)
const inputIdsBigInt = new BigInt64Array(inputIds.length); const inputIdsBigInt = new BigInt64Array(inputIds.length);
for (let i = 0; i < inputIds.length; i++) { for (let i = 0; i < inputIds.length; i++) {
inputIdsBigInt[i] = BigInt(inputIds[i]); inputIdsBigInt[i] = BigInt(inputIds[i]);
} }
const attentionMaskBigInt = new BigInt64Array(attentionMask.length); const attentionMaskBigInt = new BigInt64Array(attentionMask.length);
for (let i = 0; i < attentionMask.length; i++) { for (let i = 0; i < attentionMask.length; i++) {
attentionMaskBigInt[i] = BigInt(attentionMask[i]); attentionMaskBigInt[i] = BigInt(attentionMask[i]);
} }
const inputIdsTensor = new ort.Tensor("int64", inputIdsBigInt, [1, maxLen]); const inputIdsTensor = new ort.Tensor("int64", inputIdsBigInt, [1, maxLen]);
const attentionMaskTensor = new ort.Tensor("int64", attentionMaskBigInt, [1, maxLen]); const attentionMaskTensor = new ort.Tensor("int64", attentionMaskBigInt, [
1,
maxLen,
]);
// Run inference // Run inference
const feeds: Record<string, Tensor> = { const feeds: Record<string, Tensor> = {
input_ids: inputIdsTensor, input_ids: inputIdsTensor,
attention_mask: attentionMaskTensor, attention_mask: attentionMaskTensor,
}; };
const outputs = await modelState.session.run(feeds); const outputs = await modelState.session.run(feeds);
const logits = outputs[modelState.session.outputNames[0]]; const logits = outputs[modelState.session.outputNames[0]];
// Extract logits (shape: [1, num_labels]) // Extract logits (shape: [1, num_labels])
const logitsData = logits.data as Float32Array | number[]; const logitsData = logits.data as Float32Array | number[];
const spamLogit = logitsData[1] ?? 0; const spamLogit = logitsData[1] ?? 0;
const hamLogit = logitsData[0] ?? 0; const hamLogit = logitsData[0] ?? 0;
// Apply sigmoid to get probability // Apply sigmoid to get probability
const spamProb = sigmoid(spamLogit); const spamProb = sigmoid(spamLogit);
const hamProb = sigmoid(hamLogit); const hamProb = sigmoid(hamLogit);
// Binary decision based on threshold // Binary decision based on threshold
const isSpam = spamProb >= threshold; const isSpam = spamProb >= threshold;
const confidence = isSpam ? spamProb : 1 - spamProb; const confidence = isSpam ? spamProb : 1 - spamProb;
const result: TextClassification = { const result: TextClassification = {
isSpam, isSpam,
confidence: Math.round(confidence * 10000) / 10000, confidence: Math.round(confidence * 10000) / 10000,
score: Math.round(spamProb * 10000) / 10000, score: Math.round(spamProb * 10000) / 10000,
modelVersion: modelState.metadata.version, modelVersion: modelState.metadata.version,
}; };
setCache(text, result); setCache(text, result);
return result; return result;
} }
// ── Public API ───────────────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────────────
@@ -393,21 +559,21 @@ async function runInference(
* Falls back to returning a safe default if the model fails to load. * Falls back to returning a safe default if the model fails to load.
*/ */
export async function classifyTextBERT( export async function classifyTextBERT(
text: string, text: string,
thresholdMode: ThresholdMode = "moderate", thresholdMode: ThresholdMode = "moderate",
): Promise<TextClassification> { ): Promise<TextClassification> {
try { try {
return await runInference(text, thresholdMode); return await runInference(text, thresholdMode);
} catch (err) { } catch (err) {
console.error("[spamshield] ONNX inference error:", err); console.error("[spamshield] ONNX inference error:", err);
// Graceful fallback: return non-spam with low confidence // Graceful fallback: return non-spam with low confidence
return { return {
isSpam: false, isSpam: false,
confidence: 0, confidence: 0,
score: 0, score: 0,
modelVersion: "error", modelVersion: "error",
}; };
} }
} }
/** /**
@@ -415,41 +581,41 @@ export async function classifyTextBERT(
* Call this once during server initialization. * Call this once during server initialization.
*/ */
export async function initSpamModel(): Promise<boolean> { export async function initSpamModel(): Promise<boolean> {
await loadModel(); await loadModel();
return modelState.loaded; return modelState.loaded;
} }
/** /**
* Check if the model is loaded and ready. * Check if the model is loaded and ready.
*/ */
export function isModelLoaded(): boolean { export function isModelLoaded(): boolean {
return modelState.loaded && modelState.session !== null; return modelState.loaded && modelState.session !== null;
} }
/** /**
* Get model metadata. * Get model metadata.
*/ */
export function getModelInfo(): ModelMetadata { export function getModelInfo(): ModelMetadata {
return { ...modelState.metadata }; return { ...modelState.metadata };
} }
/** /**
* Get the current cache stats. * Get the current cache stats.
*/ */
export function getCacheStats(): { size: number; max: number } { 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. * Clear the result cache.
*/ */
export function clearCache(): void { export function clearCache(): void {
resultCache.clear(); resultCache.clear();
} }
/** /**
* Get available threshold modes and their values. * Get available threshold modes and their values.
*/ */
export function getThresholds(): ClassificationThresholds { export function getThresholds(): ClassificationThresholds {
return { ...DEFAULT_THRESHOLDS }; return { ...DEFAULT_THRESHOLDS };
} }

View File

@@ -8,539 +8,417 @@ import { createSession } from "~/server/auth/session";
import { signJWT } from "~/server/auth/jwt"; import { signJWT } from "~/server/auth/jwt";
export async function createUserWithPassword( export async function createUserWithPassword(
name: string, name: string,
email: string, email: string,
password: string, password: string,
) { ) {
const [existing] = await db const [existing] = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.email, email)) .where(eq(users.email, email))
.limit(1); .limit(1);
if (existing) { if (existing) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Email already in use", message: "Email already in use",
}); });
} }
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const [user] = await db const [user] = await db
.insert(users) .insert(users)
.values({ name, email, passwordHash }) .values({ name, email, passwordHash })
.returning(); .returning();
const session = await createSession(user.id); const session = await createSession(user.id);
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" }); 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( export async function authenticateUser(email: string, password: string) {
email: string, const [user] = await db
password: string, .select()
) { .from(users)
const [user] = await db .where(eq(users.email, email))
.select() .limit(1);
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user || !user.passwordHash) { if (!user || !user.passwordHash) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid email or password", message: "Invalid email or password",
}); });
} }
const valid = await verifyPassword(password, user.passwordHash); const valid = await verifyPassword(password, user.passwordHash);
if (!valid) { if (!valid) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid email or password", message: "Invalid email or password",
}); });
} }
const session = await createSession(user.id); const session = await createSession(user.id);
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" }); const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
return { user, sessionToken: session.sessionToken, accessToken }; return { user, sessionToken: session.sessionToken, accessToken };
} }
const GOOGLE_ISSUER = "https://accounts.google.com";
const APPLE_ISSUER = "https://appleid.apple.com"; const APPLE_ISSUER = "https://appleid.apple.com";
const APPLE_JWKS_URL = new URL("https://appleid.apple.com/auth/keys"); 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. * Verifies an Apple identity token and authenticates the user.
* If the user does not exist, creates a new account. * If the user does not exist, creates a new account.
* If the user exists but has not linked Apple, links the provider. * If the user exists but has not linked Apple, links the provider.
*/ */
export async function authenticateWithApple( export async function authenticateWithApple(
identityToken: string, identityToken: string,
authorizationCode: string, authorizationCode: string,
userIdentifier?: string | null, userIdentifier?: string | null,
) { ) {
if (!identityToken) { if (!identityToken) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Missing identity token", message: "Missing identity token",
}); });
} }
// Verify Apple ID token using Apple's JWKS // Verify Apple ID token using Apple's JWKS
let payload: { sub: string; email?: string; is_private_email?: string; }; let payload: { sub: string; email?: string; is_private_email?: string };
try { try {
const JWKS = createRemoteJWKSet(APPLE_JWKS_URL); const JWKS = createRemoteJWKSet(APPLE_JWKS_URL);
const result = await jwtVerify(identityToken, JWKS, { const result = await jwtVerify(identityToken, JWKS, {
issuer: APPLE_ISSUER, issuer: APPLE_ISSUER,
audience: process.env.IOS_BUNDLE_ID ?? "com.frenocorp.kordant", audience: process.env.IOS_BUNDLE_ID ?? "com.frenocorp.kordant",
}); });
payload = result.payload as unknown as { sub: string; email?: string; is_private_email?: string; }; payload = result.payload as unknown as {
} catch (err) { sub: string;
throw new TRPCError({ email?: string;
code: "UNAUTHORIZED", is_private_email?: string;
message: "Invalid Apple identity token", };
}); } catch (err) {
} throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid Apple identity token",
});
}
const appleUserId = payload.sub; const appleUserId = payload.sub;
const email = payload.email ?? null; const email = payload.email ?? null;
if (!email) { if (!email) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Apple account has no email address", message: "Apple account has no email address",
}); });
} }
// Check if this Apple account is already linked // Check if this Apple account is already linked
const [existingAccount] = await db const [existingAccount] = await db
.select() .select()
.from(accounts) .from(accounts)
.where( .where(
and( and(
eq(accounts.provider, "apple"), eq(accounts.provider, "apple"),
eq(accounts.providerAccountId, appleUserId), eq(accounts.providerAccountId, appleUserId),
), ),
) )
.limit(1); .limit(1);
let userId: string; let userId: string;
let isNewUser = false; let isNewUser = false;
if (existingAccount) { if (existingAccount) {
// Already linked — use the existing user // Already linked — use the existing user
userId = existingAccount.userId; userId = existingAccount.userId;
isNewUser = false; isNewUser = false;
// Update tokens // Update tokens
await db await db
.update(accounts) .update(accounts)
.set({ .set({
accessToken: identityToken, accessToken: identityToken,
refreshToken: authorizationCode, refreshToken: authorizationCode,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where(eq(accounts.id, existingAccount.id)); .where(eq(accounts.id, existingAccount.id));
} else { } else {
// Not linked — check if a user with this email exists // Not linked — check if a user with this email exists
const [existingUserByEmail] = await db const [existingUserByEmail] = await db
.select() .select()
.from(users) .from(users)
.where(and(eq(users.email, email), isNull(users.deletedAt))) .where(and(eq(users.email, email), isNull(users.deletedAt)))
.limit(1); .limit(1);
// Apple provides the user's first name and last name only on the initial sign-up // 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 // We derive a display name from email if userIdentifier-based lookup doesn't work
const displayName = email.split("@")[0] ?? "User"; const displayName = email.split("@")[0] ?? "User";
if (existingUserByEmail) { if (existingUserByEmail) {
// Link Apple to existing user // Link Apple to existing user
userId = existingUserByEmail.id; userId = existingUserByEmail.id;
isNewUser = false; isNewUser = false;
await db.insert(accounts).values({ await db.insert(accounts).values({
userId, userId,
provider: "apple", provider: "apple",
providerAccountId: appleUserId, providerAccountId: appleUserId,
accessToken: identityToken, accessToken: identityToken,
refreshToken: authorizationCode, refreshToken: authorizationCode,
}); });
} else { } else {
// Create new user with Apple // Create new user with Apple
isNewUser = true; isNewUser = true;
const [newUser] = await db const [newUser] = await db
.insert(users) .insert(users)
.values({ .values({
name: displayName, name: displayName,
email, email,
emailVerified: new Date(), emailVerified: new Date(),
}) })
.returning(); .returning();
userId = newUser.id; userId = newUser.id;
await db.insert(accounts).values({ await db.insert(accounts).values({
userId, userId,
provider: "apple", provider: "apple",
providerAccountId: appleUserId, providerAccountId: appleUserId,
accessToken: identityToken, accessToken: identityToken,
refreshToken: authorizationCode, refreshToken: authorizationCode,
}); });
} }
} }
// Create session and JWT // Create session and JWT
const session = await createSession(userId); const session = await createSession(userId);
const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" }); const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
const refreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" }); const refreshToken = await signJWT(
{ sub: userId, type: "refresh" },
{ expiresIn: "30d" },
);
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); const [user] = await db
if (!user) { .select()
throw new TRPCError({ code: "NOT_FOUND", message: "User not found after creation" }); .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. * Refreshes an access token using a valid refresh token.
*/ */
export async function refreshAccessToken(refreshToken: string) { 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 }; let payload: { sub?: string; type?: string };
try { try {
payload = await verifyJWT<{ sub: string; type: string }>(refreshToken); payload = await verifyJWT<{ sub: string; type: string }>(refreshToken);
} catch { } catch {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid or expired refresh token", message: "Invalid or expired refresh token",
}); });
} }
if (payload.type !== "refresh") { if (payload.type !== "refresh") {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid token type", message: "Invalid token type",
}); });
} }
const userId = payload.sub!; const userId = payload.sub!;
const [user] = await db const [user] = await db
.select() .select()
.from(users) .from(users)
.where(and(eq(users.id, userId), isNull(users.deletedAt))) .where(and(eq(users.id, userId), isNull(users.deletedAt)))
.limit(1); .limit(1);
if (!user) { if (!user) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "User not found", message: "User not found",
}); });
} }
const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" }); const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
const newRefreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" }); 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. * Sends a password reset email.
*/ */
export async function forgotPassword(email: string) { export async function forgotPassword(email: string) {
const [user] = await db const [user] = await db
.select() .select()
.from(users) .from(users)
.where(and(eq(users.email, email), isNull(users.deletedAt))) .where(and(eq(users.email, email), isNull(users.deletedAt)))
.limit(1); .limit(1);
if (!user) { if (!user) {
// Don't reveal whether the email exists // Don't reveal whether the email exists
return { success: true }; return { success: true };
} }
// Generate a reset token (valid for 1 hour) // Generate a reset token (valid for 1 hour)
const resetToken = await signJWT( const resetToken = await signJWT(
{ sub: user.id, type: "password-reset" }, { sub: user.id, type: "password-reset" },
{ expiresIn: "1h" }, { expiresIn: "1h" },
); );
// In production, send via email service (Resend, SendGrid, etc.) // In production, send via email service (Resend, SendGrid, etc.)
// For now, we log it and return success // For now, we log it and return success
console.log(`Password reset token for ${email}: ${resetToken}`); console.log(`Password reset token for ${email}: ${resetToken}`);
// TODO: Send email via Resend // TODO: Send email via Resend
// const { Resend } = await import("resend"); // const { Resend } = await import("resend");
// const resend = new Resend(process.env.RESEND_API_KEY); // const resend = new Resend(process.env.RESEND_API_KEY);
// await resend.emails.send({ // await resend.emails.send({
// from: "Kordant <support@kordant.com>", // from: "Kordant <support@kordant.com>",
// to: email, // to: email,
// subject: "Reset your password", // subject: "Reset your password",
// html: `<a href="${process.env.APP_URL}/reset-password?token=${resetToken}">Reset password</a>`, // 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. * Resets a user's password using a valid reset token.
*/ */
export async function resetPassword(token: string, newPassword: string) { 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 }; let payload: { sub?: string; type?: string };
try { try {
payload = await verifyJWT<{ sub: string; type: string }>(token); payload = await verifyJWT<{ sub: string; type: string }>(token);
} catch { } catch {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid or expired reset token", message: "Invalid or expired reset token",
}); });
} }
if (payload.type !== "password-reset") { if (payload.type !== "password-reset") {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Invalid token type", message: "Invalid token type",
}); });
} }
const userId = payload.sub!; const userId = payload.sub!;
const passwordHash = await hashPassword(newPassword); const passwordHash = await hashPassword(newPassword);
await db await db
.update(users) .update(users)
.set({ passwordHash, updatedAt: new Date() }) .set({ passwordHash, updatedAt: new Date() })
.where(eq(users.id, userId)); .where(eq(users.id, userId));
return { success: true }; return { success: true };
} }
/** /**
* Revokes all sessions for a user (logout everywhere). * Revokes all sessions for a user (logout everywhere).
*/ */
export async function revokeUserSessions(userId: string) { export async function revokeUserSessions(userId: string) {
const { sessions } = await import("~/server/db/schema/auth"); const { sessions } = await import("~/server/db/schema/auth");
await db await db.delete(sessions).where(eq(sessions.userId, userId));
.delete(sessions) return { success: true };
.where(eq(sessions.userId, userId));
return { success: true };
} }
export async function getUserById(id: string) { export async function getUserById(id: string) {
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.id, id), where: eq(users.id, id),
with: { with: {
accounts: true, accounts: true,
sessions: true, sessions: true,
deviceTokens: true, deviceTokens: true,
familyGroups: true, familyGroups: true,
familyGroupOwned: true, familyGroupOwned: true,
subscriptions: true, subscriptions: true,
}, },
}); });
if (!user) { if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
} }
return user; return user;
} }
export async function updateUser( export async function updateUser(
id: string, id: string,
data: { name?: string; email?: string; image?: string }, data: { name?: string; email?: string; image?: string },
) { ) {
const [existing] = await db const [existing] = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.id, id)) .where(eq(users.id, id))
.limit(1); .limit(1);
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
} }
if (data.email && data.email !== existing.email) { if (data.email && data.email !== existing.email) {
const [duplicate] = await db const [duplicate] = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.email, data.email)) .where(eq(users.email, data.email))
.limit(1); .limit(1);
if (duplicate) { if (duplicate) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Email already in use", message: "Email already in use",
}); });
} }
} }
const [updated] = await db const [updated] = await db
.update(users) .update(users)
.set(data) .set(data)
.where(eq(users.id, id)) .where(eq(users.id, id))
.returning(); .returning();
return updated; return updated;
} }
export async function deleteUser(id: string) { export async function deleteUser(id: string) {
const [existing] = await db const [existing] = await db
.select() .select()
.from(users) .from(users)
.where(eq(users.id, id)) .where(eq(users.id, id))
.limit(1); .limit(1);
if (!existing) { if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
} }
const [deleted] = await db const [deleted] = await db
.update(users) .update(users)
.set({ deletedAt: new Date() }) .set({ deletedAt: new Date() })
.where(eq(users.id, id)) .where(eq(users.id, id))
.returning(); .returning();
return deleted; return deleted;
} }

View File

@@ -5,8 +5,5 @@ import tailwindcss from "@tailwindcss/vite";
import { solidStart } from "@solidjs/start/config"; import { solidStart } from "@solidjs/start/config";
export default defineConfig({ export default defineConfig({
plugins: [solidStart(), plugins: [solidStart(), tailwindcss(), nitro({ preset: "vercel" })],
tailwindcss(),
nitro()
]
}); });