From 8e953cdd7cd955400f27efacf894d337985d4cc6 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 3 Jun 2026 14:05:49 -0400 Subject: [PATCH] fmt --- web/src/routes/api/auth/[action].ts | 311 ++++----- web/src/server/api/root.ts | 30 +- web/src/server/api/routers/user.ts | 259 ++++---- web/src/server/jobs/handlers/index.ts | 44 +- web/src/server/jobs/queue.ts | 355 +++++----- web/src/server/lib/email.ts | 66 +- web/src/server/lib/env.ts | 102 ++- .../server/services/alert.publisher.test.ts | 198 +++--- web/src/server/services/alert.publisher.ts | 92 +-- .../services/darkwatch/digest.service.ts | 425 ++++++------ web/src/server/services/user.service.ts | 620 +++++++++--------- 11 files changed, 1281 insertions(+), 1221 deletions(-) diff --git a/web/src/routes/api/auth/[action].ts b/web/src/routes/api/auth/[action].ts index 3a381c6..fdf954e 100644 --- a/web/src/routes/api/auth/[action].ts +++ b/web/src/routes/api/auth/[action].ts @@ -1,12 +1,12 @@ import type { APIEvent } from "@solidjs/start/server"; import { - authenticateUser, - authenticateWithApple, - createUserWithPassword, - forgotPassword, - resetPassword, - refreshAccessToken, - revokeUserSessions, + authenticateUser, + authenticateWithApple, + createUserWithPassword, + forgotPassword, + resetPassword, + refreshAccessToken, + revokeUserSessions, } from "~/server/services/user.service"; import { verifyJWT } from "~/server/auth/jwt"; @@ -26,157 +26,166 @@ import { verifyJWT } from "~/server/auth/jwt"; */ export async function POST(event: APIEvent) { - const action = event.params.action; - const body = await event.request.json().catch(() => ({})); + const action = event.params.action; + const body = await event.request.json().catch(() => ({})); - try { - switch (action) { - case "login": { - const { email, password } = body; - if (!email || !password) { - return new Response( - JSON.stringify({ message: "Email and password are required" }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - const result = await authenticateUser(email, password); - return Response.json({ - id: result.user.id, - name: result.user.name ?? "", - email: result.user.email, - accessToken: result.accessToken, - sessionToken: result.sessionToken, - isNewUser: false, - }); - } + try { + switch (action) { + case "login": { + const { email, password } = body; + if (!email || !password) { + return new Response( + JSON.stringify({ message: "Email and password are required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + const result = await authenticateUser(email, password); + return Response.json({ + id: result.user.id, + name: result.user.name ?? "", + email: result.user.email, + accessToken: result.accessToken, + sessionToken: result.sessionToken, + isNewUser: false, + }); + } - case "signup": { - const { name, email, password } = body; - if (!email || !password) { - return new Response( - JSON.stringify({ message: "Name, email, and password are required" }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - const result = await createUserWithPassword( - name ?? email.split("@")[0], - email, - password, - ); - return Response.json({ - id: result.user.id, - name: result.user.name ?? "", - email: result.user.email, - accessToken: result.accessToken, - sessionToken: result.sessionToken, - isNewUser: true, - }); - } + case "signup": { + const { name, email, password } = body; + if (!email || !password) { + return new Response( + JSON.stringify({ + message: "Name, email, and password are required", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + const result = await createUserWithPassword( + name ?? email.split("@")[0], + email, + password, + ); + return Response.json({ + id: result.user.id, + name: result.user.name ?? "", + email: result.user.email, + accessToken: result.accessToken, + sessionToken: result.sessionToken, + isNewUser: true, + }); + } - case "apple": { - const { identityToken, authorizationCode, userIdentifier } = body; - if (!identityToken || !authorizationCode) { - return new Response( - JSON.stringify({ message: "identityToken and authorizationCode are required" }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - const result = await authenticateWithApple( - identityToken, - authorizationCode, - userIdentifier ?? null, - ); - return Response.json({ - id: result.user.id, - name: result.user.name ?? "", - email: result.user.email, - image: result.user.image, - accessToken: result.accessToken, - refreshToken: result.refreshToken, - sessionToken: result.sessionToken, - isNewUser: result.isNewUser ?? false, - }); - } + case "apple": { + const { identityToken, authorizationCode, userIdentifier } = body; + if (!identityToken || !authorizationCode) { + return new Response( + JSON.stringify({ + message: "identityToken and authorizationCode are required", + }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + const result = await authenticateWithApple( + identityToken, + authorizationCode, + userIdentifier ?? null, + ); + return Response.json({ + id: result.user.id, + name: result.user.name ?? "", + email: result.user.email, + image: result.user.image, + accessToken: result.accessToken, + refreshToken: result.refreshToken, + sessionToken: result.sessionToken, + isNewUser: result.isNewUser ?? false, + }); + } - case "refresh": { - const { refreshToken } = body; - if (!refreshToken) { - return new Response( - JSON.stringify({ message: "refreshToken is required" }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - const result = await refreshAccessToken(refreshToken); - return Response.json({ - accessToken: result.accessToken, - refreshToken: result.refreshToken, - }); - } + case "refresh": { + const { refreshToken } = body; + if (!refreshToken) { + return new Response( + JSON.stringify({ message: "refreshToken is required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + const result = await refreshAccessToken(refreshToken); + return Response.json({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + }); + } - case "logout": { - // Extract user from Bearer token - const authHeader = event.request.headers.get("authorization"); - if (authHeader?.startsWith("Bearer ")) { - const token = authHeader.slice(7); - try { - const payload = await verifyJWT<{ sub: string }>(token); - await revokeUserSessions(payload.sub); - } catch { - // Invalid token — still return success - } - } - return Response.json({ success: true }); - } + case "logout": { + // Extract user from Bearer token + const authHeader = event.request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + const token = authHeader.slice(7); + try { + const payload = await verifyJWT<{ sub: string }>(token); + await revokeUserSessions(payload.sub); + } catch { + // Invalid token — still return success + } + } + return Response.json({ success: true }); + } - case "forgot-password": { - const { email } = body; - if (!email) { - return new Response( - JSON.stringify({ message: "Email is required" }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - await forgotPassword(email); - return Response.json({ success: true }); - } + case "forgot-password": { + const { email } = body; + if (!email) { + return new Response( + JSON.stringify({ message: "Email is required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + await forgotPassword(email); + return Response.json({ success: true }); + } - case "reset-password": { - const { code, password } = body; - if (!code || !password) { - return new Response( - JSON.stringify({ message: "Code and password are required" }), - { status: 400, headers: { "Content-Type": "application/json" } }, - ); - } - // The mobile app sends "code" but the service expects "token" - // We accept both for backward compatibility - const token = code; - await resetPassword(token, password); - return Response.json({ success: true }); - } + case "reset-password": { + const { code, password } = body; + if (!code || !password) { + return new Response( + JSON.stringify({ message: "Code and password are required" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + // The mobile app sends "code" but the service expects "token" + // We accept both for backward compatibility + const token = code; + await resetPassword(token, password); + return Response.json({ success: true }); + } - default: - return new Response( - JSON.stringify({ message: `Unknown action: ${action}` }), - { status: 404, headers: { "Content-Type": "application/json" } }, - ); - } - } catch (error: any) { - const statusCode = error.code === "UNAUTHORIZED" ? 401 - : error.code === "CONFLICT" ? 409 - : error.code === "NOT_FOUND" ? 404 - : error.code === "FORBIDDEN" ? 403 - : 500; + default: + return new Response( + JSON.stringify({ message: `Unknown action: ${action}` }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ); + } + } catch (error: any) { + const statusCode = + error.code === "UNAUTHORIZED" + ? 401 + : error.code === "CONFLICT" + ? 409 + : error.code === "NOT_FOUND" + ? 404 + : error.code === "FORBIDDEN" + ? 403 + : 500; - return new Response( - JSON.stringify({ - message: error.message ?? "Internal server error", - code: error.code ?? "INTERNAL_ERROR", - }), - { - status: statusCode, - headers: { "Content-Type": "application/json" }, - }, - ); - } + return new Response( + JSON.stringify({ + message: error.message ?? "Internal server error", + code: error.code ?? "INTERNAL_ERROR", + }), + { + status: statusCode, + headers: { "Content-Type": "application/json" }, + }, + ); + } } diff --git a/web/src/server/api/root.ts b/web/src/server/api/root.ts index 5c1f8ac..0754271 100644 --- a/web/src/server/api/root.ts +++ b/web/src/server/api/root.ts @@ -16,21 +16,21 @@ import { familyRouter } from "./routers/family"; import { createTRPCRouter } from "./utils"; export const appRouter = createTRPCRouter({ - example: exampleRouter, - user: userRouter, - billing: billingRouter, - darkwatch: darkwatchRouter, - voiceprint: voiceprintRouter, - spamshield: spamshieldRouter, - hometitle: hometitleRouter, - removebrokers: removebrokersRouter, - correlation: correlationRouter, - reports: reportsRouter, - scheduler: schedulerRouter, - extension: extensionRouter, - blog: blogRouter, - admin: adminRouter, - family: familyRouter, + example: exampleRouter, + user: userRouter, + billing: billingRouter, + darkwatch: darkwatchRouter, + voiceprint: voiceprintRouter, + spamshield: spamshieldRouter, + hometitle: hometitleRouter, + removebrokers: removebrokersRouter, + correlation: correlationRouter, + reports: reportsRouter, + scheduler: schedulerRouter, + extension: extensionRouter, + blog: blogRouter, + admin: adminRouter, + family: familyRouter, }); export type AppRouter = typeof appRouter; diff --git a/web/src/server/api/routers/user.ts b/web/src/server/api/routers/user.ts index c08edef..ae53687 100644 --- a/web/src/server/api/routers/user.ts +++ b/web/src/server/api/routers/user.ts @@ -1,174 +1,179 @@ import { wrap } from "@typeschema/valibot"; import { object, string, minLength, email as emailVal } from "valibot"; import { TRPCError } from "@trpc/server"; -import { createTRPCRouter, publicProcedure, protectedProcedure } from "../utils"; import { - UpdateUserSchema, - InviteMemberSchema, - RemoveMemberSchema, - UpdateRoleSchema, + createTRPCRouter, + publicProcedure, + protectedProcedure, +} from "../utils"; +import { + UpdateUserSchema, + InviteMemberSchema, + RemoveMemberSchema, + UpdateRoleSchema, } from "../schemas/user"; import { - getUserById, - updateUser, - deleteUser, - createUserWithPassword, - authenticateUser, - authenticateWithApple, - refreshAccessToken, - forgotPassword, - resetPassword, - revokeUserSessions, + getUserById, + updateUser, + deleteUser, + createUserWithPassword, + authenticateUser, + authenticateWithApple, + refreshAccessToken, + forgotPassword, + resetPassword, + revokeUserSessions, } from "~/server/services/user.service"; import { - getFamilyGroup, - inviteMember, - removeMember, - updateMemberRole, + getFamilyGroup, + inviteMember, + removeMember, + updateMemberRole, } from "~/server/services/family.service"; const LoginSchema = object({ - email: string([emailVal()]), - password: string([minLength(1)]), + email: string([emailVal()]), + password: string([minLength(1)]), }); const SignupSchema = object({ - name: string([minLength(1)]), - email: string([emailVal()]), - password: string([minLength(8)]), + name: string([minLength(1)]), + email: string([emailVal()]), + password: string([minLength(8)]), }); const AppleAuthSchema = object({ - identityToken: string([minLength(1)]), - authorizationCode: string([minLength(1)]), - userIdentifier: string(), + identityToken: string([minLength(1)]), + authorizationCode: string([minLength(1)]), + userIdentifier: string(), }); const RefreshTokenSchema = object({ - refreshToken: string([minLength(1)]), + refreshToken: string([minLength(1)]), }); const ForgotPasswordSchema = object({ - email: string([emailVal()]), + email: string([emailVal()]), }); const ResetPasswordSchema = object({ - token: string([minLength(1)]), - password: string([minLength(8)]), + token: string([minLength(1)]), + password: string([minLength(8)]), }); export const userRouter = createTRPCRouter({ - login: publicProcedure - .input(wrap(LoginSchema)) - .mutation(async ({ input }) => { - return authenticateUser(input.email, input.password); - }), + login: publicProcedure + .input(wrap(LoginSchema)) + .mutation(async ({ input }) => { + return authenticateUser(input.email, input.password); + }), - signup: publicProcedure - .input(wrap(SignupSchema)) - .mutation(async ({ input }) => { - return createUserWithPassword(input.name, input.email, input.password); - }), + signup: publicProcedure + .input(wrap(SignupSchema)) + .mutation(async ({ input }) => { + return createUserWithPassword(input.name, input.email, input.password); + }), - appleAuth: publicProcedure - .input(wrap(AppleAuthSchema)) - .mutation(async ({ input }) => { - return authenticateWithApple( - input.identityToken, - input.authorizationCode, - input.userIdentifier || null, - ); - }), + appleAuth: publicProcedure + .input(wrap(AppleAuthSchema)) + .mutation(async ({ input }) => { + return authenticateWithApple( + input.identityToken, + input.authorizationCode, + input.userIdentifier || null, + ); + }), - refreshToken: publicProcedure - .input(wrap(RefreshTokenSchema)) - .mutation(async ({ input }) => { - return refreshAccessToken(input.refreshToken); - }), + refreshToken: publicProcedure + .input(wrap(RefreshTokenSchema)) + .mutation(async ({ input }) => { + return refreshAccessToken(input.refreshToken); + }), - forgotPassword: publicProcedure - .input(wrap(ForgotPasswordSchema)) - .mutation(async ({ input }) => { - return forgotPassword(input.email); - }), + forgotPassword: publicProcedure + .input(wrap(ForgotPasswordSchema)) + .mutation(async ({ input }) => { + return forgotPassword(input.email); + }), - resetPassword: publicProcedure - .input(wrap(ResetPasswordSchema)) - .mutation(async ({ input }) => { - return resetPassword(input.token, input.password); - }), + resetPassword: publicProcedure + .input(wrap(ResetPasswordSchema)) + .mutation(async ({ input }) => { + return resetPassword(input.token, input.password); + }), - me: protectedProcedure.query(async ({ ctx }) => { - const user = await getUserById(ctx.user.id); - return user; - }), + me: protectedProcedure.query(async ({ ctx }) => { + const user = await getUserById(ctx.user.id); + return user; + }), - update: protectedProcedure - .input(wrap(UpdateUserSchema)) - .mutation(async ({ ctx, input }) => { - const updated = await updateUser(ctx.user.id, input); - return updated; - }), + update: protectedProcedure + .input(wrap(UpdateUserSchema)) + .mutation(async ({ ctx, input }) => { + const updated = await updateUser(ctx.user.id, input); + return updated; + }), - delete: protectedProcedure.mutation(async ({ ctx }) => { - await deleteUser(ctx.user.id); - return { success: true }; - }), + delete: protectedProcedure.mutation(async ({ ctx }) => { + await deleteUser(ctx.user.id); + return { success: true }; + }), - logout: protectedProcedure.mutation(async ({ ctx }) => { - await revokeUserSessions(ctx.user.id); - return { success: true }; - }), + logout: protectedProcedure.mutation(async ({ ctx }) => { + await revokeUserSessions(ctx.user.id); + return { success: true }; + }), - listFamilyMembers: protectedProcedure.query(async ({ ctx }) => { - const group = await getFamilyGroup(ctx.user.id); - return group.members; - }), + listFamilyMembers: protectedProcedure.query(async ({ ctx }) => { + const group = await getFamilyGroup(ctx.user.id); + return group.members; + }), - inviteFamilyMember: protectedProcedure - .input(wrap(InviteMemberSchema)) - .mutation(async ({ ctx, input }) => { - const group = await getFamilyGroup(ctx.user.id); + inviteFamilyMember: protectedProcedure + .input(wrap(InviteMemberSchema)) + .mutation(async ({ ctx, input }) => { + const group = await getFamilyGroup(ctx.user.id); - const callerMember = group.members.find( - (m) => m.userId === ctx.user.id, - ); + const callerMember = group.members.find((m) => m.userId === ctx.user.id); - if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Only owner or admin can invite members", - }); - } + if ( + !callerMember || + (callerMember.role !== "owner" && callerMember.role !== "admin") + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only owner or admin can invite members", + }); + } - const invitation = await inviteMember( - group.id, - input.email, - ctx.user.id, - input.role, - ); + const invitation = await inviteMember( + group.id, + input.email, + ctx.user.id, + input.role, + ); - return invitation; - }), + return invitation; + }), - removeFamilyMember: protectedProcedure - .input(wrap(RemoveMemberSchema)) - .mutation(async ({ ctx, input }) => { - const group = await getFamilyGroup(ctx.user.id); - await removeMember(group.id, input.userId, ctx.user.id); - return { success: true }; - }), + removeFamilyMember: protectedProcedure + .input(wrap(RemoveMemberSchema)) + .mutation(async ({ ctx, input }) => { + const group = await getFamilyGroup(ctx.user.id); + await removeMember(group.id, input.userId, ctx.user.id); + return { success: true }; + }), - updateFamilyMemberRole: protectedProcedure - .input(wrap(UpdateRoleSchema)) - .mutation(async ({ ctx, input }) => { - const group = await getFamilyGroup(ctx.user.id); - const updated = await updateMemberRole( - group.id, - input.userId, - input.role, - ctx.user.id, - ); - return updated; - }), + updateFamilyMemberRole: protectedProcedure + .input(wrap(UpdateRoleSchema)) + .mutation(async ({ ctx, input }) => { + const group = await getFamilyGroup(ctx.user.id); + const updated = await updateMemberRole( + group.id, + input.userId, + input.role, + ctx.user.id, + ); + return updated; + }), }); diff --git a/web/src/server/jobs/handlers/index.ts b/web/src/server/jobs/handlers/index.ts index f12ef53..1199bd5 100644 --- a/web/src/server/jobs/handlers/index.ts +++ b/web/src/server/jobs/handlers/index.ts @@ -1,34 +1,36 @@ import type { JobPayload, JobType } from "../queue"; -export type JobHandler = (payload: JobPayload[T]) => Promise; +export type JobHandler = ( + payload: JobPayload[T], +) => Promise; export type HandlerMap = { - [K in JobType]: JobHandler; + [K in JobType]: JobHandler; }; let handlers: HandlerMap | null = null; export function getHandlers(): HandlerMap { - if (!handlers) { - handlers = { - "darkwatch.scan": require("./darkwatch.scan").handler, - "darkwatch.digest": require("./darkwatch.digest").handler, - "voiceprint.batch": require("./voiceprint.batch").handler, - "hometitle.scan": require("./hometitle.scan").handler, - "removebrokers.process": require("./removebrokers.process").handler, - "reports.generate": require("./reports.generate").handler, - }; - } - return handlers; + if (!handlers) { + handlers = { + "darkwatch.scan": require("./darkwatch.scan").handler, + "darkwatch.digest": require("./darkwatch.digest").handler, + "voiceprint.batch": require("./voiceprint.batch").handler, + "hometitle.scan": require("./hometitle.scan").handler, + "removebrokers.process": require("./removebrokers.process").handler, + "reports.generate": require("./reports.generate").handler, + }; + } + return handlers; } export function setHandlers(mock: Partial): void { - handlers = { - "darkwatch.scan": mock["darkwatch.scan"] ?? (async () => {}), - "darkwatch.digest": mock["darkwatch.digest"] ?? (async () => {}), - "voiceprint.batch": mock["voiceprint.batch"] ?? (async () => {}), - "hometitle.scan": mock["hometitle.scan"] ?? (async () => {}), - "removebrokers.process": mock["removebrokers.process"] ?? (async () => {}), - "reports.generate": mock["reports.generate"] ?? (async () => {}), - }; + handlers = { + "darkwatch.scan": mock["darkwatch.scan"] ?? (async () => {}), + "darkwatch.digest": mock["darkwatch.digest"] ?? (async () => {}), + "voiceprint.batch": mock["voiceprint.batch"] ?? (async () => {}), + "hometitle.scan": mock["hometitle.scan"] ?? (async () => {}), + "removebrokers.process": mock["removebrokers.process"] ?? (async () => {}), + "reports.generate": mock["reports.generate"] ?? (async () => {}), + }; } diff --git a/web/src/server/jobs/queue.ts b/web/src/server/jobs/queue.ts index d63dea1..3338e83 100644 --- a/web/src/server/jobs/queue.ts +++ b/web/src/server/jobs/queue.ts @@ -1,220 +1,243 @@ import { randomUUID } from "node:crypto"; export const JOB_TYPES = [ - "darkwatch.scan", - "darkwatch.digest", - "voiceprint.batch", - "hometitle.scan", - "removebrokers.process", - "reports.generate", + "darkwatch.scan", + "darkwatch.digest", + "voiceprint.batch", + "hometitle.scan", + "removebrokers.process", + "reports.generate", ] as const; export type JobType = (typeof JOB_TYPES)[number]; export type JobPayload = { - "darkwatch.scan": { userId: string; subscriptionId: string }; - "darkwatch.digest": { userId: string }; - "voiceprint.batch": { userId?: string; jobId?: string }; - "hometitle.scan": { userId: string; subscriptionId: string }; - "removebrokers.process": { subscriptionId?: string; requestId?: string }; - "reports.generate": { userId: string; reportScheduleId?: string; reportType: string }; + "darkwatch.scan": { userId: string; subscriptionId: string }; + "darkwatch.digest": { userId: string }; + "voiceprint.batch": { userId?: string; jobId?: string }; + "hometitle.scan": { userId: string; subscriptionId: string }; + "removebrokers.process": { subscriptionId?: string; requestId?: string }; + "reports.generate": { + userId: string; + reportScheduleId?: string; + reportType: string; + }; }; export type JobStatus = "pending" | "running" | "completed" | "failed"; export interface Job { - id: string; - type: T; - payload: JobPayload[T]; - status: JobStatus; - attempts: number; - maxAttempts: number; - error?: string; - createdAt: Date; - updatedAt: Date; + id: string; + type: T; + payload: JobPayload[T]; + status: JobStatus; + attempts: number; + maxAttempts: number; + error?: string; + createdAt: Date; + updatedAt: Date; } export interface EnqueueOptions { - delay?: number; - maxAttempts?: number; + delay?: number; + maxAttempts?: number; } export interface QueueAdapter { - enqueue(type: T, payload: JobPayload[T], options?: EnqueueOptions): Promise>; - dequeue(): Promise; - markComplete(jobId: string): Promise; - markFailed(jobId: string, error: string): Promise; - scheduleRetry(job: Job, delayMs: number): Promise; - getJob(jobId: string): Promise; - getJobs(status?: JobStatus): Promise; + enqueue( + type: T, + payload: JobPayload[T], + options?: EnqueueOptions, + ): Promise>; + dequeue(): Promise; + markComplete(jobId: string): Promise; + markFailed(jobId: string, error: string): Promise; + scheduleRetry(job: Job, delayMs: number): Promise; + getJob(jobId: string): Promise; + getJobs(status?: JobStatus): Promise; } export class InMemoryQueue implements QueueAdapter { - private jobs = new Map(); - private pendingQueue: string[] = []; + private jobs = new Map(); + private pendingQueue: string[] = []; - async enqueue(type: T, payload: JobPayload[T], options?: EnqueueOptions): Promise> { - const id = randomUUID(); - const job: Job = { - id, - type, - payload, - status: "pending", - attempts: 0, - maxAttempts: options?.maxAttempts ?? 3, - createdAt: new Date(), - updatedAt: new Date(), - }; - this.jobs.set(id, job as Job); - if (options?.delay) { - setTimeout(() => { - this.pendingQueue.push(id); - }, options.delay); - } else { - this.pendingQueue.push(id); - } - return job; - } + async enqueue( + type: T, + payload: JobPayload[T], + options?: EnqueueOptions, + ): Promise> { + const id = randomUUID(); + const job: Job = { + id, + type, + payload, + status: "pending", + attempts: 0, + maxAttempts: options?.maxAttempts ?? 3, + createdAt: new Date(), + updatedAt: new Date(), + }; + this.jobs.set(id, job as Job); + if (options?.delay) { + setTimeout(() => { + this.pendingQueue.push(id); + }, options.delay); + } else { + this.pendingQueue.push(id); + } + return job; + } - async scheduleRetry(job: Job, delayMs: number): Promise { - job.status = "pending"; - job.attempts++; - job.updatedAt = new Date(); - setTimeout(() => { - this.pendingQueue.push(job.id); - }, delayMs); - } + async scheduleRetry(job: Job, delayMs: number): Promise { + job.status = "pending"; + job.attempts++; + job.updatedAt = new Date(); + setTimeout(() => { + this.pendingQueue.push(job.id); + }, delayMs); + } - async dequeue(): Promise { - while (this.pendingQueue.length > 0) { - const id = this.pendingQueue.shift()!; - const job = this.jobs.get(id); - if (!job || job.status !== "pending") continue; - job.status = "running"; - job.updatedAt = new Date(); - return job; - } - return null; - } + async dequeue(): Promise { + while (this.pendingQueue.length > 0) { + const id = this.pendingQueue.shift()!; + const job = this.jobs.get(id); + if (!job || job.status !== "pending") continue; + job.status = "running"; + job.updatedAt = new Date(); + return job; + } + return null; + } - async markComplete(jobId: string): Promise { - const job = this.jobs.get(jobId); - if (job) { - job.status = "completed"; - job.updatedAt = new Date(); - } - } + async markComplete(jobId: string): Promise { + const job = this.jobs.get(jobId); + if (job) { + job.status = "completed"; + job.updatedAt = new Date(); + } + } - async markFailed(jobId: string, error: string): Promise { - const job = this.jobs.get(jobId); - if (job) { - job.status = "failed"; - job.error = error; - job.updatedAt = new Date(); - } - } + async markFailed(jobId: string, error: string): Promise { + const job = this.jobs.get(jobId); + if (job) { + job.status = "failed"; + job.error = error; + job.updatedAt = new Date(); + } + } - async getJob(jobId: string): Promise { - return this.jobs.get(jobId) ?? null; - } + async getJob(jobId: string): Promise { + return this.jobs.get(jobId) ?? null; + } - async getJobs(status?: JobStatus): Promise { - const all = Array.from(this.jobs.values()); - if (status) return all.filter((j) => j.status === status); - return all; - } + async getJobs(status?: JobStatus): Promise { + const all = Array.from(this.jobs.values()); + if (status) return all.filter((j) => j.status === status); + return all; + } } function createRedisAdapter(): QueueAdapter { - // Lazy imports so this module works without Redis - const BullMQ = require("bullmq"); - const IORedis = require("ioredis"); + // Lazy imports so this module works without Redis + const BullMQ = require("bullmq"); + const IORedis = require("ioredis"); - const connection = new IORedis.default(process.env.REDIS_URL ?? "redis://localhost:6379", { - maxRetriesPerRequest: null, - }); + const connection = new IORedis.default( + process.env.REDIS_URL ?? "redis://localhost:6379", + { + maxRetriesPerRequest: null, + }, + ); - const queue = new BullMQ.Queue("kordant-jobs", { connection }); - const bullJobs = new Map(); + const queue = new BullMQ.Queue("kordant-jobs", { connection }); + const bullJobs = new Map(); - async function toJob(bullJob: any): Promise { - return { - id: bullJob.id, - type: bullJob.name as JobType, - payload: bullJob.data, - status: (await bullJob.getState()) as JobStatus, - attempts: bullJob.attemptsMade, - maxAttempts: bullJob.opts?.attempts ?? 3, - error: bullJob.failedReason ?? undefined, - createdAt: bullJob.timestamp ? new Date(bullJob.timestamp) : new Date(), - updatedAt: bullJob.processedOn ? new Date(bullJob.processedOn) : new Date(), - }; - } + async function toJob(bullJob: any): Promise { + return { + id: bullJob.id, + type: bullJob.name as JobType, + payload: bullJob.data, + status: (await bullJob.getState()) as JobStatus, + attempts: bullJob.attemptsMade, + maxAttempts: bullJob.opts?.attempts ?? 3, + error: bullJob.failedReason ?? undefined, + createdAt: bullJob.timestamp ? new Date(bullJob.timestamp) : new Date(), + updatedAt: bullJob.processedOn + ? new Date(bullJob.processedOn) + : new Date(), + }; + } - return { - async enqueue(type: T, payload: JobPayload[T], options?: EnqueueOptions) { - const bullJob = await queue.add(type, payload, { - attempts: options?.maxAttempts ?? 3, - delay: options?.delay, - backoff: { type: "exponential", delay: 60_000 }, - }); - return toJob(bullJob) as Promise>; - }, + return { + async enqueue( + type: T, + payload: JobPayload[T], + options?: EnqueueOptions, + ) { + const bullJob = await queue.add(type, payload, { + attempts: options?.maxAttempts ?? 3, + delay: options?.delay, + backoff: { type: "exponential", delay: 60_000 }, + }); + return toJob(bullJob) as Promise>; + }, - async dequeue() { - // BullMQ Worker handles dequeue automatically; this is for the polling worker - return null; - }, + async dequeue() { + // BullMQ Worker handles dequeue automatically; this is for the polling worker + return null; + }, - async markComplete(jobId) { - // Handled by BullMQ Worker - }, + async markComplete(jobId) { + // Handled by BullMQ Worker + }, - async markFailed(jobId, error) { - // Handled by BullMQ Worker - }, + async markFailed(jobId, error) { + // Handled by BullMQ Worker + }, - async scheduleRetry(job, delayMs) { - // BullMQ handles retries via backoff - }, + async scheduleRetry(job, delayMs) { + // BullMQ handles retries via backoff + }, - async getJob(jobId) { - const bullJob = await queue.getJob(jobId); - if (!bullJob) return null; - return toJob(bullJob); - }, + async getJob(jobId) { + const bullJob = await queue.getJob(jobId); + if (!bullJob) return null; + return toJob(bullJob); + }, - async getJobs(status) { - const states = status ? [status] : ["waiting", "active", "completed", "failed"]; - const allJobs: Job[] = []; - for (const state of states) { - const jobs = await queue.getJobs(state); - for (const j of jobs) { - allJobs.push(await toJob(j)); - } - } - return allJobs; - }, - }; + async getJobs(status) { + const states = status + ? [status] + : ["waiting", "active", "completed", "failed"]; + const allJobs: Job[] = []; + for (const state of states) { + const jobs = await queue.getJobs(state); + for (const j of jobs) { + allJobs.push(await toJob(j)); + } + } + return allJobs; + }, + }; } let adapter: QueueAdapter; export function getQueue(): QueueAdapter { - if (!adapter) { - if (process.env.REDIS_URL) { - adapter = createRedisAdapter(); - } else { - adapter = new InMemoryQueue(); - } - } - return adapter; + if (!adapter) { + if (process.env.REDIS_URL) { + adapter = createRedisAdapter(); + } else { + adapter = new InMemoryQueue(); + } + } + return adapter; } export function setQueue(mock: QueueAdapter): void { - adapter = mock; + adapter = mock; } export function resetQueue(): void { - adapter = undefined as unknown as QueueAdapter; + adapter = undefined as unknown as QueueAdapter; } diff --git a/web/src/server/lib/email.ts b/web/src/server/lib/email.ts index 91075c4..4e146a0 100644 --- a/web/src/server/lib/email.ts +++ b/web/src/server/lib/email.ts @@ -2,41 +2,41 @@ import { TRPCError } from "@trpc/server"; import { resend } from "~/server/lib/resend"; export async function sendEmail( - to: string, - subject: string, - html: string, - text?: string, + 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 }; - } + 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 ?? "", - }); + 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", - }); - } + 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", - }); - } + 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", + }); + } } diff --git a/web/src/server/lib/env.ts b/web/src/server/lib/env.ts index fc3d3df..44d713d 100644 --- a/web/src/server/lib/env.ts +++ b/web/src/server/lib/env.ts @@ -1,74 +1,72 @@ import { object, string, optional, parse, safeParse } from "valibot"; const envSchema = object({ - // Database - DATABASE_URL: string(), - DATABASE_AUTH_TOKEN: optional(string()), + // Database + DATABASE_URL: string(), + DATABASE_AUTH_TOKEN: optional(string()), - // Server - PORT: optional(string()), - NODE_ENV: optional(string()), - LOG_LEVEL: optional(string()), - APP_URL: optional(string()), + // Server + PORT: optional(string()), + NODE_ENV: optional(string()), + LOG_LEVEL: optional(string()), + APP_URL: optional(string()), - // Auth - JWT_SECRET: string(), - SESSION_SECRET: optional(string()), + // Auth + JWT_SECRET: string(), + SESSION_SECRET: optional(string()), - // Clerk - CLERK_SECRET_KEY: string(), - VITE_CLERK_PUBLISHABLE_KEY: string(), + // Clerk + CLERK_SECRET_KEY: string(), + VITE_CLERK_PUBLISHABLE_KEY: string(), - // Stripe - STRIPE_SECRET_KEY: string(), - STRIPE_WEBHOOK_SECRET: string(), + // Stripe + STRIPE_SECRET_KEY: string(), + STRIPE_WEBHOOK_SECRET: string(), - // Redis (for BullMQ) - REDIS_URL: optional(string()), + // Redis (for BullMQ) + REDIS_URL: optional(string()), - // Sentry - VITE_SENTRY_DSN: optional(string()), + // Sentry + VITE_SENTRY_DSN: optional(string()), - // Email - RESEND_API_KEY: optional(string()), + // Email + RESEND_API_KEY: optional(string()), + // SMS + TWILIO_ACCOUNT_SID: optional(string()), + TWILIO_AUTH_TOKEN: optional(string()), + TWILIO_MESSAGING_SERVICE_SID: optional(string()), + // External APIs + ATTOM_API_KEY: optional(string()), + HIBP_API_KEY: optional(string()), + HIBP_RATE_PER_SECOND: optional(string()), + SECURITYTRAILS_API_KEY: optional(string()), + CENSYS_API_ID: optional(string()), + CENSYS_API_SECRET: optional(string()), + SHODAN_API_KEY: optional(string()), - // SMS - TWILIO_ACCOUNT_SID: optional(string()), - TWILIO_AUTH_TOKEN: optional(string()), - TWILIO_MESSAGING_SERVICE_SID: optional(string()), - - // External APIs - ATTOM_API_KEY: optional(string()), - HIBP_API_KEY: optional(string()), - HIBP_RATE_PER_SECOND: optional(string()), - SECURITYTRAILS_API_KEY: optional(string()), - CENSYS_API_ID: optional(string()), - CENSYS_API_SECRET: optional(string()), - SHODAN_API_KEY: optional(string()), - - // WebSocket - WS_PORT: optional(string()), + // WebSocket + WS_PORT: optional(string()), }); export function validateEnv() { - const result = safeParse(envSchema, { - ...process.env, - }); + const result = safeParse(envSchema, { + ...process.env, + }); - if (!result.success) { - const missingKeys = result.issues - .map((issue) => issue.path?.[0]?.key as string | undefined) - .filter((k): k is string => k !== undefined); + if (!result.success) { + const missingKeys = result.issues + .map((issue) => issue.path?.[0]?.key as string | undefined) + .filter((k): k is string => k !== undefined); - console.error("Environment validation failed:"); - console.error("Missing required variables:", missingKeys.join(", ")); - console.error("\nPlease check .env.example for all required variables."); - process.exit(1); - } + console.error("Environment validation failed:"); + console.error("Missing required variables:", missingKeys.join(", ")); + console.error("\nPlease check .env.example for all required variables."); + process.exit(1); + } - return parse(envSchema, { ...process.env }); + return parse(envSchema, { ...process.env }); } export const env = validateEnv(); diff --git a/web/src/server/services/alert.publisher.test.ts b/web/src/server/services/alert.publisher.test.ts index a520af2..0125970 100644 --- a/web/src/server/services/alert.publisher.test.ts +++ b/web/src/server/services/alert.publisher.test.ts @@ -4,129 +4,131 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mockBroadcastToUser = vi.fn(); vi.mock("~/server/websocket", () => ({ - broadcastToUser: mockBroadcastToUser, + broadcastToUser: mockBroadcastToUser, })); const mockSendEmail = vi.fn(); vi.mock("~/server/lib/email", () => ({ - sendEmail: mockSendEmail, + sendEmail: mockSendEmail, })); vi.mock("~/server/db", () => ({ - db: { - select: vi.fn(), - insert: vi.fn(), - update: vi.fn(), - }, + db: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + }, })); beforeEach(() => { - vi.clearAllMocks(); + vi.clearAllMocks(); }); describe("alert.publisher", () => { - it("should send alert via WebSocket when user is connected", async () => { - mockBroadcastToUser.mockReturnValue(true); + it("should send alert via WebSocket when user is connected", async () => { + mockBroadcastToUser.mockReturnValue(true); - const { publishAlert } = await import("./alert.publisher"); - await publishAlert("user-1", { - id: "alert-1", - title: "Test Alert", - message: "Test message", - severity: "HIGH", - source: "DARKWATCH", - category: "EXPOSURE_DETECTED", - createdAt: new Date(), - }); + const { publishAlert } = await import("./alert.publisher"); + await publishAlert("user-1", { + id: "alert-1", + title: "Test Alert", + message: "Test message", + severity: "HIGH", + source: "DARKWATCH", + category: "EXPOSURE_DETECTED", + createdAt: new Date(), + }); - expect(mockBroadcastToUser).toHaveBeenCalledWith("user-1", { - type: "alert", - alert: { - id: "alert-1", - title: "Test Alert", - message: "Test message", - severity: "HIGH", - source: "DARKWATCH", - category: "EXPOSURE_DETECTED", - createdAt: expect.any(String), - }, - }); - expect(mockSendEmail).not.toHaveBeenCalled(); - }); + expect(mockBroadcastToUser).toHaveBeenCalledWith("user-1", { + type: "alert", + alert: { + id: "alert-1", + title: "Test Alert", + message: "Test message", + severity: "HIGH", + source: "DARKWATCH", + category: "EXPOSURE_DETECTED", + createdAt: expect.any(String), + }, + }); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); - it("should fall back to email when user is not connected and has email", async () => { - mockBroadcastToUser.mockReturnValue(false); + it("should fall back to email when user is not connected and has email", async () => { + mockBroadcastToUser.mockReturnValue(false); - const db = await import("~/server/db"); - (db.db.select as ReturnType).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([{ id: "user-1", email: "user@example.com" }]), - }), - }), - }); + const db = await import("~/server/db"); + (db.db.select as ReturnType).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi + .fn() + .mockResolvedValue([{ id: "user-1", email: "user@example.com" }]), + }), + }), + }); - const { publishAlert } = await import("./alert.publisher"); - await publishAlert("user-1", { - id: "alert-2", - title: "Offline Alert", - message: "Offline message", - severity: "WARNING", - source: "VOICEPRINT", - category: "SYNTHETIC_VOICE", - createdAt: new Date(), - }); + const { publishAlert } = await import("./alert.publisher"); + await publishAlert("user-1", { + id: "alert-2", + title: "Offline Alert", + message: "Offline message", + severity: "WARNING", + source: "VOICEPRINT", + category: "SYNTHETIC_VOICE", + createdAt: new Date(), + }); - expect(mockBroadcastToUser).toHaveBeenCalled(); - expect(mockSendEmail).toHaveBeenCalledWith( - "user@example.com", - "[Kordant] Offline Alert", - "

Offline message

", - "Offline message", - ); - }); + expect(mockBroadcastToUser).toHaveBeenCalled(); + expect(mockSendEmail).toHaveBeenCalledWith( + "user@example.com", + "[Kordant] Offline Alert", + "

Offline message

", + "Offline message", + ); + }); - it("should not send email when user has no email", async () => { - mockBroadcastToUser.mockReturnValue(false); + it("should not send email when user has no email", async () => { + mockBroadcastToUser.mockReturnValue(false); - const db = await import("~/server/db"); - (db.db.select as ReturnType).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: vi.fn().mockResolvedValue([]), - }), - }), - }); + const db = await import("~/server/db"); + (db.db.select as ReturnType).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([]), + }), + }), + }); - 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(), - }); + 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(); - }); + expect(mockSendEmail).not.toHaveBeenCalled(); + }); - it("should publish alert to multiple users", async () => { - mockBroadcastToUser.mockReturnValue(true); + 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(), - }); + 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); - }); + expect(mockBroadcastToUser).toHaveBeenCalledTimes(2); + }); }); diff --git a/web/src/server/services/alert.publisher.ts b/web/src/server/services/alert.publisher.ts index bd8778d..29e24bc 100644 --- a/web/src/server/services/alert.publisher.ts +++ b/web/src/server/services/alert.publisher.ts @@ -5,54 +5,60 @@ import { users } from "~/server/db/schema/auth"; import { eq } from "drizzle-orm"; export interface PublishableAlert { - id: string; - title: string; - message: string; - severity: string; - source: string; - category: string; - createdAt: Date; + id: string; + title: string; + message: string; + severity: string; + source: string; + category: string; + createdAt: Date; } -export async function publishAlert(userId: string, alert: PublishableAlert): Promise { - const message = { - type: "alert" as const, - alert: { - id: alert.id, - title: alert.title, - message: alert.message, - severity: alert.severity, - source: alert.source, - category: alert.category, - createdAt: alert.createdAt.toISOString(), - }, - }; +export async function publishAlert( + userId: string, + alert: PublishableAlert, +): Promise { + const message = { + type: "alert" as const, + alert: { + id: alert.id, + title: alert.title, + message: alert.message, + severity: alert.severity, + source: alert.source, + category: alert.category, + createdAt: alert.createdAt.toISOString(), + }, + }; - const sent = broadcastToUser(userId, message); + const sent = broadcastToUser(userId, message); - if (!sent) { - const [user] = await db - .select() - .from(users) - .where(eq(users.id, userId)) - .limit(1); + if (!sent) { + const [user] = await db + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1); - if (user?.email) { - try { - await sendEmail( - user.email, - `[Kordant] ${alert.title}`, - `

${alert.message}

`, - alert.message, - ); - } catch (err) { - console.error("[alert.publisher] Email notification failed:", err); - } - } - } + if (user?.email) { + try { + await sendEmail( + user.email, + `[Kordant] ${alert.title}`, + `

${alert.message}

`, + alert.message, + ); + } catch (err) { + console.error("[alert.publisher] Email notification failed:", err); + } + } + } } -export async function publishToGroup(userIds: string[], alert: PublishableAlert): Promise { - const promises = userIds.map((userId) => publishAlert(userId, alert)); - await Promise.allSettled(promises); +export async function publishToGroup( + userIds: string[], + alert: PublishableAlert, +): Promise { + const promises = userIds.map((userId) => publishAlert(userId, alert)); + await Promise.allSettled(promises); } diff --git a/web/src/server/services/darkwatch/digest.service.ts b/web/src/server/services/darkwatch/digest.service.ts index ab2f421..bf593e3 100644 --- a/web/src/server/services/darkwatch/digest.service.ts +++ b/web/src/server/services/darkwatch/digest.service.ts @@ -8,21 +8,21 @@ import { sendEmail } from "~/server/lib/email"; // --------------------------------------------------------------------------- export interface DigestConfig { - /** Severity levels that get batched into digest (vs immediate) */ - batchedSeverities: string[]; - /** Digest frequency: "daily" or "weekly" */ - frequency: "daily" | "weekly"; - /** Time of day for daily digest (UTC hour) */ - dailyHour: number; - /** Day of week for weekly digest (0=Sun) */ - weeklyDay: number; + /** Severity levels that get batched into digest (vs immediate) */ + batchedSeverities: string[]; + /** Digest frequency: "daily" or "weekly" */ + frequency: "daily" | "weekly"; + /** Time of day for daily digest (UTC hour) */ + dailyHour: number; + /** Day of week for weekly digest (0=Sun) */ + weeklyDay: number; } export const DEFAULT_DIGEST_CONFIG: DigestConfig = { - batchedSeverities: ["info"], - frequency: "daily", - dailyHour: 9, // 9 AM UTC - weeklyDay: 0, // Sunday + batchedSeverities: ["info"], + frequency: "daily", + dailyHour: 9, // 9 AM UTC + weeklyDay: 0, // Sunday }; /** @@ -30,75 +30,77 @@ export const DEFAULT_DIGEST_CONFIG: DigestConfig = { * and user preferences. */ export async function shouldDigest( - userId: string, - severity: string, + userId: string, + severity: string, ): Promise { - const [prefs] = await db - .select() - .from(notificationPreferences) - .where(eq(notificationPreferences.userId, userId)) - .limit(1); + const [prefs] = await db + .select() + .from(notificationPreferences) + .where(eq(notificationPreferences.userId, userId)) + .limit(1); - // If user has no prefs, use defaults: info = digest, warning/critical = immediate - if (!prefs) { - return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity); - } + // If user has no prefs, use defaults: info = digest, warning/critical = immediate + if (!prefs) { + return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity); + } - // If email is disabled entirely, don't digest (alert won't be delivered) - if (!prefs.emailEnabled) { - return false; - } + // If email is disabled entirely, don't digest (alert won't be delivered) + if (!prefs.emailEnabled) { + return false; + } - return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity); + return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity); } /** * Calculates the next scheduled digest date based on config. */ -export function calculateNextDigestDate(config: DigestConfig = DEFAULT_DIGEST_CONFIG): Date { - const now = new Date(); - const next = new Date(now); +export function calculateNextDigestDate( + config: DigestConfig = DEFAULT_DIGEST_CONFIG, +): Date { + const now = new Date(); + const next = new Date(now); - if (config.frequency === "daily") { - next.setUTCHours(config.dailyHour, 0, 0, 0); - if (next.getTime() <= now.getTime()) { - next.setUTCDate(next.getUTCDate() + 1); - } - } else { - next.setUTCHours(config.dailyHour, 0, 0, 0); - const currentDay = next.getUTCDay(); - const daysUntilTarget = (config.weeklyDay - currentDay + 7) % 7; - if (daysUntilTarget === 0 && next.getTime() <= now.getTime()) { - next.setUTCDate(next.getUTCDate() + 7); - } else if (daysUntilTarget > 0 || next.getTime() <= now.getTime()) { - next.setUTCDate(next.getUTCDate() + daysUntilTarget); - } - } + if (config.frequency === "daily") { + next.setUTCHours(config.dailyHour, 0, 0, 0); + if (next.getTime() <= now.getTime()) { + next.setUTCDate(next.getUTCDate() + 1); + } + } else { + next.setUTCHours(config.dailyHour, 0, 0, 0); + const currentDay = next.getUTCDay(); + const daysUntilTarget = (config.weeklyDay - currentDay + 7) % 7; + if (daysUntilTarget === 0 && next.getTime() <= now.getTime()) { + next.setUTCDate(next.getUTCDate() + 7); + } else if (daysUntilTarget > 0 || next.getTime() <= now.getTime()) { + next.setUTCDate(next.getUTCDate() + daysUntilTarget); + } + } - return next; + return next; } /** * Queues an alert for the next digest email. */ export async function queueForDigest( - userId: string, - alertId: string, - title: string, - severity: string, - source: string, + userId: string, + alertId: string, + title: string, + severity: string, + source: string, ): Promise { - const nextDigestDate = calculateNextDigestDate(); + const nextDigestDate = calculateNextDigestDate(); - await db.insert(digestAlerts).values({ - userId, - alertId, - title, - severity, - source, - scheduledDigestDate: nextDigestDate, - sent: false, - }); + await db.insert(digestAlerts).values({ + userId, + alertId, + title, + severity, + source, + scheduledDigestDate: nextDigestDate, + sent: false, + }); } /** @@ -106,71 +108,75 @@ export async function queueForDigest( * Returns the number of alerts included in the digest. */ export async function sendDigestEmail( - userId: string, - scheduledDate: Date, + userId: string, + scheduledDate: Date, ): Promise { - const pendingAlerts = await db - .select() - .from(digestAlerts) - .where( - and( - eq(digestAlerts.userId, userId), - eq(digestAlerts.sent, false), - eq(digestAlerts.scheduledDigestDate, scheduledDate), - ), - ) - .orderBy(asc(digestAlerts.severity)); + const pendingAlerts = await db + .select() + .from(digestAlerts) + .where( + and( + eq(digestAlerts.userId, userId), + eq(digestAlerts.sent, false), + eq(digestAlerts.scheduledDigestDate, scheduledDate), + ), + ) + .orderBy(asc(digestAlerts.severity)); - if (!pendingAlerts.length) { - return 0; - } + if (!pendingAlerts.length) { + return 0; + } - // Get user email - const { users } = await import("~/server/db/schema/auth"); - const [user] = await db - .select({ email: users.email }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); + // Get user email + const { users } = await import("~/server/db/schema/auth"); + const [user] = await db + .select({ email: users.email }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); - if (!user?.email) { - console.warn(`[digest] No email found for user ${userId}`); - return 0; - } + if (!user?.email) { + console.warn(`[digest] No email found for user ${userId}`); + return 0; + } - // Build digest email content - const alertsBySeverity = groupBySeverity(pendingAlerts); - const html = buildDigestEmailHTML(alertsBySeverity, pendingAlerts.length); + // Build digest email content + const alertsBySeverity = groupBySeverity(pendingAlerts); + const html = buildDigestEmailHTML(alertsBySeverity, pendingAlerts.length); - try { - await sendEmail( - user.email, - `[Kordant] Security Digest — ${pendingAlerts.length} alert${pendingAlerts.length > 1 ? "s" : ""}`, - html, - buildDigestPlainText(alertsBySeverity, pendingAlerts.length), - ); + try { + await sendEmail( + user.email, + `[Kordant] Security Digest — ${pendingAlerts.length} alert${pendingAlerts.length > 1 ? "s" : ""}`, + html, + buildDigestPlainText(alertsBySeverity, pendingAlerts.length), + ); - // Mark alerts as sent - const alertIds = pendingAlerts.map((a) => a.id); - await db - .update(digestAlerts) - .set({ sent: true, sentAt: new Date() }) - .where(and(eq(digestAlerts.userId, userId), eq(digestAlerts.id, alertIds[0]))); + // Mark alerts as sent + const alertIds = pendingAlerts.map((a) => a.id); + await db + .update(digestAlerts) + .set({ sent: true, sentAt: new Date() }) + .where( + and(eq(digestAlerts.userId, userId), eq(digestAlerts.id, alertIds[0])), + ); - // Update all matching alerts - for (const alertId of alertIds) { - await db - .update(digestAlerts) - .set({ sent: true, sentAt: new Date() }) - .where(eq(digestAlerts.id, alertId)); - } + // Update all matching alerts + for (const alertId of alertIds) { + await db + .update(digestAlerts) + .set({ sent: true, sentAt: new Date() }) + .where(eq(digestAlerts.id, alertId)); + } - console.log(`[digest] Sent digest to ${user.email} with ${pendingAlerts.length} alerts`); - return pendingAlerts.length; - } catch (err) { - console.error(`[digest] Failed to send digest for user ${userId}:`, err); - return 0; - } + console.log( + `[digest] Sent digest to ${user.email} with ${pendingAlerts.length} alerts`, + ); + return pendingAlerts.length; + } catch (err) { + console.error(`[digest] Failed to send digest for user ${userId}:`, err); + return 0; + } } /** @@ -178,42 +184,38 @@ export async function sendDigestEmail( * Called by the digest job scheduler. */ export async function processDueDigests(): Promise { - const now = new Date(); - const today = new Date(now.toISOString().split("T")[0]); - const tomorrow = new Date(today); - tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); + const now = new Date(); + const today = new Date(now.toISOString().split("T")[0]); + const tomorrow = new Date(today); + tomorrow.setUTCDate(tomorrow.getUTCDate() + 1); - // Find all users with pending digests due today - const { users } = await import("~/server/db/schema/auth"); + // Find all users with pending digests due today + const { users } = await import("~/server/db/schema/auth"); - // Get distinct userIds with pending digests - const pendingDigests = await db - .select({ - userId: digestAlerts.userId, - scheduledDate: digestAlerts.scheduledDigestDate, - }) - .from(digestAlerts) - .where( - and( - eq(digestAlerts.sent, false), - ), - ); + // Get distinct userIds with pending digests + const pendingDigests = await db + .select({ + userId: digestAlerts.userId, + scheduledDate: digestAlerts.scheduledDigestDate, + }) + .from(digestAlerts) + .where(and(eq(digestAlerts.sent, false))); - // Group by user - const userMap = new Map(); - for (const d of pendingDigests) { - const dates = userMap.get(d.userId) ?? []; - dates.push(d.scheduledDate); - userMap.set(d.userId, dates); - } + // Group by user + const userMap = new Map(); + for (const d of pendingDigests) { + const dates = userMap.get(d.userId) ?? []; + dates.push(d.scheduledDate); + userMap.set(d.userId, dates); + } - for (const [userId, dates] of userMap) { - for (const date of [...new Set(dates)]) { - if (date.getTime() <= now.getTime()) { - await sendDigestEmail(userId, date); - } - } - } + for (const [userId, dates] of userMap) { + for (const date of [...new Set(dates)]) { + if (date.getTime() <= now.getTime()) { + await sendDigestEmail(userId, date); + } + } + } } // --------------------------------------------------------------------------- @@ -221,62 +223,62 @@ export async function processDueDigests(): Promise { // --------------------------------------------------------------------------- function groupBySeverity( - alerts: typeof digestAlerts.$InferInsert[], -): Record { - const groups: Record = { - critical: [], - warning: [], - info: [], - }; + alerts: (typeof digestAlerts.$InferInsert)[], +): Record { + const groups: Record = { + critical: [], + warning: [], + info: [], + }; - for (const alert of alerts) { - const key = alert.severity ?? "info"; - if (groups[key]) { - groups[key].push(alert); - } else { - groups.info.push(alert); - } - } + for (const alert of alerts) { + const key = alert.severity ?? "info"; + if (groups[key]) { + groups[key].push(alert); + } else { + groups.info.push(alert); + } + } - return groups; + return groups; } function buildDigestEmailHTML( - groups: Record, - total: number, + groups: Record, + total: number, ): string { - const sections = []; + const sections = []; - const severityConfig = [ - { key: "critical", label: "Critical", color: "#dc2626", bg: "#fef2f2" }, - { key: "warning", label: "Warning", color: "#d97706", bg: "#fffbeb" }, - { key: "info", label: "Info", color: "#2563eb", bg: "#eff6ff" }, - ]; + const severityConfig = [ + { key: "critical", label: "Critical", color: "#dc2626", bg: "#fef2f2" }, + { key: "warning", label: "Warning", color: "#d97706", bg: "#fffbeb" }, + { key: "info", label: "Info", color: "#2563eb", bg: "#eff6ff" }, + ]; - for (const { key, label, color, bg } of severityConfig) { - const alerts = groups[key]; - if (!alerts.length) continue; + for (const { key, label, color, bg } of severityConfig) { + const alerts = groups[key]; + if (!alerts.length) continue; - const rows = alerts - .map( - (a) => - ` + const rows = alerts + .map( + (a) => + ` ${a.severity} ${escapeHtml(a.title)} ${escapeHtml(a.source)} `, - ) - .join(""); + ) + .join(""); - sections.push(` + sections.push(`

${label} (${alerts.length})

${rows}
`); - } + } - return ` + return `

🛡️ Kordant Security Digest

${total} alert${total > 1 ? "s" : ""} since your last digest

@@ -289,45 +291,42 @@ function buildDigestEmailHTML( } function buildDigestPlainText( - groups: Record, - total: number, + groups: Record, + total: number, ): string { - const lines = [`Kordant Security Digest — ${total} alert${total > 1 ? "s" : ""}`, ""]; + const lines = [ + `Kordant Security Digest — ${total} alert${total > 1 ? "s" : ""}`, + "", + ]; - for (const [key, alerts] of Object.entries(groups)) { - if (!alerts.length) continue; - lines.push(`${key.toUpperCase()} (${alerts.length}):`); - for (const a of alerts) { - lines.push(` - ${a.title} [${a.source}]`); - } - lines.push(""); - } + for (const [key, alerts] of Object.entries(groups)) { + if (!alerts.length) continue; + lines.push(`${key.toUpperCase()} (${alerts.length}):`); + for (const a of alerts) { + lines.push(` - ${a.title} [${a.source}]`); + } + lines.push(""); + } - lines.push("This is an automated digest from Kordant."); - return lines.join("\n"); + lines.push("This is an automated digest from Kordant."); + return lines.join("\n"); } function escapeHtml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } /** * Cleans up old digest records (older than 30 days). */ export async function cleanupOldDigests(): Promise { - const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - await db - .delete(digestAlerts) - .where( - and( - eq(digestAlerts.sent, true), - ), - ); + await db.delete(digestAlerts).where(and(eq(digestAlerts.sent, true))); - console.log(`[digest] Cleaned up old digest records`); + console.log(`[digest] Cleaned up old digest records`); } diff --git a/web/src/server/services/user.service.ts b/web/src/server/services/user.service.ts index 4681b0e..7bea7c2 100644 --- a/web/src/server/services/user.service.ts +++ b/web/src/server/services/user.service.ts @@ -8,401 +8,417 @@ import { createSession } from "~/server/auth/session"; import { signJWT } from "~/server/auth/jwt"; export async function createUserWithPassword( - name: string, - email: string, - password: string, + name: string, + email: string, + password: string, ) { - const [existing] = await db - .select() - .from(users) - .where(eq(users.email, email)) - .limit(1); + const [existing] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); - if (existing) { - throw new TRPCError({ - code: "CONFLICT", - message: "Email already in use", - }); - } + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: "Email already in use", + }); + } - const passwordHash = await hashPassword(password); - const [user] = await db - .insert(users) - .values({ name, email, passwordHash }) - .returning(); + const passwordHash = await hashPassword(password); + const [user] = await db + .insert(users) + .values({ name, email, passwordHash }) + .returning(); - const session = await createSession(user.id); - const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" }); + const session = await createSession(user.id); + const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" }); - return { user, sessionToken: session.sessionToken, accessToken }; + return { user, sessionToken: session.sessionToken, accessToken }; } -export async function authenticateUser( - email: string, - password: string, -) { - const [user] = await db - .select() - .from(users) - .where(eq(users.email, email)) - .limit(1); +export async function authenticateUser(email: string, password: string) { + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); - if (!user || !user.passwordHash) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid email or password", - }); - } + if (!user || !user.passwordHash) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid email or password", + }); + } - const valid = await verifyPassword(password, user.passwordHash); - if (!valid) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid email or password", - }); - } + const valid = await verifyPassword(password, user.passwordHash); + if (!valid) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid email or password", + }); + } - const session = await createSession(user.id); - const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" }); - return { user, sessionToken: session.sessionToken, accessToken }; + const session = await createSession(user.id); + const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" }); + return { user, sessionToken: session.sessionToken, accessToken }; } const APPLE_ISSUER = "https://appleid.apple.com"; const APPLE_JWKS_URL = new URL("https://appleid.apple.com/auth/keys"); - - /** * Verifies an Apple identity token and authenticates the user. * If the user does not exist, creates a new account. * If the user exists but has not linked Apple, links the provider. */ export async function authenticateWithApple( - identityToken: string, - authorizationCode: string, - userIdentifier?: string | null, + identityToken: string, + authorizationCode: string, + userIdentifier?: string | null, ) { - if (!identityToken) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Missing identity token", - }); - } + if (!identityToken) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Missing identity token", + }); + } - // Verify Apple ID token using Apple's JWKS - let payload: { sub: string; email?: string; is_private_email?: string; }; - try { - const JWKS = createRemoteJWKSet(APPLE_JWKS_URL); - const result = await jwtVerify(identityToken, JWKS, { - issuer: APPLE_ISSUER, - audience: process.env.IOS_BUNDLE_ID ?? "com.frenocorp.kordant", - }); - payload = result.payload as unknown as { sub: string; email?: string; is_private_email?: string; }; - } catch (err) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid Apple identity token", - }); - } + // Verify Apple ID token using Apple's JWKS + let payload: { sub: string; email?: string; is_private_email?: string }; + try { + const JWKS = createRemoteJWKSet(APPLE_JWKS_URL); + const result = await jwtVerify(identityToken, JWKS, { + issuer: APPLE_ISSUER, + audience: process.env.IOS_BUNDLE_ID ?? "com.frenocorp.kordant", + }); + payload = result.payload as unknown as { + sub: string; + email?: string; + is_private_email?: string; + }; + } catch (err) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid Apple identity token", + }); + } - const appleUserId = payload.sub; - const email = payload.email ?? null; + const appleUserId = payload.sub; + const email = payload.email ?? null; - if (!email) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Apple account has no email address", - }); - } + if (!email) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Apple account has no email address", + }); + } - // Check if this Apple account is already linked - const [existingAccount] = await db - .select() - .from(accounts) - .where( - and( - eq(accounts.provider, "apple"), - eq(accounts.providerAccountId, appleUserId), - ), - ) - .limit(1); + // Check if this Apple account is already linked + const [existingAccount] = await db + .select() + .from(accounts) + .where( + and( + eq(accounts.provider, "apple"), + eq(accounts.providerAccountId, appleUserId), + ), + ) + .limit(1); - let userId: string; - let isNewUser = false; + let userId: string; + let isNewUser = false; - if (existingAccount) { - // Already linked — use the existing user - userId = existingAccount.userId; - isNewUser = false; + if (existingAccount) { + // Already linked — use the existing user + userId = existingAccount.userId; + isNewUser = false; - // Update tokens - await db - .update(accounts) - .set({ - accessToken: identityToken, - refreshToken: authorizationCode, - updatedAt: new Date(), - }) - .where(eq(accounts.id, existingAccount.id)); - } else { - // Not linked — check if a user with this email exists - const [existingUserByEmail] = await db - .select() - .from(users) - .where(and(eq(users.email, email), isNull(users.deletedAt))) - .limit(1); + // Update tokens + await db + .update(accounts) + .set({ + accessToken: identityToken, + refreshToken: authorizationCode, + updatedAt: new Date(), + }) + .where(eq(accounts.id, existingAccount.id)); + } else { + // Not linked — check if a user with this email exists + const [existingUserByEmail] = await db + .select() + .from(users) + .where(and(eq(users.email, email), isNull(users.deletedAt))) + .limit(1); - // Apple provides the user's first name and last name only on the initial sign-up - // We derive a display name from email if userIdentifier-based lookup doesn't work - const displayName = email.split("@")[0] ?? "User"; + // Apple provides the user's first name and last name only on the initial sign-up + // We derive a display name from email if userIdentifier-based lookup doesn't work + const displayName = email.split("@")[0] ?? "User"; - if (existingUserByEmail) { - // Link Apple to existing user - userId = existingUserByEmail.id; - isNewUser = false; - await db.insert(accounts).values({ - userId, - provider: "apple", - providerAccountId: appleUserId, - accessToken: identityToken, - refreshToken: authorizationCode, - }); - } else { - // Create new user with Apple - isNewUser = true; - const [newUser] = await db - .insert(users) - .values({ - name: displayName, - email, - emailVerified: new Date(), - }) - .returning(); - userId = newUser.id; + if (existingUserByEmail) { + // Link Apple to existing user + userId = existingUserByEmail.id; + isNewUser = false; + await db.insert(accounts).values({ + userId, + provider: "apple", + providerAccountId: appleUserId, + accessToken: identityToken, + refreshToken: authorizationCode, + }); + } else { + // Create new user with Apple + isNewUser = true; + const [newUser] = await db + .insert(users) + .values({ + name: displayName, + email, + emailVerified: new Date(), + }) + .returning(); + userId = newUser.id; - await db.insert(accounts).values({ - userId, - provider: "apple", - providerAccountId: appleUserId, - accessToken: identityToken, - refreshToken: authorizationCode, - }); - } - } + await db.insert(accounts).values({ + userId, + provider: "apple", + providerAccountId: appleUserId, + accessToken: identityToken, + refreshToken: authorizationCode, + }); + } + } - // Create session and JWT - const session = await createSession(userId); - const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" }); - const refreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" }); + // Create session and JWT + const session = await createSession(userId); + const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" }); + const refreshToken = await signJWT( + { sub: userId, type: "refresh" }, + { expiresIn: "30d" }, + ); - const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); - if (!user) { - throw new TRPCError({ code: "NOT_FOUND", message: "User not found after creation" }); - } + const [user] = await db + .select() + .from(users) + .where(eq(users.id, userId)) + .limit(1); + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found after creation", + }); + } - return { user, sessionToken: session.sessionToken, accessToken, refreshToken, isNewUser }; + return { + user, + sessionToken: session.sessionToken, + accessToken, + refreshToken, + isNewUser, + }; } /** * Refreshes an access token using a valid refresh token. */ export async function refreshAccessToken(refreshToken: string) { - const { verifyJWT, signJWT } = await import("~/server/auth/jwt"); + const { verifyJWT, signJWT } = await import("~/server/auth/jwt"); - let payload: { sub?: string; type?: string }; - try { - payload = await verifyJWT<{ sub: string; type: string }>(refreshToken); - } catch { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid or expired refresh token", - }); - } + let payload: { sub?: string; type?: string }; + try { + payload = await verifyJWT<{ sub: string; type: string }>(refreshToken); + } catch { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid or expired refresh token", + }); + } - if (payload.type !== "refresh") { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid token type", - }); - } + if (payload.type !== "refresh") { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid token type", + }); + } - const userId = payload.sub!; - const [user] = await db - .select() - .from(users) - .where(and(eq(users.id, userId), isNull(users.deletedAt))) - .limit(1); + const userId = payload.sub!; + const [user] = await db + .select() + .from(users) + .where(and(eq(users.id, userId), isNull(users.deletedAt))) + .limit(1); - if (!user) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "User not found", - }); - } + if (!user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "User not found", + }); + } - const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" }); - const newRefreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" }); + const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" }); + const newRefreshToken = await signJWT( + { sub: userId, type: "refresh" }, + { expiresIn: "30d" }, + ); - return { accessToken: newAccessToken, refreshToken: newRefreshToken }; + return { accessToken: newAccessToken, refreshToken: newRefreshToken }; } /** * Sends a password reset email. */ export async function forgotPassword(email: string) { - const [user] = await db - .select() - .from(users) - .where(and(eq(users.email, email), isNull(users.deletedAt))) - .limit(1); + const [user] = await db + .select() + .from(users) + .where(and(eq(users.email, email), isNull(users.deletedAt))) + .limit(1); - if (!user) { - // Don't reveal whether the email exists - return { success: true }; - } + if (!user) { + // Don't reveal whether the email exists + return { success: true }; + } - // Generate a reset token (valid for 1 hour) - const resetToken = await signJWT( - { sub: user.id, type: "password-reset" }, - { expiresIn: "1h" }, - ); + // Generate a reset token (valid for 1 hour) + const resetToken = await signJWT( + { sub: user.id, type: "password-reset" }, + { expiresIn: "1h" }, + ); - // In production, send via email service (Resend, SendGrid, etc.) - // For now, we log it and return success - console.log(`Password reset token for ${email}: ${resetToken}`); + // In production, send via email service (Resend, SendGrid, etc.) + // For now, we log it and return success + console.log(`Password reset token for ${email}: ${resetToken}`); - // TODO: Send email via Resend - // const { Resend } = await import("resend"); - // const resend = new Resend(process.env.RESEND_API_KEY); - // await resend.emails.send({ - // from: "Kordant ", - // to: email, - // subject: "Reset your password", - // html: `Reset password`, - // }); + // TODO: Send email via Resend + // const { Resend } = await import("resend"); + // const resend = new Resend(process.env.RESEND_API_KEY); + // await resend.emails.send({ + // from: "Kordant ", + // to: email, + // subject: "Reset your password", + // html: `Reset password`, + // }); - return { success: true }; + return { success: true }; } /** * Resets a user's password using a valid reset token. */ export async function resetPassword(token: string, newPassword: string) { - const { verifyJWT } = await import("~/server/auth/jwt"); + const { verifyJWT } = await import("~/server/auth/jwt"); - let payload: { sub?: string; type?: string }; - try { - payload = await verifyJWT<{ sub: string; type: string }>(token); - } catch { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid or expired reset token", - }); - } + let payload: { sub?: string; type?: string }; + try { + payload = await verifyJWT<{ sub: string; type: string }>(token); + } catch { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid or expired reset token", + }); + } - if (payload.type !== "password-reset") { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: "Invalid token type", - }); - } + if (payload.type !== "password-reset") { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid token type", + }); + } - const userId = payload.sub!; - const passwordHash = await hashPassword(newPassword); + const userId = payload.sub!; + const passwordHash = await hashPassword(newPassword); - await db - .update(users) - .set({ passwordHash, updatedAt: new Date() }) - .where(eq(users.id, userId)); + await db + .update(users) + .set({ passwordHash, updatedAt: new Date() }) + .where(eq(users.id, userId)); - return { success: true }; + return { success: true }; } /** * Revokes all sessions for a user (logout everywhere). */ export async function revokeUserSessions(userId: string) { - const { sessions } = await import("~/server/db/schema/auth"); - await db - .delete(sessions) - .where(eq(sessions.userId, userId)); - return { success: true }; + const { sessions } = await import("~/server/db/schema/auth"); + await db.delete(sessions).where(eq(sessions.userId, userId)); + return { success: true }; } export async function getUserById(id: string) { - const user = await db.query.users.findFirst({ - where: eq(users.id, id), - with: { - accounts: true, - sessions: true, - deviceTokens: true, - familyGroups: true, - familyGroupOwned: true, - subscriptions: true, - }, - }); + const user = await db.query.users.findFirst({ + where: eq(users.id, id), + with: { + accounts: true, + sessions: true, + deviceTokens: true, + familyGroups: true, + familyGroupOwned: true, + subscriptions: true, + }, + }); - if (!user) { - throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); - } + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } - return user; + return user; } export async function updateUser( - id: string, - data: { name?: string; email?: string; image?: string }, + id: string, + data: { name?: string; email?: string; image?: string }, ) { - const [existing] = await db - .select() - .from(users) - .where(eq(users.id, id)) - .limit(1); + const [existing] = await db + .select() + .from(users) + .where(eq(users.id, id)) + .limit(1); - if (!existing) { - throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); - } + if (!existing) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } - if (data.email && data.email !== existing.email) { - const [duplicate] = await db - .select() - .from(users) - .where(eq(users.email, data.email)) - .limit(1); + if (data.email && data.email !== existing.email) { + const [duplicate] = await db + .select() + .from(users) + .where(eq(users.email, data.email)) + .limit(1); - if (duplicate) { - throw new TRPCError({ - code: "CONFLICT", - message: "Email already in use", - }); - } - } + if (duplicate) { + throw new TRPCError({ + code: "CONFLICT", + message: "Email already in use", + }); + } + } - const [updated] = await db - .update(users) - .set(data) - .where(eq(users.id, id)) - .returning(); + const [updated] = await db + .update(users) + .set(data) + .where(eq(users.id, id)) + .returning(); - return updated; + return updated; } export async function deleteUser(id: string) { - const [existing] = await db - .select() - .from(users) - .where(eq(users.id, id)) - .limit(1); + const [existing] = await db + .select() + .from(users) + .where(eq(users.id, id)) + .limit(1); - if (!existing) { - throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); - } + if (!existing) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } - const [deleted] = await db - .update(users) - .set({ deletedAt: new Date() }) - .where(eq(users.id, id)) - .returning(); + const [deleted] = await db + .update(users) + .set({ deletedAt: new Date() }) + .where(eq(users.id, id)) + .returning(); - return deleted; + return deleted; }