fmt
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
import type { APIEvent } from "@solidjs/start/server";
|
import type { APIEvent } from "@solidjs/start/server";
|
||||||
import {
|
import {
|
||||||
authenticateUser,
|
authenticateUser,
|
||||||
authenticateWithApple,
|
authenticateWithApple,
|
||||||
createUserWithPassword,
|
createUserWithPassword,
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
revokeUserSessions,
|
revokeUserSessions,
|
||||||
} from "~/server/services/user.service";
|
} from "~/server/services/user.service";
|
||||||
import { verifyJWT } from "~/server/auth/jwt";
|
import { verifyJWT } from "~/server/auth/jwt";
|
||||||
|
|
||||||
@@ -26,157 +26,166 @@ import { verifyJWT } from "~/server/auth/jwt";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export async function POST(event: APIEvent) {
|
export async function POST(event: APIEvent) {
|
||||||
const action = event.params.action;
|
const action = event.params.action;
|
||||||
const body = await event.request.json().catch(() => ({}));
|
const body = await event.request.json().catch(() => ({}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "login": {
|
case "login": {
|
||||||
const { email, password } = body;
|
const { email, password } = body;
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ message: "Email and password are required" }),
|
JSON.stringify({ message: "Email and password are required" }),
|
||||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const result = await authenticateUser(email, password);
|
const result = await authenticateUser(email, password);
|
||||||
return Response.json({
|
return Response.json({
|
||||||
id: result.user.id,
|
id: result.user.id,
|
||||||
name: result.user.name ?? "",
|
name: result.user.name ?? "",
|
||||||
email: result.user.email,
|
email: result.user.email,
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
sessionToken: result.sessionToken,
|
sessionToken: result.sessionToken,
|
||||||
isNewUser: false,
|
isNewUser: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case "signup": {
|
case "signup": {
|
||||||
const { name, email, password } = body;
|
const { name, email, password } = body;
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ message: "Name, email, and password are required" }),
|
JSON.stringify({
|
||||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
message: "Name, email, and password are required",
|
||||||
);
|
}),
|
||||||
}
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
const result = await createUserWithPassword(
|
);
|
||||||
name ?? email.split("@")[0],
|
}
|
||||||
email,
|
const result = await createUserWithPassword(
|
||||||
password,
|
name ?? email.split("@")[0],
|
||||||
);
|
email,
|
||||||
return Response.json({
|
password,
|
||||||
id: result.user.id,
|
);
|
||||||
name: result.user.name ?? "",
|
return Response.json({
|
||||||
email: result.user.email,
|
id: result.user.id,
|
||||||
accessToken: result.accessToken,
|
name: result.user.name ?? "",
|
||||||
sessionToken: result.sessionToken,
|
email: result.user.email,
|
||||||
isNewUser: true,
|
accessToken: result.accessToken,
|
||||||
});
|
sessionToken: result.sessionToken,
|
||||||
}
|
isNewUser: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case "apple": {
|
case "apple": {
|
||||||
const { identityToken, authorizationCode, userIdentifier } = body;
|
const { identityToken, authorizationCode, userIdentifier } = body;
|
||||||
if (!identityToken || !authorizationCode) {
|
if (!identityToken || !authorizationCode) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ message: "identityToken and authorizationCode are required" }),
|
JSON.stringify({
|
||||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
message: "identityToken and authorizationCode are required",
|
||||||
);
|
}),
|
||||||
}
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
const result = await authenticateWithApple(
|
);
|
||||||
identityToken,
|
}
|
||||||
authorizationCode,
|
const result = await authenticateWithApple(
|
||||||
userIdentifier ?? null,
|
identityToken,
|
||||||
);
|
authorizationCode,
|
||||||
return Response.json({
|
userIdentifier ?? null,
|
||||||
id: result.user.id,
|
);
|
||||||
name: result.user.name ?? "",
|
return Response.json({
|
||||||
email: result.user.email,
|
id: result.user.id,
|
||||||
image: result.user.image,
|
name: result.user.name ?? "",
|
||||||
accessToken: result.accessToken,
|
email: result.user.email,
|
||||||
refreshToken: result.refreshToken,
|
image: result.user.image,
|
||||||
sessionToken: result.sessionToken,
|
accessToken: result.accessToken,
|
||||||
isNewUser: result.isNewUser ?? false,
|
refreshToken: result.refreshToken,
|
||||||
});
|
sessionToken: result.sessionToken,
|
||||||
}
|
isNewUser: result.isNewUser ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case "refresh": {
|
case "refresh": {
|
||||||
const { refreshToken } = body;
|
const { refreshToken } = body;
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ message: "refreshToken is required" }),
|
JSON.stringify({ message: "refreshToken is required" }),
|
||||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const result = await refreshAccessToken(refreshToken);
|
const result = await refreshAccessToken(refreshToken);
|
||||||
return Response.json({
|
return Response.json({
|
||||||
accessToken: result.accessToken,
|
accessToken: result.accessToken,
|
||||||
refreshToken: result.refreshToken,
|
refreshToken: result.refreshToken,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
case "logout": {
|
case "logout": {
|
||||||
// Extract user from Bearer token
|
// Extract user from Bearer token
|
||||||
const authHeader = event.request.headers.get("authorization");
|
const authHeader = event.request.headers.get("authorization");
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
const token = authHeader.slice(7);
|
const token = authHeader.slice(7);
|
||||||
try {
|
try {
|
||||||
const payload = await verifyJWT<{ sub: string }>(token);
|
const payload = await verifyJWT<{ sub: string }>(token);
|
||||||
await revokeUserSessions(payload.sub);
|
await revokeUserSessions(payload.sub);
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid token — still return success
|
// Invalid token — still return success
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
case "forgot-password": {
|
case "forgot-password": {
|
||||||
const { email } = body;
|
const { email } = body;
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ message: "Email is required" }),
|
JSON.stringify({ message: "Email is required" }),
|
||||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await forgotPassword(email);
|
await forgotPassword(email);
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
case "reset-password": {
|
case "reset-password": {
|
||||||
const { code, password } = body;
|
const { code, password } = body;
|
||||||
if (!code || !password) {
|
if (!code || !password) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ message: "Code and password are required" }),
|
JSON.stringify({ message: "Code and password are required" }),
|
||||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// The mobile app sends "code" but the service expects "token"
|
// The mobile app sends "code" but the service expects "token"
|
||||||
// We accept both for backward compatibility
|
// We accept both for backward compatibility
|
||||||
const token = code;
|
const token = code;
|
||||||
await resetPassword(token, password);
|
await resetPassword(token, password);
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ message: `Unknown action: ${action}` }),
|
JSON.stringify({ message: `Unknown action: ${action}` }),
|
||||||
{ status: 404, headers: { "Content-Type": "application/json" } },
|
{ status: 404, headers: { "Content-Type": "application/json" } },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const statusCode = error.code === "UNAUTHORIZED" ? 401
|
const statusCode =
|
||||||
: error.code === "CONFLICT" ? 409
|
error.code === "UNAUTHORIZED"
|
||||||
: error.code === "NOT_FOUND" ? 404
|
? 401
|
||||||
: error.code === "FORBIDDEN" ? 403
|
: error.code === "CONFLICT"
|
||||||
: 500;
|
? 409
|
||||||
|
: error.code === "NOT_FOUND"
|
||||||
|
? 404
|
||||||
|
: error.code === "FORBIDDEN"
|
||||||
|
? 403
|
||||||
|
: 500;
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
message: error.message ?? "Internal server error",
|
message: error.message ?? "Internal server error",
|
||||||
code: error.code ?? "INTERNAL_ERROR",
|
code: error.code ?? "INTERNAL_ERROR",
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: statusCode,
|
status: statusCode,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,21 +16,21 @@ import { familyRouter } from "./routers/family";
|
|||||||
import { createTRPCRouter } from "./utils";
|
import { createTRPCRouter } from "./utils";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
example: exampleRouter,
|
example: exampleRouter,
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
billing: billingRouter,
|
billing: billingRouter,
|
||||||
darkwatch: darkwatchRouter,
|
darkwatch: darkwatchRouter,
|
||||||
voiceprint: voiceprintRouter,
|
voiceprint: voiceprintRouter,
|
||||||
spamshield: spamshieldRouter,
|
spamshield: spamshieldRouter,
|
||||||
hometitle: hometitleRouter,
|
hometitle: hometitleRouter,
|
||||||
removebrokers: removebrokersRouter,
|
removebrokers: removebrokersRouter,
|
||||||
correlation: correlationRouter,
|
correlation: correlationRouter,
|
||||||
reports: reportsRouter,
|
reports: reportsRouter,
|
||||||
scheduler: schedulerRouter,
|
scheduler: schedulerRouter,
|
||||||
extension: extensionRouter,
|
extension: extensionRouter,
|
||||||
blog: blogRouter,
|
blog: blogRouter,
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
family: familyRouter,
|
family: familyRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
@@ -1,174 +1,179 @@
|
|||||||
import { wrap } from "@typeschema/valibot";
|
import { wrap } from "@typeschema/valibot";
|
||||||
import { object, string, minLength, email as emailVal } from "valibot";
|
import { object, string, minLength, email as emailVal } from "valibot";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { createTRPCRouter, publicProcedure, protectedProcedure } from "../utils";
|
|
||||||
import {
|
import {
|
||||||
UpdateUserSchema,
|
createTRPCRouter,
|
||||||
InviteMemberSchema,
|
publicProcedure,
|
||||||
RemoveMemberSchema,
|
protectedProcedure,
|
||||||
UpdateRoleSchema,
|
} from "../utils";
|
||||||
|
import {
|
||||||
|
UpdateUserSchema,
|
||||||
|
InviteMemberSchema,
|
||||||
|
RemoveMemberSchema,
|
||||||
|
UpdateRoleSchema,
|
||||||
} from "../schemas/user";
|
} from "../schemas/user";
|
||||||
import {
|
import {
|
||||||
getUserById,
|
getUserById,
|
||||||
updateUser,
|
updateUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
createUserWithPassword,
|
createUserWithPassword,
|
||||||
authenticateUser,
|
authenticateUser,
|
||||||
authenticateWithApple,
|
authenticateWithApple,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
revokeUserSessions,
|
revokeUserSessions,
|
||||||
} from "~/server/services/user.service";
|
} from "~/server/services/user.service";
|
||||||
import {
|
import {
|
||||||
getFamilyGroup,
|
getFamilyGroup,
|
||||||
inviteMember,
|
inviteMember,
|
||||||
removeMember,
|
removeMember,
|
||||||
updateMemberRole,
|
updateMemberRole,
|
||||||
} from "~/server/services/family.service";
|
} from "~/server/services/family.service";
|
||||||
|
|
||||||
const LoginSchema = object({
|
const LoginSchema = object({
|
||||||
email: string([emailVal()]),
|
email: string([emailVal()]),
|
||||||
password: string([minLength(1)]),
|
password: string([minLength(1)]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const SignupSchema = object({
|
const SignupSchema = object({
|
||||||
name: string([minLength(1)]),
|
name: string([minLength(1)]),
|
||||||
email: string([emailVal()]),
|
email: string([emailVal()]),
|
||||||
password: string([minLength(8)]),
|
password: string([minLength(8)]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const AppleAuthSchema = object({
|
const AppleAuthSchema = object({
|
||||||
identityToken: string([minLength(1)]),
|
identityToken: string([minLength(1)]),
|
||||||
authorizationCode: string([minLength(1)]),
|
authorizationCode: string([minLength(1)]),
|
||||||
userIdentifier: string(),
|
userIdentifier: string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const RefreshTokenSchema = object({
|
const RefreshTokenSchema = object({
|
||||||
refreshToken: string([minLength(1)]),
|
refreshToken: string([minLength(1)]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ForgotPasswordSchema = object({
|
const ForgotPasswordSchema = object({
|
||||||
email: string([emailVal()]),
|
email: string([emailVal()]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const ResetPasswordSchema = object({
|
const ResetPasswordSchema = object({
|
||||||
token: string([minLength(1)]),
|
token: string([minLength(1)]),
|
||||||
password: string([minLength(8)]),
|
password: string([minLength(8)]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
login: publicProcedure
|
login: publicProcedure
|
||||||
.input(wrap(LoginSchema))
|
.input(wrap(LoginSchema))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return authenticateUser(input.email, input.password);
|
return authenticateUser(input.email, input.password);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
signup: publicProcedure
|
signup: publicProcedure
|
||||||
.input(wrap(SignupSchema))
|
.input(wrap(SignupSchema))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return createUserWithPassword(input.name, input.email, input.password);
|
return createUserWithPassword(input.name, input.email, input.password);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
appleAuth: publicProcedure
|
appleAuth: publicProcedure
|
||||||
.input(wrap(AppleAuthSchema))
|
.input(wrap(AppleAuthSchema))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return authenticateWithApple(
|
return authenticateWithApple(
|
||||||
input.identityToken,
|
input.identityToken,
|
||||||
input.authorizationCode,
|
input.authorizationCode,
|
||||||
input.userIdentifier || null,
|
input.userIdentifier || null,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
refreshToken: publicProcedure
|
refreshToken: publicProcedure
|
||||||
.input(wrap(RefreshTokenSchema))
|
.input(wrap(RefreshTokenSchema))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return refreshAccessToken(input.refreshToken);
|
return refreshAccessToken(input.refreshToken);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
forgotPassword: publicProcedure
|
forgotPassword: publicProcedure
|
||||||
.input(wrap(ForgotPasswordSchema))
|
.input(wrap(ForgotPasswordSchema))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return forgotPassword(input.email);
|
return forgotPassword(input.email);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
resetPassword: publicProcedure
|
resetPassword: publicProcedure
|
||||||
.input(wrap(ResetPasswordSchema))
|
.input(wrap(ResetPasswordSchema))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
return resetPassword(input.token, input.password);
|
return resetPassword(input.token, input.password);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
me: protectedProcedure.query(async ({ ctx }) => {
|
me: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const user = await getUserById(ctx.user.id);
|
const user = await getUserById(ctx.user.id);
|
||||||
return user;
|
return user;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
.input(wrap(UpdateUserSchema))
|
.input(wrap(UpdateUserSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const updated = await updateUser(ctx.user.id, input);
|
const updated = await updateUser(ctx.user.id, input);
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure.mutation(async ({ ctx }) => {
|
delete: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
await deleteUser(ctx.user.id);
|
await deleteUser(ctx.user.id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
logout: protectedProcedure.mutation(async ({ ctx }) => {
|
logout: protectedProcedure.mutation(async ({ ctx }) => {
|
||||||
await revokeUserSessions(ctx.user.id);
|
await revokeUserSessions(ctx.user.id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listFamilyMembers: protectedProcedure.query(async ({ ctx }) => {
|
listFamilyMembers: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const group = await getFamilyGroup(ctx.user.id);
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
return group.members;
|
return group.members;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
inviteFamilyMember: protectedProcedure
|
inviteFamilyMember: protectedProcedure
|
||||||
.input(wrap(InviteMemberSchema))
|
.input(wrap(InviteMemberSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const group = await getFamilyGroup(ctx.user.id);
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
|
|
||||||
const callerMember = group.members.find(
|
const callerMember = group.members.find((m) => m.userId === ctx.user.id);
|
||||||
(m) => m.userId === ctx.user.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) {
|
if (
|
||||||
throw new TRPCError({
|
!callerMember ||
|
||||||
code: "FORBIDDEN",
|
(callerMember.role !== "owner" && callerMember.role !== "admin")
|
||||||
message: "Only owner or admin can invite members",
|
) {
|
||||||
});
|
throw new TRPCError({
|
||||||
}
|
code: "FORBIDDEN",
|
||||||
|
message: "Only owner or admin can invite members",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const invitation = await inviteMember(
|
const invitation = await inviteMember(
|
||||||
group.id,
|
group.id,
|
||||||
input.email,
|
input.email,
|
||||||
ctx.user.id,
|
ctx.user.id,
|
||||||
input.role,
|
input.role,
|
||||||
);
|
);
|
||||||
|
|
||||||
return invitation;
|
return invitation;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
removeFamilyMember: protectedProcedure
|
removeFamilyMember: protectedProcedure
|
||||||
.input(wrap(RemoveMemberSchema))
|
.input(wrap(RemoveMemberSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const group = await getFamilyGroup(ctx.user.id);
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
await removeMember(group.id, input.userId, ctx.user.id);
|
await removeMember(group.id, input.userId, ctx.user.id);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
updateFamilyMemberRole: protectedProcedure
|
updateFamilyMemberRole: protectedProcedure
|
||||||
.input(wrap(UpdateRoleSchema))
|
.input(wrap(UpdateRoleSchema))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const group = await getFamilyGroup(ctx.user.id);
|
const group = await getFamilyGroup(ctx.user.id);
|
||||||
const updated = await updateMemberRole(
|
const updated = await updateMemberRole(
|
||||||
group.id,
|
group.id,
|
||||||
input.userId,
|
input.userId,
|
||||||
input.role,
|
input.role,
|
||||||
ctx.user.id,
|
ctx.user.id,
|
||||||
);
|
);
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +1,36 @@
|
|||||||
import type { JobPayload, JobType } from "../queue";
|
import type { JobPayload, JobType } from "../queue";
|
||||||
|
|
||||||
export type JobHandler<T extends JobType = JobType> = (payload: JobPayload[T]) => Promise<void>;
|
export type JobHandler<T extends JobType = JobType> = (
|
||||||
|
payload: JobPayload[T],
|
||||||
|
) => Promise<void>;
|
||||||
|
|
||||||
export type HandlerMap = {
|
export type HandlerMap = {
|
||||||
[K in JobType]: JobHandler<K>;
|
[K in JobType]: JobHandler<K>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let handlers: HandlerMap | null = null;
|
let handlers: HandlerMap | null = null;
|
||||||
|
|
||||||
export function getHandlers(): HandlerMap {
|
export function getHandlers(): HandlerMap {
|
||||||
if (!handlers) {
|
if (!handlers) {
|
||||||
handlers = {
|
handlers = {
|
||||||
"darkwatch.scan": require("./darkwatch.scan").handler,
|
"darkwatch.scan": require("./darkwatch.scan").handler,
|
||||||
"darkwatch.digest": require("./darkwatch.digest").handler,
|
"darkwatch.digest": require("./darkwatch.digest").handler,
|
||||||
"voiceprint.batch": require("./voiceprint.batch").handler,
|
"voiceprint.batch": require("./voiceprint.batch").handler,
|
||||||
"hometitle.scan": require("./hometitle.scan").handler,
|
"hometitle.scan": require("./hometitle.scan").handler,
|
||||||
"removebrokers.process": require("./removebrokers.process").handler,
|
"removebrokers.process": require("./removebrokers.process").handler,
|
||||||
"reports.generate": require("./reports.generate").handler,
|
"reports.generate": require("./reports.generate").handler,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return handlers;
|
return handlers;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setHandlers(mock: Partial<HandlerMap>): void {
|
export function setHandlers(mock: Partial<HandlerMap>): void {
|
||||||
handlers = {
|
handlers = {
|
||||||
"darkwatch.scan": mock["darkwatch.scan"] ?? (async () => {}),
|
"darkwatch.scan": mock["darkwatch.scan"] ?? (async () => {}),
|
||||||
"darkwatch.digest": mock["darkwatch.digest"] ?? (async () => {}),
|
"darkwatch.digest": mock["darkwatch.digest"] ?? (async () => {}),
|
||||||
"voiceprint.batch": mock["voiceprint.batch"] ?? (async () => {}),
|
"voiceprint.batch": mock["voiceprint.batch"] ?? (async () => {}),
|
||||||
"hometitle.scan": mock["hometitle.scan"] ?? (async () => {}),
|
"hometitle.scan": mock["hometitle.scan"] ?? (async () => {}),
|
||||||
"removebrokers.process": mock["removebrokers.process"] ?? (async () => {}),
|
"removebrokers.process": mock["removebrokers.process"] ?? (async () => {}),
|
||||||
"reports.generate": mock["reports.generate"] ?? (async () => {}),
|
"reports.generate": mock["reports.generate"] ?? (async () => {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,220 +1,243 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
export const JOB_TYPES = [
|
export const JOB_TYPES = [
|
||||||
"darkwatch.scan",
|
"darkwatch.scan",
|
||||||
"darkwatch.digest",
|
"darkwatch.digest",
|
||||||
"voiceprint.batch",
|
"voiceprint.batch",
|
||||||
"hometitle.scan",
|
"hometitle.scan",
|
||||||
"removebrokers.process",
|
"removebrokers.process",
|
||||||
"reports.generate",
|
"reports.generate",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type JobType = (typeof JOB_TYPES)[number];
|
export type JobType = (typeof JOB_TYPES)[number];
|
||||||
|
|
||||||
export type JobPayload = {
|
export type JobPayload = {
|
||||||
"darkwatch.scan": { userId: string; subscriptionId: string };
|
"darkwatch.scan": { userId: string; subscriptionId: string };
|
||||||
"darkwatch.digest": { userId: string };
|
"darkwatch.digest": { userId: string };
|
||||||
"voiceprint.batch": { userId?: string; jobId?: string };
|
"voiceprint.batch": { userId?: string; jobId?: string };
|
||||||
"hometitle.scan": { userId: string; subscriptionId: string };
|
"hometitle.scan": { userId: string; subscriptionId: string };
|
||||||
"removebrokers.process": { subscriptionId?: string; requestId?: string };
|
"removebrokers.process": { subscriptionId?: string; requestId?: string };
|
||||||
"reports.generate": { userId: string; reportScheduleId?: string; reportType: string };
|
"reports.generate": {
|
||||||
|
userId: string;
|
||||||
|
reportScheduleId?: string;
|
||||||
|
reportType: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type JobStatus = "pending" | "running" | "completed" | "failed";
|
export type JobStatus = "pending" | "running" | "completed" | "failed";
|
||||||
|
|
||||||
export interface Job<T extends JobType = JobType> {
|
export interface Job<T extends JobType = JobType> {
|
||||||
id: string;
|
id: string;
|
||||||
type: T;
|
type: T;
|
||||||
payload: JobPayload[T];
|
payload: JobPayload[T];
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
maxAttempts: number;
|
maxAttempts: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnqueueOptions {
|
export interface EnqueueOptions {
|
||||||
delay?: number;
|
delay?: number;
|
||||||
maxAttempts?: number;
|
maxAttempts?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueueAdapter {
|
export interface QueueAdapter {
|
||||||
enqueue<T extends JobType>(type: T, payload: JobPayload[T], options?: EnqueueOptions): Promise<Job<T>>;
|
enqueue<T extends JobType>(
|
||||||
dequeue(): Promise<Job | null>;
|
type: T,
|
||||||
markComplete(jobId: string): Promise<void>;
|
payload: JobPayload[T],
|
||||||
markFailed(jobId: string, error: string): Promise<void>;
|
options?: EnqueueOptions,
|
||||||
scheduleRetry(job: Job, delayMs: number): Promise<void>;
|
): Promise<Job<T>>;
|
||||||
getJob(jobId: string): Promise<Job | null>;
|
dequeue(): Promise<Job | null>;
|
||||||
getJobs(status?: JobStatus): Promise<Job[]>;
|
markComplete(jobId: string): Promise<void>;
|
||||||
|
markFailed(jobId: string, error: string): Promise<void>;
|
||||||
|
scheduleRetry(job: Job, delayMs: number): Promise<void>;
|
||||||
|
getJob(jobId: string): Promise<Job | null>;
|
||||||
|
getJobs(status?: JobStatus): Promise<Job[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InMemoryQueue implements QueueAdapter {
|
export class InMemoryQueue implements QueueAdapter {
|
||||||
private jobs = new Map<string, Job>();
|
private jobs = new Map<string, Job>();
|
||||||
private pendingQueue: string[] = [];
|
private pendingQueue: string[] = [];
|
||||||
|
|
||||||
async enqueue<T extends JobType>(type: T, payload: JobPayload[T], options?: EnqueueOptions): Promise<Job<T>> {
|
async enqueue<T extends JobType>(
|
||||||
const id = randomUUID();
|
type: T,
|
||||||
const job: Job<T> = {
|
payload: JobPayload[T],
|
||||||
id,
|
options?: EnqueueOptions,
|
||||||
type,
|
): Promise<Job<T>> {
|
||||||
payload,
|
const id = randomUUID();
|
||||||
status: "pending",
|
const job: Job<T> = {
|
||||||
attempts: 0,
|
id,
|
||||||
maxAttempts: options?.maxAttempts ?? 3,
|
type,
|
||||||
createdAt: new Date(),
|
payload,
|
||||||
updatedAt: new Date(),
|
status: "pending",
|
||||||
};
|
attempts: 0,
|
||||||
this.jobs.set(id, job as Job);
|
maxAttempts: options?.maxAttempts ?? 3,
|
||||||
if (options?.delay) {
|
createdAt: new Date(),
|
||||||
setTimeout(() => {
|
updatedAt: new Date(),
|
||||||
this.pendingQueue.push(id);
|
};
|
||||||
}, options.delay);
|
this.jobs.set(id, job as Job);
|
||||||
} else {
|
if (options?.delay) {
|
||||||
this.pendingQueue.push(id);
|
setTimeout(() => {
|
||||||
}
|
this.pendingQueue.push(id);
|
||||||
return job;
|
}, options.delay);
|
||||||
}
|
} else {
|
||||||
|
this.pendingQueue.push(id);
|
||||||
|
}
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
async scheduleRetry(job: Job, delayMs: number): Promise<void> {
|
async scheduleRetry(job: Job, delayMs: number): Promise<void> {
|
||||||
job.status = "pending";
|
job.status = "pending";
|
||||||
job.attempts++;
|
job.attempts++;
|
||||||
job.updatedAt = new Date();
|
job.updatedAt = new Date();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.pendingQueue.push(job.id);
|
this.pendingQueue.push(job.id);
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async dequeue(): Promise<Job | null> {
|
async dequeue(): Promise<Job | null> {
|
||||||
while (this.pendingQueue.length > 0) {
|
while (this.pendingQueue.length > 0) {
|
||||||
const id = this.pendingQueue.shift()!;
|
const id = this.pendingQueue.shift()!;
|
||||||
const job = this.jobs.get(id);
|
const job = this.jobs.get(id);
|
||||||
if (!job || job.status !== "pending") continue;
|
if (!job || job.status !== "pending") continue;
|
||||||
job.status = "running";
|
job.status = "running";
|
||||||
job.updatedAt = new Date();
|
job.updatedAt = new Date();
|
||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async markComplete(jobId: string): Promise<void> {
|
async markComplete(jobId: string): Promise<void> {
|
||||||
const job = this.jobs.get(jobId);
|
const job = this.jobs.get(jobId);
|
||||||
if (job) {
|
if (job) {
|
||||||
job.status = "completed";
|
job.status = "completed";
|
||||||
job.updatedAt = new Date();
|
job.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async markFailed(jobId: string, error: string): Promise<void> {
|
async markFailed(jobId: string, error: string): Promise<void> {
|
||||||
const job = this.jobs.get(jobId);
|
const job = this.jobs.get(jobId);
|
||||||
if (job) {
|
if (job) {
|
||||||
job.status = "failed";
|
job.status = "failed";
|
||||||
job.error = error;
|
job.error = error;
|
||||||
job.updatedAt = new Date();
|
job.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJob(jobId: string): Promise<Job | null> {
|
async getJob(jobId: string): Promise<Job | null> {
|
||||||
return this.jobs.get(jobId) ?? null;
|
return this.jobs.get(jobId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJobs(status?: JobStatus): Promise<Job[]> {
|
async getJobs(status?: JobStatus): Promise<Job[]> {
|
||||||
const all = Array.from(this.jobs.values());
|
const all = Array.from(this.jobs.values());
|
||||||
if (status) return all.filter((j) => j.status === status);
|
if (status) return all.filter((j) => j.status === status);
|
||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRedisAdapter(): QueueAdapter {
|
function createRedisAdapter(): QueueAdapter {
|
||||||
// Lazy imports so this module works without Redis
|
// Lazy imports so this module works without Redis
|
||||||
const BullMQ = require("bullmq");
|
const BullMQ = require("bullmq");
|
||||||
const IORedis = require("ioredis");
|
const IORedis = require("ioredis");
|
||||||
|
|
||||||
const connection = new IORedis.default(process.env.REDIS_URL ?? "redis://localhost:6379", {
|
const connection = new IORedis.default(
|
||||||
maxRetriesPerRequest: null,
|
process.env.REDIS_URL ?? "redis://localhost:6379",
|
||||||
});
|
{
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const queue = new BullMQ.Queue("kordant-jobs", { connection });
|
const queue = new BullMQ.Queue("kordant-jobs", { connection });
|
||||||
const bullJobs = new Map<string, any>();
|
const bullJobs = new Map<string, any>();
|
||||||
|
|
||||||
async function toJob(bullJob: any): Promise<Job> {
|
async function toJob(bullJob: any): Promise<Job> {
|
||||||
return {
|
return {
|
||||||
id: bullJob.id,
|
id: bullJob.id,
|
||||||
type: bullJob.name as JobType,
|
type: bullJob.name as JobType,
|
||||||
payload: bullJob.data,
|
payload: bullJob.data,
|
||||||
status: (await bullJob.getState()) as JobStatus,
|
status: (await bullJob.getState()) as JobStatus,
|
||||||
attempts: bullJob.attemptsMade,
|
attempts: bullJob.attemptsMade,
|
||||||
maxAttempts: bullJob.opts?.attempts ?? 3,
|
maxAttempts: bullJob.opts?.attempts ?? 3,
|
||||||
error: bullJob.failedReason ?? undefined,
|
error: bullJob.failedReason ?? undefined,
|
||||||
createdAt: bullJob.timestamp ? new Date(bullJob.timestamp) : new Date(),
|
createdAt: bullJob.timestamp ? new Date(bullJob.timestamp) : new Date(),
|
||||||
updatedAt: bullJob.processedOn ? new Date(bullJob.processedOn) : new Date(),
|
updatedAt: bullJob.processedOn
|
||||||
};
|
? new Date(bullJob.processedOn)
|
||||||
}
|
: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async enqueue<T extends JobType>(type: T, payload: JobPayload[T], options?: EnqueueOptions) {
|
async enqueue<T extends JobType>(
|
||||||
const bullJob = await queue.add(type, payload, {
|
type: T,
|
||||||
attempts: options?.maxAttempts ?? 3,
|
payload: JobPayload[T],
|
||||||
delay: options?.delay,
|
options?: EnqueueOptions,
|
||||||
backoff: { type: "exponential", delay: 60_000 },
|
) {
|
||||||
});
|
const bullJob = await queue.add(type, payload, {
|
||||||
return toJob(bullJob) as Promise<Job<T>>;
|
attempts: options?.maxAttempts ?? 3,
|
||||||
},
|
delay: options?.delay,
|
||||||
|
backoff: { type: "exponential", delay: 60_000 },
|
||||||
|
});
|
||||||
|
return toJob(bullJob) as Promise<Job<T>>;
|
||||||
|
},
|
||||||
|
|
||||||
async dequeue() {
|
async dequeue() {
|
||||||
// BullMQ Worker handles dequeue automatically; this is for the polling worker
|
// BullMQ Worker handles dequeue automatically; this is for the polling worker
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async markComplete(jobId) {
|
async markComplete(jobId) {
|
||||||
// Handled by BullMQ Worker
|
// Handled by BullMQ Worker
|
||||||
},
|
},
|
||||||
|
|
||||||
async markFailed(jobId, error) {
|
async markFailed(jobId, error) {
|
||||||
// Handled by BullMQ Worker
|
// Handled by BullMQ Worker
|
||||||
},
|
},
|
||||||
|
|
||||||
async scheduleRetry(job, delayMs) {
|
async scheduleRetry(job, delayMs) {
|
||||||
// BullMQ handles retries via backoff
|
// BullMQ handles retries via backoff
|
||||||
},
|
},
|
||||||
|
|
||||||
async getJob(jobId) {
|
async getJob(jobId) {
|
||||||
const bullJob = await queue.getJob(jobId);
|
const bullJob = await queue.getJob(jobId);
|
||||||
if (!bullJob) return null;
|
if (!bullJob) return null;
|
||||||
return toJob(bullJob);
|
return toJob(bullJob);
|
||||||
},
|
},
|
||||||
|
|
||||||
async getJobs(status) {
|
async getJobs(status) {
|
||||||
const states = status ? [status] : ["waiting", "active", "completed", "failed"];
|
const states = status
|
||||||
const allJobs: Job[] = [];
|
? [status]
|
||||||
for (const state of states) {
|
: ["waiting", "active", "completed", "failed"];
|
||||||
const jobs = await queue.getJobs(state);
|
const allJobs: Job[] = [];
|
||||||
for (const j of jobs) {
|
for (const state of states) {
|
||||||
allJobs.push(await toJob(j));
|
const jobs = await queue.getJobs(state);
|
||||||
}
|
for (const j of jobs) {
|
||||||
}
|
allJobs.push(await toJob(j));
|
||||||
return allJobs;
|
}
|
||||||
},
|
}
|
||||||
};
|
return allJobs;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let adapter: QueueAdapter;
|
let adapter: QueueAdapter;
|
||||||
|
|
||||||
export function getQueue(): QueueAdapter {
|
export function getQueue(): QueueAdapter {
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
if (process.env.REDIS_URL) {
|
if (process.env.REDIS_URL) {
|
||||||
adapter = createRedisAdapter();
|
adapter = createRedisAdapter();
|
||||||
} else {
|
} else {
|
||||||
adapter = new InMemoryQueue();
|
adapter = new InMemoryQueue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return adapter;
|
return adapter;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setQueue(mock: QueueAdapter): void {
|
export function setQueue(mock: QueueAdapter): void {
|
||||||
adapter = mock;
|
adapter = mock;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetQueue(): void {
|
export function resetQueue(): void {
|
||||||
adapter = undefined as unknown as QueueAdapter;
|
adapter = undefined as unknown as QueueAdapter;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,41 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { resend } from "~/server/lib/resend";
|
import { resend } from "~/server/lib/resend";
|
||||||
|
|
||||||
export async function sendEmail(
|
export async function sendEmail(
|
||||||
to: string,
|
to: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
html: string,
|
html: string,
|
||||||
text?: string,
|
text?: string,
|
||||||
) {
|
) {
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
console.warn("[email] Resend not configured, skipping email");
|
console.warn("[email] Resend not configured, skipping email");
|
||||||
return { id: null };
|
return { id: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await resend.emails.send({
|
const { data, error } = await resend.emails.send({
|
||||||
from: process.env.RESEND_FROM_EMAIL ?? "noreply@kordant.ai",
|
from: process.env.RESEND_FROM_EMAIL ?? "noreply@kordant.ai",
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
text: text ?? "",
|
text: text ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("[email] Resend error:", error);
|
console.error("[email] Resend error:", error);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "Failed to send email",
|
message: "Failed to send email",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("[email] Email sent:", data?.id);
|
console.log("[email] Email sent:", data?.id);
|
||||||
return { id: data?.id ?? null };
|
return { id: data?.id ?? null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof TRPCError) throw err;
|
if (err instanceof TRPCError) throw err;
|
||||||
console.error("[email] Email send error:", err);
|
console.error("[email] Email send error:", err);
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "INTERNAL_SERVER_ERROR",
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
message: "Failed to send email",
|
message: "Failed to send email",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,72 @@
|
|||||||
import { object, string, optional, parse, safeParse } from "valibot";
|
import { object, string, optional, parse, safeParse } from "valibot";
|
||||||
|
|
||||||
const envSchema = object({
|
const envSchema = object({
|
||||||
// Database
|
// Database
|
||||||
DATABASE_URL: string(),
|
DATABASE_URL: string(),
|
||||||
DATABASE_AUTH_TOKEN: optional(string()),
|
DATABASE_AUTH_TOKEN: optional(string()),
|
||||||
|
|
||||||
// Server
|
// Server
|
||||||
PORT: optional(string()),
|
PORT: optional(string()),
|
||||||
NODE_ENV: optional(string()),
|
NODE_ENV: optional(string()),
|
||||||
LOG_LEVEL: optional(string()),
|
LOG_LEVEL: optional(string()),
|
||||||
APP_URL: optional(string()),
|
APP_URL: optional(string()),
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
JWT_SECRET: string(),
|
JWT_SECRET: string(),
|
||||||
SESSION_SECRET: optional(string()),
|
SESSION_SECRET: optional(string()),
|
||||||
|
|
||||||
// Clerk
|
// Clerk
|
||||||
CLERK_SECRET_KEY: string(),
|
CLERK_SECRET_KEY: string(),
|
||||||
VITE_CLERK_PUBLISHABLE_KEY: string(),
|
VITE_CLERK_PUBLISHABLE_KEY: string(),
|
||||||
|
|
||||||
// Stripe
|
// Stripe
|
||||||
STRIPE_SECRET_KEY: string(),
|
STRIPE_SECRET_KEY: string(),
|
||||||
STRIPE_WEBHOOK_SECRET: string(),
|
STRIPE_WEBHOOK_SECRET: string(),
|
||||||
|
|
||||||
// Redis (for BullMQ)
|
// Redis (for BullMQ)
|
||||||
REDIS_URL: optional(string()),
|
REDIS_URL: optional(string()),
|
||||||
|
|
||||||
// Sentry
|
// Sentry
|
||||||
VITE_SENTRY_DSN: optional(string()),
|
VITE_SENTRY_DSN: optional(string()),
|
||||||
|
|
||||||
// Email
|
// Email
|
||||||
RESEND_API_KEY: optional(string()),
|
RESEND_API_KEY: optional(string()),
|
||||||
|
|
||||||
|
// 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
|
// WebSocket
|
||||||
TWILIO_ACCOUNT_SID: optional(string()),
|
WS_PORT: 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()),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export function validateEnv() {
|
export function validateEnv() {
|
||||||
const result = safeParse(envSchema, {
|
const result = safeParse(envSchema, {
|
||||||
...process.env,
|
...process.env,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const missingKeys = result.issues
|
const missingKeys = result.issues
|
||||||
.map((issue) => issue.path?.[0]?.key as string | undefined)
|
.map((issue) => issue.path?.[0]?.key as string | undefined)
|
||||||
.filter((k): k is string => k !== undefined);
|
.filter((k): k is string => k !== undefined);
|
||||||
|
|
||||||
console.error("Environment validation failed:");
|
console.error("Environment validation failed:");
|
||||||
console.error("Missing required variables:", missingKeys.join(", "));
|
console.error("Missing required variables:", missingKeys.join(", "));
|
||||||
console.error("\nPlease check .env.example for all required variables.");
|
console.error("\nPlease check .env.example for all required variables.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parse(envSchema, { ...process.env });
|
return parse(envSchema, { ...process.env });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const env = validateEnv();
|
export const env = validateEnv();
|
||||||
|
|||||||
@@ -4,129 +4,131 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
|||||||
const mockBroadcastToUser = vi.fn();
|
const mockBroadcastToUser = vi.fn();
|
||||||
|
|
||||||
vi.mock("~/server/websocket", () => ({
|
vi.mock("~/server/websocket", () => ({
|
||||||
broadcastToUser: mockBroadcastToUser,
|
broadcastToUser: mockBroadcastToUser,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockSendEmail = vi.fn();
|
const mockSendEmail = vi.fn();
|
||||||
|
|
||||||
vi.mock("~/server/lib/email", () => ({
|
vi.mock("~/server/lib/email", () => ({
|
||||||
sendEmail: mockSendEmail,
|
sendEmail: mockSendEmail,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("~/server/db", () => ({
|
vi.mock("~/server/db", () => ({
|
||||||
db: {
|
db: {
|
||||||
select: vi.fn(),
|
select: vi.fn(),
|
||||||
insert: vi.fn(),
|
insert: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("alert.publisher", () => {
|
describe("alert.publisher", () => {
|
||||||
it("should send alert via WebSocket when user is connected", async () => {
|
it("should send alert via WebSocket when user is connected", async () => {
|
||||||
mockBroadcastToUser.mockReturnValue(true);
|
mockBroadcastToUser.mockReturnValue(true);
|
||||||
|
|
||||||
const { publishAlert } = await import("./alert.publisher");
|
const { publishAlert } = await import("./alert.publisher");
|
||||||
await publishAlert("user-1", {
|
await publishAlert("user-1", {
|
||||||
id: "alert-1",
|
id: "alert-1",
|
||||||
title: "Test Alert",
|
title: "Test Alert",
|
||||||
message: "Test message",
|
message: "Test message",
|
||||||
severity: "HIGH",
|
severity: "HIGH",
|
||||||
source: "DARKWATCH",
|
source: "DARKWATCH",
|
||||||
category: "EXPOSURE_DETECTED",
|
category: "EXPOSURE_DETECTED",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockBroadcastToUser).toHaveBeenCalledWith("user-1", {
|
expect(mockBroadcastToUser).toHaveBeenCalledWith("user-1", {
|
||||||
type: "alert",
|
type: "alert",
|
||||||
alert: {
|
alert: {
|
||||||
id: "alert-1",
|
id: "alert-1",
|
||||||
title: "Test Alert",
|
title: "Test Alert",
|
||||||
message: "Test message",
|
message: "Test message",
|
||||||
severity: "HIGH",
|
severity: "HIGH",
|
||||||
source: "DARKWATCH",
|
source: "DARKWATCH",
|
||||||
category: "EXPOSURE_DETECTED",
|
category: "EXPOSURE_DETECTED",
|
||||||
createdAt: expect.any(String),
|
createdAt: expect.any(String),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to email when user is not connected and has email", async () => {
|
it("should fall back to email when user is not connected and has email", async () => {
|
||||||
mockBroadcastToUser.mockReturnValue(false);
|
mockBroadcastToUser.mockReturnValue(false);
|
||||||
|
|
||||||
const db = await import("~/server/db");
|
const db = await import("~/server/db");
|
||||||
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
limit: vi.fn().mockResolvedValue([{ id: "user-1", email: "user@example.com" }]),
|
limit: vi
|
||||||
}),
|
.fn()
|
||||||
}),
|
.mockResolvedValue([{ id: "user-1", email: "user@example.com" }]),
|
||||||
});
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
const { publishAlert } = await import("./alert.publisher");
|
const { publishAlert } = await import("./alert.publisher");
|
||||||
await publishAlert("user-1", {
|
await publishAlert("user-1", {
|
||||||
id: "alert-2",
|
id: "alert-2",
|
||||||
title: "Offline Alert",
|
title: "Offline Alert",
|
||||||
message: "Offline message",
|
message: "Offline message",
|
||||||
severity: "WARNING",
|
severity: "WARNING",
|
||||||
source: "VOICEPRINT",
|
source: "VOICEPRINT",
|
||||||
category: "SYNTHETIC_VOICE",
|
category: "SYNTHETIC_VOICE",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockBroadcastToUser).toHaveBeenCalled();
|
expect(mockBroadcastToUser).toHaveBeenCalled();
|
||||||
expect(mockSendEmail).toHaveBeenCalledWith(
|
expect(mockSendEmail).toHaveBeenCalledWith(
|
||||||
"user@example.com",
|
"user@example.com",
|
||||||
"[Kordant] Offline Alert",
|
"[Kordant] Offline Alert",
|
||||||
"<p>Offline message</p>",
|
"<p>Offline message</p>",
|
||||||
"Offline message",
|
"Offline message",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not send email when user has no email", async () => {
|
it("should not send email when user has no email", async () => {
|
||||||
mockBroadcastToUser.mockReturnValue(false);
|
mockBroadcastToUser.mockReturnValue(false);
|
||||||
|
|
||||||
const db = await import("~/server/db");
|
const db = await import("~/server/db");
|
||||||
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
(db.db.select as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
from: vi.fn().mockReturnValue({
|
from: vi.fn().mockReturnValue({
|
||||||
where: vi.fn().mockReturnValue({
|
where: vi.fn().mockReturnValue({
|
||||||
limit: vi.fn().mockResolvedValue([]),
|
limit: vi.fn().mockResolvedValue([]),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { publishAlert } = await import("./alert.publisher");
|
const { publishAlert } = await import("./alert.publisher");
|
||||||
await publishAlert("user-1", {
|
await publishAlert("user-1", {
|
||||||
id: "alert-3",
|
id: "alert-3",
|
||||||
title: "No Email",
|
title: "No Email",
|
||||||
message: "No email",
|
message: "No email",
|
||||||
severity: "INFO",
|
severity: "INFO",
|
||||||
source: "HOME_TITLE",
|
source: "HOME_TITLE",
|
||||||
category: "HOME_TITLE",
|
category: "HOME_TITLE",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should publish alert to multiple users", async () => {
|
it("should publish alert to multiple users", async () => {
|
||||||
mockBroadcastToUser.mockReturnValue(true);
|
mockBroadcastToUser.mockReturnValue(true);
|
||||||
|
|
||||||
const { publishToGroup } = await import("./alert.publisher");
|
const { publishToGroup } = await import("./alert.publisher");
|
||||||
await publishToGroup(["user-1", "user-2"], {
|
await publishToGroup(["user-1", "user-2"], {
|
||||||
id: "alert-4",
|
id: "alert-4",
|
||||||
title: "Group Alert",
|
title: "Group Alert",
|
||||||
message: "Group message",
|
message: "Group message",
|
||||||
severity: "INFO",
|
severity: "INFO",
|
||||||
source: "HOME_TITLE",
|
source: "HOME_TITLE",
|
||||||
category: "HOME_TITLE",
|
category: "HOME_TITLE",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockBroadcastToUser).toHaveBeenCalledTimes(2);
|
expect(mockBroadcastToUser).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,54 +5,60 @@ import { users } from "~/server/db/schema/auth";
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export interface PublishableAlert {
|
export interface PublishableAlert {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
source: string;
|
source: string;
|
||||||
category: string;
|
category: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishAlert(userId: string, alert: PublishableAlert): Promise<void> {
|
export async function publishAlert(
|
||||||
const message = {
|
userId: string,
|
||||||
type: "alert" as const,
|
alert: PublishableAlert,
|
||||||
alert: {
|
): Promise<void> {
|
||||||
id: alert.id,
|
const message = {
|
||||||
title: alert.title,
|
type: "alert" as const,
|
||||||
message: alert.message,
|
alert: {
|
||||||
severity: alert.severity,
|
id: alert.id,
|
||||||
source: alert.source,
|
title: alert.title,
|
||||||
category: alert.category,
|
message: alert.message,
|
||||||
createdAt: alert.createdAt.toISOString(),
|
severity: alert.severity,
|
||||||
},
|
source: alert.source,
|
||||||
};
|
category: alert.category,
|
||||||
|
createdAt: alert.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const sent = broadcastToUser(userId, message);
|
const sent = broadcastToUser(userId, message);
|
||||||
|
|
||||||
if (!sent) {
|
if (!sent) {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, userId))
|
.where(eq(users.id, userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (user?.email) {
|
if (user?.email) {
|
||||||
try {
|
try {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
user.email,
|
user.email,
|
||||||
`[Kordant] ${alert.title}`,
|
`[Kordant] ${alert.title}`,
|
||||||
`<p>${alert.message}</p>`,
|
`<p>${alert.message}</p>`,
|
||||||
alert.message,
|
alert.message,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[alert.publisher] Email notification failed:", err);
|
console.error("[alert.publisher] Email notification failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishToGroup(userIds: string[], alert: PublishableAlert): Promise<void> {
|
export async function publishToGroup(
|
||||||
const promises = userIds.map((userId) => publishAlert(userId, alert));
|
userIds: string[],
|
||||||
await Promise.allSettled(promises);
|
alert: PublishableAlert,
|
||||||
|
): Promise<void> {
|
||||||
|
const promises = userIds.map((userId) => publishAlert(userId, alert));
|
||||||
|
await Promise.allSettled(promises);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,21 @@ import { sendEmail } from "~/server/lib/email";
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface DigestConfig {
|
export interface DigestConfig {
|
||||||
/** Severity levels that get batched into digest (vs immediate) */
|
/** Severity levels that get batched into digest (vs immediate) */
|
||||||
batchedSeverities: string[];
|
batchedSeverities: string[];
|
||||||
/** Digest frequency: "daily" or "weekly" */
|
/** Digest frequency: "daily" or "weekly" */
|
||||||
frequency: "daily" | "weekly";
|
frequency: "daily" | "weekly";
|
||||||
/** Time of day for daily digest (UTC hour) */
|
/** Time of day for daily digest (UTC hour) */
|
||||||
dailyHour: number;
|
dailyHour: number;
|
||||||
/** Day of week for weekly digest (0=Sun) */
|
/** Day of week for weekly digest (0=Sun) */
|
||||||
weeklyDay: number;
|
weeklyDay: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_DIGEST_CONFIG: DigestConfig = {
|
export const DEFAULT_DIGEST_CONFIG: DigestConfig = {
|
||||||
batchedSeverities: ["info"],
|
batchedSeverities: ["info"],
|
||||||
frequency: "daily",
|
frequency: "daily",
|
||||||
dailyHour: 9, // 9 AM UTC
|
dailyHour: 9, // 9 AM UTC
|
||||||
weeklyDay: 0, // Sunday
|
weeklyDay: 0, // Sunday
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,75 +30,77 @@ export const DEFAULT_DIGEST_CONFIG: DigestConfig = {
|
|||||||
* and user preferences.
|
* and user preferences.
|
||||||
*/
|
*/
|
||||||
export async function shouldDigest(
|
export async function shouldDigest(
|
||||||
userId: string,
|
userId: string,
|
||||||
severity: string,
|
severity: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const [prefs] = await db
|
const [prefs] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(notificationPreferences)
|
.from(notificationPreferences)
|
||||||
.where(eq(notificationPreferences.userId, userId))
|
.where(eq(notificationPreferences.userId, userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
// If user has no prefs, use defaults: info = digest, warning/critical = immediate
|
// If user has no prefs, use defaults: info = digest, warning/critical = immediate
|
||||||
if (!prefs) {
|
if (!prefs) {
|
||||||
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
|
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If email is disabled entirely, don't digest (alert won't be delivered)
|
// If email is disabled entirely, don't digest (alert won't be delivered)
|
||||||
if (!prefs.emailEnabled) {
|
if (!prefs.emailEnabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
|
return DEFAULT_DIGEST_CONFIG.batchedSeverities.includes(severity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the next scheduled digest date based on config.
|
* Calculates the next scheduled digest date based on config.
|
||||||
*/
|
*/
|
||||||
export function calculateNextDigestDate(config: DigestConfig = DEFAULT_DIGEST_CONFIG): Date {
|
export function calculateNextDigestDate(
|
||||||
const now = new Date();
|
config: DigestConfig = DEFAULT_DIGEST_CONFIG,
|
||||||
const next = new Date(now);
|
): Date {
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
|
||||||
if (config.frequency === "daily") {
|
if (config.frequency === "daily") {
|
||||||
next.setUTCHours(config.dailyHour, 0, 0, 0);
|
next.setUTCHours(config.dailyHour, 0, 0, 0);
|
||||||
if (next.getTime() <= now.getTime()) {
|
if (next.getTime() <= now.getTime()) {
|
||||||
next.setUTCDate(next.getUTCDate() + 1);
|
next.setUTCDate(next.getUTCDate() + 1);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
next.setUTCHours(config.dailyHour, 0, 0, 0);
|
next.setUTCHours(config.dailyHour, 0, 0, 0);
|
||||||
const currentDay = next.getUTCDay();
|
const currentDay = next.getUTCDay();
|
||||||
const daysUntilTarget = (config.weeklyDay - currentDay + 7) % 7;
|
const daysUntilTarget = (config.weeklyDay - currentDay + 7) % 7;
|
||||||
if (daysUntilTarget === 0 && next.getTime() <= now.getTime()) {
|
if (daysUntilTarget === 0 && next.getTime() <= now.getTime()) {
|
||||||
next.setUTCDate(next.getUTCDate() + 7);
|
next.setUTCDate(next.getUTCDate() + 7);
|
||||||
} else if (daysUntilTarget > 0 || next.getTime() <= now.getTime()) {
|
} else if (daysUntilTarget > 0 || next.getTime() <= now.getTime()) {
|
||||||
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
|
next.setUTCDate(next.getUTCDate() + daysUntilTarget);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queues an alert for the next digest email.
|
* Queues an alert for the next digest email.
|
||||||
*/
|
*/
|
||||||
export async function queueForDigest(
|
export async function queueForDigest(
|
||||||
userId: string,
|
userId: string,
|
||||||
alertId: string,
|
alertId: string,
|
||||||
title: string,
|
title: string,
|
||||||
severity: string,
|
severity: string,
|
||||||
source: string,
|
source: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const nextDigestDate = calculateNextDigestDate();
|
const nextDigestDate = calculateNextDigestDate();
|
||||||
|
|
||||||
await db.insert(digestAlerts).values({
|
await db.insert(digestAlerts).values({
|
||||||
userId,
|
userId,
|
||||||
alertId,
|
alertId,
|
||||||
title,
|
title,
|
||||||
severity,
|
severity,
|
||||||
source,
|
source,
|
||||||
scheduledDigestDate: nextDigestDate,
|
scheduledDigestDate: nextDigestDate,
|
||||||
sent: false,
|
sent: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,71 +108,75 @@ export async function queueForDigest(
|
|||||||
* Returns the number of alerts included in the digest.
|
* Returns the number of alerts included in the digest.
|
||||||
*/
|
*/
|
||||||
export async function sendDigestEmail(
|
export async function sendDigestEmail(
|
||||||
userId: string,
|
userId: string,
|
||||||
scheduledDate: Date,
|
scheduledDate: Date,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const pendingAlerts = await db
|
const pendingAlerts = await db
|
||||||
.select()
|
.select()
|
||||||
.from(digestAlerts)
|
.from(digestAlerts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(digestAlerts.userId, userId),
|
eq(digestAlerts.userId, userId),
|
||||||
eq(digestAlerts.sent, false),
|
eq(digestAlerts.sent, false),
|
||||||
eq(digestAlerts.scheduledDigestDate, scheduledDate),
|
eq(digestAlerts.scheduledDigestDate, scheduledDate),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(asc(digestAlerts.severity));
|
.orderBy(asc(digestAlerts.severity));
|
||||||
|
|
||||||
if (!pendingAlerts.length) {
|
if (!pendingAlerts.length) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user email
|
// Get user email
|
||||||
const { users } = await import("~/server/db/schema/auth");
|
const { users } = await import("~/server/db/schema/auth");
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select({ email: users.email })
|
.select({ email: users.email })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, userId))
|
.where(eq(users.id, userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!user?.email) {
|
if (!user?.email) {
|
||||||
console.warn(`[digest] No email found for user ${userId}`);
|
console.warn(`[digest] No email found for user ${userId}`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build digest email content
|
// Build digest email content
|
||||||
const alertsBySeverity = groupBySeverity(pendingAlerts);
|
const alertsBySeverity = groupBySeverity(pendingAlerts);
|
||||||
const html = buildDigestEmailHTML(alertsBySeverity, pendingAlerts.length);
|
const html = buildDigestEmailHTML(alertsBySeverity, pendingAlerts.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail(
|
await sendEmail(
|
||||||
user.email,
|
user.email,
|
||||||
`[Kordant] Security Digest — ${pendingAlerts.length} alert${pendingAlerts.length > 1 ? "s" : ""}`,
|
`[Kordant] Security Digest — ${pendingAlerts.length} alert${pendingAlerts.length > 1 ? "s" : ""}`,
|
||||||
html,
|
html,
|
||||||
buildDigestPlainText(alertsBySeverity, pendingAlerts.length),
|
buildDigestPlainText(alertsBySeverity, pendingAlerts.length),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark alerts as sent
|
// Mark alerts as sent
|
||||||
const alertIds = pendingAlerts.map((a) => a.id);
|
const alertIds = pendingAlerts.map((a) => a.id);
|
||||||
await db
|
await db
|
||||||
.update(digestAlerts)
|
.update(digestAlerts)
|
||||||
.set({ sent: true, sentAt: new Date() })
|
.set({ sent: true, sentAt: new Date() })
|
||||||
.where(and(eq(digestAlerts.userId, userId), eq(digestAlerts.id, alertIds[0])));
|
.where(
|
||||||
|
and(eq(digestAlerts.userId, userId), eq(digestAlerts.id, alertIds[0])),
|
||||||
|
);
|
||||||
|
|
||||||
// Update all matching alerts
|
// Update all matching alerts
|
||||||
for (const alertId of alertIds) {
|
for (const alertId of alertIds) {
|
||||||
await db
|
await db
|
||||||
.update(digestAlerts)
|
.update(digestAlerts)
|
||||||
.set({ sent: true, sentAt: new Date() })
|
.set({ sent: true, sentAt: new Date() })
|
||||||
.where(eq(digestAlerts.id, alertId));
|
.where(eq(digestAlerts.id, alertId));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[digest] Sent digest to ${user.email} with ${pendingAlerts.length} alerts`);
|
console.log(
|
||||||
return pendingAlerts.length;
|
`[digest] Sent digest to ${user.email} with ${pendingAlerts.length} alerts`,
|
||||||
} catch (err) {
|
);
|
||||||
console.error(`[digest] Failed to send digest for user ${userId}:`, err);
|
return pendingAlerts.length;
|
||||||
return 0;
|
} catch (err) {
|
||||||
}
|
console.error(`[digest] Failed to send digest for user ${userId}:`, err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,42 +184,38 @@ export async function sendDigestEmail(
|
|||||||
* Called by the digest job scheduler.
|
* Called by the digest job scheduler.
|
||||||
*/
|
*/
|
||||||
export async function processDueDigests(): Promise<void> {
|
export async function processDueDigests(): Promise<void> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const today = new Date(now.toISOString().split("T")[0]);
|
const today = new Date(now.toISOString().split("T")[0]);
|
||||||
const tomorrow = new Date(today);
|
const tomorrow = new Date(today);
|
||||||
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);
|
||||||
|
|
||||||
// Find all users with pending digests due today
|
// Find all users with pending digests due today
|
||||||
const { users } = await import("~/server/db/schema/auth");
|
const { users } = await import("~/server/db/schema/auth");
|
||||||
|
|
||||||
// Get distinct userIds with pending digests
|
// Get distinct userIds with pending digests
|
||||||
const pendingDigests = await db
|
const pendingDigests = await db
|
||||||
.select({
|
.select({
|
||||||
userId: digestAlerts.userId,
|
userId: digestAlerts.userId,
|
||||||
scheduledDate: digestAlerts.scheduledDigestDate,
|
scheduledDate: digestAlerts.scheduledDigestDate,
|
||||||
})
|
})
|
||||||
.from(digestAlerts)
|
.from(digestAlerts)
|
||||||
.where(
|
.where(and(eq(digestAlerts.sent, false)));
|
||||||
and(
|
|
||||||
eq(digestAlerts.sent, false),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Group by user
|
// Group by user
|
||||||
const userMap = new Map<string, Date[]>();
|
const userMap = new Map<string, Date[]>();
|
||||||
for (const d of pendingDigests) {
|
for (const d of pendingDigests) {
|
||||||
const dates = userMap.get(d.userId) ?? [];
|
const dates = userMap.get(d.userId) ?? [];
|
||||||
dates.push(d.scheduledDate);
|
dates.push(d.scheduledDate);
|
||||||
userMap.set(d.userId, dates);
|
userMap.set(d.userId, dates);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [userId, dates] of userMap) {
|
for (const [userId, dates] of userMap) {
|
||||||
for (const date of [...new Set(dates)]) {
|
for (const date of [...new Set(dates)]) {
|
||||||
if (date.getTime() <= now.getTime()) {
|
if (date.getTime() <= now.getTime()) {
|
||||||
await sendDigestEmail(userId, date);
|
await sendDigestEmail(userId, date);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -221,62 +223,62 @@ export async function processDueDigests(): Promise<void> {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function groupBySeverity(
|
function groupBySeverity(
|
||||||
alerts: typeof digestAlerts.$InferInsert[],
|
alerts: (typeof digestAlerts.$InferInsert)[],
|
||||||
): Record<string, typeof digestAlerts.$InferInsert[]> {
|
): Record<string, (typeof digestAlerts.$InferInsert)[]> {
|
||||||
const groups: Record<string, typeof digestAlerts.$InferInsert[]> = {
|
const groups: Record<string, (typeof digestAlerts.$InferInsert)[]> = {
|
||||||
critical: [],
|
critical: [],
|
||||||
warning: [],
|
warning: [],
|
||||||
info: [],
|
info: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const alert of alerts) {
|
for (const alert of alerts) {
|
||||||
const key = alert.severity ?? "info";
|
const key = alert.severity ?? "info";
|
||||||
if (groups[key]) {
|
if (groups[key]) {
|
||||||
groups[key].push(alert);
|
groups[key].push(alert);
|
||||||
} else {
|
} else {
|
||||||
groups.info.push(alert);
|
groups.info.push(alert);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDigestEmailHTML(
|
function buildDigestEmailHTML(
|
||||||
groups: Record<string, typeof digestAlerts.$InferInsert[]>,
|
groups: Record<string, (typeof digestAlerts.$InferInsert)[]>,
|
||||||
total: number,
|
total: number,
|
||||||
): string {
|
): string {
|
||||||
const sections = [];
|
const sections = [];
|
||||||
|
|
||||||
const severityConfig = [
|
const severityConfig = [
|
||||||
{ key: "critical", label: "Critical", color: "#dc2626", bg: "#fef2f2" },
|
{ key: "critical", label: "Critical", color: "#dc2626", bg: "#fef2f2" },
|
||||||
{ key: "warning", label: "Warning", color: "#d97706", bg: "#fffbeb" },
|
{ key: "warning", label: "Warning", color: "#d97706", bg: "#fffbeb" },
|
||||||
{ key: "info", label: "Info", color: "#2563eb", bg: "#eff6ff" },
|
{ key: "info", label: "Info", color: "#2563eb", bg: "#eff6ff" },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { key, label, color, bg } of severityConfig) {
|
for (const { key, label, color, bg } of severityConfig) {
|
||||||
const alerts = groups[key];
|
const alerts = groups[key];
|
||||||
if (!alerts.length) continue;
|
if (!alerts.length) continue;
|
||||||
|
|
||||||
const rows = alerts
|
const rows = alerts
|
||||||
.map(
|
.map(
|
||||||
(a) =>
|
(a) =>
|
||||||
`<tr style="border-bottom:1px solid #eee">
|
`<tr style="border-bottom:1px solid #eee">
|
||||||
<td style="padding:8px 12px"><span style="color:${color};font-weight:600;text-transform:uppercase;font-size:11px">${a.severity}</span></td>
|
<td style="padding:8px 12px"><span style="color:${color};font-weight:600;text-transform:uppercase;font-size:11px">${a.severity}</span></td>
|
||||||
<td style="padding:8px 12px">${escapeHtml(a.title)}</td>
|
<td style="padding:8px 12px">${escapeHtml(a.title)}</td>
|
||||||
<td style="padding:8px 12px;color:#666;font-size:12px">${escapeHtml(a.source)}</td>
|
<td style="padding:8px 12px;color:#666;font-size:12px">${escapeHtml(a.source)}</td>
|
||||||
</tr>`,
|
</tr>`,
|
||||||
)
|
)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
sections.push(`
|
sections.push(`
|
||||||
<div style="margin:16px 0;padding:12px;background:${bg};border-radius:8px;border-left:4px solid ${color}">
|
<div style="margin:16px 0;padding:12px;background:${bg};border-radius:8px;border-left:4px solid ${color}">
|
||||||
<h3 style="margin:0 0 8px 0;color:${color}">${label} (${alerts.length})</h3>
|
<h3 style="margin:0 0 8px 0;color:${color}">${label} (${alerts.length})</h3>
|
||||||
<table style="width:100%;border-collapse:collapse">${rows}</table>
|
<table style="width:100%;border-collapse:collapse">${rows}</table>
|
||||||
</div>
|
</div>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px">
|
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px">
|
||||||
<h2 style="margin:0 0 4px 0">🛡️ Kordant Security Digest</h2>
|
<h2 style="margin:0 0 4px 0">🛡️ Kordant Security Digest</h2>
|
||||||
<p style="color:#666;margin:0 0 24px 0">${total} alert${total > 1 ? "s" : ""} since your last digest</p>
|
<p style="color:#666;margin:0 0 24px 0">${total} alert${total > 1 ? "s" : ""} since your last digest</p>
|
||||||
@@ -289,45 +291,42 @@ function buildDigestEmailHTML(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildDigestPlainText(
|
function buildDigestPlainText(
|
||||||
groups: Record<string, typeof digestAlerts.$InferInsert[]>,
|
groups: Record<string, (typeof digestAlerts.$InferInsert)[]>,
|
||||||
total: number,
|
total: number,
|
||||||
): string {
|
): string {
|
||||||
const lines = [`Kordant Security Digest — ${total} alert${total > 1 ? "s" : ""}`, ""];
|
const lines = [
|
||||||
|
`Kordant Security Digest — ${total} alert${total > 1 ? "s" : ""}`,
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
for (const [key, alerts] of Object.entries(groups)) {
|
for (const [key, alerts] of Object.entries(groups)) {
|
||||||
if (!alerts.length) continue;
|
if (!alerts.length) continue;
|
||||||
lines.push(`${key.toUpperCase()} (${alerts.length}):`);
|
lines.push(`${key.toUpperCase()} (${alerts.length}):`);
|
||||||
for (const a of alerts) {
|
for (const a of alerts) {
|
||||||
lines.push(` - ${a.title} [${a.source}]`);
|
lines.push(` - ${a.title} [${a.source}]`);
|
||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push("This is an automated digest from Kordant.");
|
lines.push("This is an automated digest from Kordant.");
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
return str
|
return str
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, "&")
|
||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, ">")
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cleans up old digest records (older than 30 days).
|
* Cleans up old digest records (older than 30 days).
|
||||||
*/
|
*/
|
||||||
export async function cleanupOldDigests(): Promise<void> {
|
export async function cleanupOldDigests(): Promise<void> {
|
||||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
await db
|
await db.delete(digestAlerts).where(and(eq(digestAlerts.sent, true)));
|
||||||
.delete(digestAlerts)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(digestAlerts.sent, true),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`[digest] Cleaned up old digest records`);
|
console.log(`[digest] Cleaned up old digest records`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,401 +8,417 @@ import { createSession } from "~/server/auth/session";
|
|||||||
import { signJWT } from "~/server/auth/jwt";
|
import { signJWT } from "~/server/auth/jwt";
|
||||||
|
|
||||||
export async function createUserWithPassword(
|
export async function createUserWithPassword(
|
||||||
name: string,
|
name: string,
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
) {
|
) {
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, email))
|
.where(eq(users.email, email))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "CONFLICT",
|
code: "CONFLICT",
|
||||||
message: "Email already in use",
|
message: "Email already in use",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({ name, email, passwordHash })
|
.values({ name, email, passwordHash })
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const session = await createSession(user.id);
|
const session = await createSession(user.id);
|
||||||
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
|
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
|
||||||
|
|
||||||
return { user, sessionToken: session.sessionToken, accessToken };
|
return { user, sessionToken: session.sessionToken, accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authenticateUser(
|
export async function authenticateUser(email: string, password: string) {
|
||||||
email: string,
|
const [user] = await db
|
||||||
password: string,
|
.select()
|
||||||
) {
|
.from(users)
|
||||||
const [user] = await db
|
.where(eq(users.email, email))
|
||||||
.select()
|
.limit(1);
|
||||||
.from(users)
|
|
||||||
.where(eq(users.email, email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!user || !user.passwordHash) {
|
if (!user || !user.passwordHash) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Invalid email or password",
|
message: "Invalid email or password",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = await verifyPassword(password, user.passwordHash);
|
const valid = await verifyPassword(password, user.passwordHash);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Invalid email or password",
|
message: "Invalid email or password",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await createSession(user.id);
|
const session = await createSession(user.id);
|
||||||
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
|
const accessToken = await signJWT({ sub: user.id }, { expiresIn: "7d" });
|
||||||
return { user, sessionToken: session.sessionToken, accessToken };
|
return { user, sessionToken: session.sessionToken, accessToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
const APPLE_ISSUER = "https://appleid.apple.com";
|
const APPLE_ISSUER = "https://appleid.apple.com";
|
||||||
const APPLE_JWKS_URL = new URL("https://appleid.apple.com/auth/keys");
|
const APPLE_JWKS_URL = new URL("https://appleid.apple.com/auth/keys");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies an Apple identity token and authenticates the user.
|
* Verifies an Apple identity token and authenticates the user.
|
||||||
* If the user does not exist, creates a new account.
|
* If the user does not exist, creates a new account.
|
||||||
* If the user exists but has not linked Apple, links the provider.
|
* If the user exists but has not linked Apple, links the provider.
|
||||||
*/
|
*/
|
||||||
export async function authenticateWithApple(
|
export async function authenticateWithApple(
|
||||||
identityToken: string,
|
identityToken: string,
|
||||||
authorizationCode: string,
|
authorizationCode: string,
|
||||||
userIdentifier?: string | null,
|
userIdentifier?: string | null,
|
||||||
) {
|
) {
|
||||||
if (!identityToken) {
|
if (!identityToken) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
message: "Missing identity token",
|
message: "Missing identity token",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify Apple ID token using Apple's JWKS
|
// Verify Apple ID token using Apple's JWKS
|
||||||
let payload: { sub: string; email?: string; is_private_email?: string; };
|
let payload: { sub: string; email?: string; is_private_email?: string };
|
||||||
try {
|
try {
|
||||||
const JWKS = createRemoteJWKSet(APPLE_JWKS_URL);
|
const JWKS = createRemoteJWKSet(APPLE_JWKS_URL);
|
||||||
const result = await jwtVerify(identityToken, JWKS, {
|
const result = await jwtVerify(identityToken, JWKS, {
|
||||||
issuer: APPLE_ISSUER,
|
issuer: APPLE_ISSUER,
|
||||||
audience: process.env.IOS_BUNDLE_ID ?? "com.frenocorp.kordant",
|
audience: process.env.IOS_BUNDLE_ID ?? "com.frenocorp.kordant",
|
||||||
});
|
});
|
||||||
payload = result.payload as unknown as { sub: string; email?: string; is_private_email?: string; };
|
payload = result.payload as unknown as {
|
||||||
} catch (err) {
|
sub: string;
|
||||||
throw new TRPCError({
|
email?: string;
|
||||||
code: "UNAUTHORIZED",
|
is_private_email?: string;
|
||||||
message: "Invalid Apple identity token",
|
};
|
||||||
});
|
} catch (err) {
|
||||||
}
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Invalid Apple identity token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const appleUserId = payload.sub;
|
const appleUserId = payload.sub;
|
||||||
const email = payload.email ?? null;
|
const email = payload.email ?? null;
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Apple account has no email address",
|
message: "Apple account has no email address",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this Apple account is already linked
|
// Check if this Apple account is already linked
|
||||||
const [existingAccount] = await db
|
const [existingAccount] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(accounts)
|
.from(accounts)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(accounts.provider, "apple"),
|
eq(accounts.provider, "apple"),
|
||||||
eq(accounts.providerAccountId, appleUserId),
|
eq(accounts.providerAccountId, appleUserId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
let userId: string;
|
let userId: string;
|
||||||
let isNewUser = false;
|
let isNewUser = false;
|
||||||
|
|
||||||
if (existingAccount) {
|
if (existingAccount) {
|
||||||
// Already linked — use the existing user
|
// Already linked — use the existing user
|
||||||
userId = existingAccount.userId;
|
userId = existingAccount.userId;
|
||||||
isNewUser = false;
|
isNewUser = false;
|
||||||
|
|
||||||
// Update tokens
|
// Update tokens
|
||||||
await db
|
await db
|
||||||
.update(accounts)
|
.update(accounts)
|
||||||
.set({
|
.set({
|
||||||
accessToken: identityToken,
|
accessToken: identityToken,
|
||||||
refreshToken: authorizationCode,
|
refreshToken: authorizationCode,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(accounts.id, existingAccount.id));
|
.where(eq(accounts.id, existingAccount.id));
|
||||||
} else {
|
} else {
|
||||||
// Not linked — check if a user with this email exists
|
// Not linked — check if a user with this email exists
|
||||||
const [existingUserByEmail] = await db
|
const [existingUserByEmail] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
// Apple provides the user's first name and last name only on the initial sign-up
|
// Apple provides the user's first name and last name only on the initial sign-up
|
||||||
// We derive a display name from email if userIdentifier-based lookup doesn't work
|
// We derive a display name from email if userIdentifier-based lookup doesn't work
|
||||||
const displayName = email.split("@")[0] ?? "User";
|
const displayName = email.split("@")[0] ?? "User";
|
||||||
|
|
||||||
if (existingUserByEmail) {
|
if (existingUserByEmail) {
|
||||||
// Link Apple to existing user
|
// Link Apple to existing user
|
||||||
userId = existingUserByEmail.id;
|
userId = existingUserByEmail.id;
|
||||||
isNewUser = false;
|
isNewUser = false;
|
||||||
await db.insert(accounts).values({
|
await db.insert(accounts).values({
|
||||||
userId,
|
userId,
|
||||||
provider: "apple",
|
provider: "apple",
|
||||||
providerAccountId: appleUserId,
|
providerAccountId: appleUserId,
|
||||||
accessToken: identityToken,
|
accessToken: identityToken,
|
||||||
refreshToken: authorizationCode,
|
refreshToken: authorizationCode,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new user with Apple
|
// Create new user with Apple
|
||||||
isNewUser = true;
|
isNewUser = true;
|
||||||
const [newUser] = await db
|
const [newUser] = await db
|
||||||
.insert(users)
|
.insert(users)
|
||||||
.values({
|
.values({
|
||||||
name: displayName,
|
name: displayName,
|
||||||
email,
|
email,
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
userId = newUser.id;
|
userId = newUser.id;
|
||||||
|
|
||||||
await db.insert(accounts).values({
|
await db.insert(accounts).values({
|
||||||
userId,
|
userId,
|
||||||
provider: "apple",
|
provider: "apple",
|
||||||
providerAccountId: appleUserId,
|
providerAccountId: appleUserId,
|
||||||
accessToken: identityToken,
|
accessToken: identityToken,
|
||||||
refreshToken: authorizationCode,
|
refreshToken: authorizationCode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create session and JWT
|
// Create session and JWT
|
||||||
const session = await createSession(userId);
|
const session = await createSession(userId);
|
||||||
const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
const accessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||||
const refreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
|
const refreshToken = await signJWT(
|
||||||
|
{ sub: userId, type: "refresh" },
|
||||||
|
{ expiresIn: "30d" },
|
||||||
|
);
|
||||||
|
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
const [user] = await db
|
||||||
if (!user) {
|
.select()
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found after creation" });
|
.from(users)
|
||||||
}
|
.where(eq(users.id, userId))
|
||||||
|
.limit(1);
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found after creation",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return { user, sessionToken: session.sessionToken, accessToken, refreshToken, isNewUser };
|
return {
|
||||||
|
user,
|
||||||
|
sessionToken: session.sessionToken,
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
isNewUser,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refreshes an access token using a valid refresh token.
|
* Refreshes an access token using a valid refresh token.
|
||||||
*/
|
*/
|
||||||
export async function refreshAccessToken(refreshToken: string) {
|
export async function refreshAccessToken(refreshToken: string) {
|
||||||
const { verifyJWT, signJWT } = await import("~/server/auth/jwt");
|
const { verifyJWT, signJWT } = await import("~/server/auth/jwt");
|
||||||
|
|
||||||
let payload: { sub?: string; type?: string };
|
let payload: { sub?: string; type?: string };
|
||||||
try {
|
try {
|
||||||
payload = await verifyJWT<{ sub: string; type: string }>(refreshToken);
|
payload = await verifyJWT<{ sub: string; type: string }>(refreshToken);
|
||||||
} catch {
|
} catch {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Invalid or expired refresh token",
|
message: "Invalid or expired refresh token",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type !== "refresh") {
|
if (payload.type !== "refresh") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Invalid token type",
|
message: "Invalid token type",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = payload.sub!;
|
const userId = payload.sub!;
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(and(eq(users.id, userId), isNull(users.deletedAt)))
|
.where(and(eq(users.id, userId), isNull(users.deletedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "User not found",
|
message: "User not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
const newAccessToken = await signJWT({ sub: userId }, { expiresIn: "7d" });
|
||||||
const newRefreshToken = await signJWT({ sub: userId, type: "refresh" }, { expiresIn: "30d" });
|
const newRefreshToken = await signJWT(
|
||||||
|
{ sub: userId, type: "refresh" },
|
||||||
|
{ expiresIn: "30d" },
|
||||||
|
);
|
||||||
|
|
||||||
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
|
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends a password reset email.
|
* Sends a password reset email.
|
||||||
*/
|
*/
|
||||||
export async function forgotPassword(email: string) {
|
export async function forgotPassword(email: string) {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
.where(and(eq(users.email, email), isNull(users.deletedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// Don't reveal whether the email exists
|
// Don't reveal whether the email exists
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a reset token (valid for 1 hour)
|
// Generate a reset token (valid for 1 hour)
|
||||||
const resetToken = await signJWT(
|
const resetToken = await signJWT(
|
||||||
{ sub: user.id, type: "password-reset" },
|
{ sub: user.id, type: "password-reset" },
|
||||||
{ expiresIn: "1h" },
|
{ expiresIn: "1h" },
|
||||||
);
|
);
|
||||||
|
|
||||||
// In production, send via email service (Resend, SendGrid, etc.)
|
// In production, send via email service (Resend, SendGrid, etc.)
|
||||||
// For now, we log it and return success
|
// For now, we log it and return success
|
||||||
console.log(`Password reset token for ${email}: ${resetToken}`);
|
console.log(`Password reset token for ${email}: ${resetToken}`);
|
||||||
|
|
||||||
// TODO: Send email via Resend
|
// TODO: Send email via Resend
|
||||||
// const { Resend } = await import("resend");
|
// const { Resend } = await import("resend");
|
||||||
// const resend = new Resend(process.env.RESEND_API_KEY);
|
// const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
// await resend.emails.send({
|
// await resend.emails.send({
|
||||||
// from: "Kordant <support@kordant.com>",
|
// from: "Kordant <support@kordant.com>",
|
||||||
// to: email,
|
// to: email,
|
||||||
// subject: "Reset your password",
|
// subject: "Reset your password",
|
||||||
// html: `<a href="${process.env.APP_URL}/reset-password?token=${resetToken}">Reset password</a>`,
|
// html: `<a href="${process.env.APP_URL}/reset-password?token=${resetToken}">Reset password</a>`,
|
||||||
// });
|
// });
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets a user's password using a valid reset token.
|
* Resets a user's password using a valid reset token.
|
||||||
*/
|
*/
|
||||||
export async function resetPassword(token: string, newPassword: string) {
|
export async function resetPassword(token: string, newPassword: string) {
|
||||||
const { verifyJWT } = await import("~/server/auth/jwt");
|
const { verifyJWT } = await import("~/server/auth/jwt");
|
||||||
|
|
||||||
let payload: { sub?: string; type?: string };
|
let payload: { sub?: string; type?: string };
|
||||||
try {
|
try {
|
||||||
payload = await verifyJWT<{ sub: string; type: string }>(token);
|
payload = await verifyJWT<{ sub: string; type: string }>(token);
|
||||||
} catch {
|
} catch {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Invalid or expired reset token",
|
message: "Invalid or expired reset token",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type !== "password-reset") {
|
if (payload.type !== "password-reset") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "UNAUTHORIZED",
|
code: "UNAUTHORIZED",
|
||||||
message: "Invalid token type",
|
message: "Invalid token type",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = payload.sub!;
|
const userId = payload.sub!;
|
||||||
const passwordHash = await hashPassword(newPassword);
|
const passwordHash = await hashPassword(newPassword);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ passwordHash, updatedAt: new Date() })
|
.set({ passwordHash, updatedAt: new Date() })
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Revokes all sessions for a user (logout everywhere).
|
* Revokes all sessions for a user (logout everywhere).
|
||||||
*/
|
*/
|
||||||
export async function revokeUserSessions(userId: string) {
|
export async function revokeUserSessions(userId: string) {
|
||||||
const { sessions } = await import("~/server/db/schema/auth");
|
const { sessions } = await import("~/server/db/schema/auth");
|
||||||
await db
|
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||||
.delete(sessions)
|
return { success: true };
|
||||||
.where(eq(sessions.userId, userId));
|
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserById(id: string) {
|
export async function getUserById(id: string) {
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(users.id, id),
|
where: eq(users.id, id),
|
||||||
with: {
|
with: {
|
||||||
accounts: true,
|
accounts: true,
|
||||||
sessions: true,
|
sessions: true,
|
||||||
deviceTokens: true,
|
deviceTokens: true,
|
||||||
familyGroups: true,
|
familyGroups: true,
|
||||||
familyGroupOwned: true,
|
familyGroupOwned: true,
|
||||||
subscriptions: true,
|
subscriptions: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(
|
export async function updateUser(
|
||||||
id: string,
|
id: string,
|
||||||
data: { name?: string; email?: string; image?: string },
|
data: { name?: string; email?: string; image?: string },
|
||||||
) {
|
) {
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, id))
|
.where(eq(users.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.email && data.email !== existing.email) {
|
if (data.email && data.email !== existing.email) {
|
||||||
const [duplicate] = await db
|
const [duplicate] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.email, data.email))
|
.where(eq(users.email, data.email))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (duplicate) {
|
if (duplicate) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "CONFLICT",
|
code: "CONFLICT",
|
||||||
message: "Email already in use",
|
message: "Email already in use",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set(data)
|
.set(data)
|
||||||
.where(eq(users.id, id))
|
.where(eq(users.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteUser(id: string) {
|
export async function deleteUser(id: string) {
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.id, id))
|
.where(eq(users.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [deleted] = await db
|
const [deleted] = await db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({ deletedAt: new Date() })
|
.set({ deletedAt: new Date() })
|
||||||
.where(eq(users.id, id))
|
.where(eq(users.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user