This commit is contained in:
2026-06-03 14:05:49 -04:00
parent a07c004f2d
commit 8e953cdd7c
11 changed files with 1281 additions and 1221 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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",
"<p>Offline message</p>",
"Offline message",
);
});
expect(mockBroadcastToUser).toHaveBeenCalled();
expect(mockSendEmail).toHaveBeenCalledWith(
"user@example.com",
"[Kordant] Offline Alert",
"<p>Offline message</p>",
"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<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
});
const db = await import("~/server/db");
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
});
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);
});
});

View File

@@ -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<void> {
const message = {
type: "alert" as const,
alert: {
id: alert.id,
title: alert.title,
message: alert.message,
severity: alert.severity,
source: alert.source,
category: alert.category,
createdAt: alert.createdAt.toISOString(),
},
};
export async function publishAlert(
userId: string,
alert: PublishableAlert,
): Promise<void> {
const message = {
type: "alert" as const,
alert: {
id: alert.id,
title: alert.title,
message: alert.message,
severity: alert.severity,
source: alert.source,
category: alert.category,
createdAt: alert.createdAt.toISOString(),
},
};
const sent = broadcastToUser(userId, message);
const sent = broadcastToUser(userId, message);
if (!sent) {
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}`,
`<p>${alert.message}</p>`,
alert.message,
);
} catch (err) {
console.error("[alert.publisher] Email notification failed:", err);
}
}
}
if (user?.email) {
try {
await sendEmail(
user.email,
`[Kordant] ${alert.title}`,
`<p>${alert.message}</p>`,
alert.message,
);
} catch (err) {
console.error("[alert.publisher] Email notification failed:", err);
}
}
}
}
export async function publishToGroup(userIds: string[], alert: PublishableAlert): Promise<void> {
const promises = userIds.map((userId) => publishAlert(userId, alert));
await Promise.allSettled(promises);
export async function publishToGroup(
userIds: string[],
alert: PublishableAlert,
): Promise<void> {
const promises = userIds.map((userId) => publishAlert(userId, alert));
await Promise.allSettled(promises);
}

View File

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

View File

@@ -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 <support@kordant.com>",
// to: email,
// subject: "Reset your password",
// html: `<a href="${process.env.APP_URL}/reset-password?token=${resetToken}">Reset password</a>`,
// });
// TODO: Send email via Resend
// const { Resend } = await import("resend");
// const resend = new Resend(process.env.RESEND_API_KEY);
// await resend.emails.send({
// from: "Kordant <support@kordant.com>",
// to: email,
// subject: "Reset your password",
// html: `<a href="${process.env.APP_URL}/reset-password?token=${resetToken}">Reset password</a>`,
// });
return { success: true };
return { success: true };
}
/**
* Resets a user's password using a valid reset token.
*/
export async function resetPassword(token: string, newPassword: string) {
const { verifyJWT } = await import("~/server/auth/jwt");
const { verifyJWT } = await import("~/server/auth/jwt");
let payload: { sub?: string; type?: string };
try {
payload = await verifyJWT<{ sub: string; type: string }>(token);
} catch {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid or expired reset token",
});
}
let payload: { sub?: string; type?: string };
try {
payload = await verifyJWT<{ sub: string; type: string }>(token);
} catch {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid or expired reset token",
});
}
if (payload.type !== "password-reset") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token type",
});
}
if (payload.type !== "password-reset") {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid token type",
});
}
const userId = payload.sub!;
const passwordHash = await hashPassword(newPassword);
const userId = payload.sub!;
const passwordHash = await hashPassword(newPassword);
await db
.update(users)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(users.id, userId));
await db
.update(users)
.set({ passwordHash, updatedAt: new Date() })
.where(eq(users.id, userId));
return { success: true };
return { success: true };
}
/**
* Revokes all sessions for a user (logout everywhere).
*/
export async function revokeUserSessions(userId: string) {
const { sessions } = await import("~/server/db/schema/auth");
await db
.delete(sessions)
.where(eq(sessions.userId, userId));
return { success: true };
const { sessions } = await import("~/server/db/schema/auth");
await db.delete(sessions).where(eq(sessions.userId, userId));
return { success: true };
}
export async function getUserById(id: string) {
const user = await db.query.users.findFirst({
where: eq(users.id, id),
with: {
accounts: true,
sessions: true,
deviceTokens: true,
familyGroups: true,
familyGroupOwned: true,
subscriptions: true,
},
});
const user = await db.query.users.findFirst({
where: eq(users.id, id),
with: {
accounts: true,
sessions: true,
deviceTokens: true,
familyGroups: true,
familyGroupOwned: true,
subscriptions: true,
},
});
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
return user;
return user;
}
export async function updateUser(
id: string,
data: { name?: string; email?: string; image?: string },
id: string,
data: { name?: string; email?: string; image?: string },
) {
const [existing] = await db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
const [existing] = await db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
if (data.email && data.email !== existing.email) {
const [duplicate] = await db
.select()
.from(users)
.where(eq(users.email, data.email))
.limit(1);
if (data.email && data.email !== existing.email) {
const [duplicate] = await db
.select()
.from(users)
.where(eq(users.email, data.email))
.limit(1);
if (duplicate) {
throw new TRPCError({
code: "CONFLICT",
message: "Email already in use",
});
}
}
if (duplicate) {
throw new TRPCError({
code: "CONFLICT",
message: "Email already in use",
});
}
}
const [updated] = await db
.update(users)
.set(data)
.where(eq(users.id, id))
.returning();
const [updated] = await db
.update(users)
.set(data)
.where(eq(users.id, id))
.returning();
return updated;
return updated;
}
export async function deleteUser(id: string) {
const [existing] = await db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
const [existing] = await db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
}
const [deleted] = await db
.update(users)
.set({ deletedAt: new Date() })
.where(eq(users.id, id))
.returning();
const [deleted] = await db
.update(users)
.set({ deletedAt: new Date() })
.where(eq(users.id, id))
.returning();
return deleted;
return deleted;
}