diff --git a/apps/server/src/auth/index.ts b/apps/server/src/auth/index.ts index f34db622..302ce77e 100644 --- a/apps/server/src/auth/index.ts +++ b/apps/server/src/auth/index.ts @@ -9,7 +9,7 @@ import { config } from "#config"; import { prisma } from "#utils/prisma"; import { getRedis } from "#utils/redis"; -import { sendAuthOtpEmail } from "#auth/mailer"; +import { sendAuthOtpEmail, sendWelcomeEmail, sendLoginAlertEmail } from "#auth/mailer"; import { createAuthMiddleware } from "better-auth/api"; import { invalidateSessionCache, invalidateCacheByToken } from "#utils/authCache"; @@ -137,6 +137,16 @@ export const auth = betterAuth({ databaseHooks: { user: { + create: { + after: async (user) => { + try { + await sendWelcomeEmail(user.email, user.name || "there"); + } catch (error) { + console.error("Failed to send welcome email for user:", user.email, error); + } + }, + }, + delete: { before: async (user) => { try { @@ -154,6 +164,26 @@ export const auth = betterAuth({ }, session: { + create: { + after: async (session) => { + try { + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { email: true }, + }); + + if (user?.email) + await sendLoginAlertEmail(user.email, { + ip: session.ipAddress || "Unknown", + device: session.userAgent || "Unknown", + timestamp: session.createdAt.toISOString(), + }); + } catch (error) { + console.error("Failed to send login alert email for session:", session.id, error); + } + }, + }, + delete: { after: async (session) => { try { diff --git a/apps/server/src/auth/mailer.ts b/apps/server/src/auth/mailer.ts index ba0018a3..525c47d0 100644 --- a/apps/server/src/auth/mailer.ts +++ b/apps/server/src/auth/mailer.ts @@ -2,6 +2,13 @@ import nodemailer from "nodemailer"; import { config, isDevelopment } from "#config"; +import { + type LoginAlertMeta, + renderOtpEmail, + renderWelcomeEmail, + renderLoginAlertEmail, +} from "#mail/index"; + import { logger } from "#utils/logger"; interface AuthOtpEmailPayload { @@ -28,112 +35,85 @@ function getOtpEmailSubject(type: AuthOtpEmailPayload["type"]) { } } -function getOtpEmailHeadline(type: AuthOtpEmailPayload["type"]) { - switch (type) { - case "email-verification": - return "Verify your email"; - case "forget-password": - return "Password reset code"; - case "change-email": - return "Confirm email change"; - case "sign-in": - default: - return "Use this code to sign in"; - } -} +async function sendMail({ + to, + subject, + text, + html, +}: { + to: string; + subject: string; + text: string; + html: string; +}) { + if (config.auth.emailProvider === "smtp") { + if (!validateSmtpConfig()) { + throw new Error("SMTP provider selected but SMTP environment values are incomplete"); + } -function buildOtpEmailHtml(payload: AuthOtpEmailPayload) { - const headline = getOtpEmailHeadline(payload.type); - - return ` - - -
- - -| - - | -
| + + + | +
+ We detected a new sign-in to your VeriWorkly account (${sanitizedEmail}). Please review the session details below. +
+ + +| Device / Browser: | +${escapeHtml(meta.device)} | +
| IP Address: | +${escapeHtml(meta.ip)} | +
| Location: | +${escapeHtml(meta.location)} | +
| Timestamp (UTC): | +${escapeHtml(meta.timestamp)} | +
| + Was this not you? + If you did not authorize this login, your account security could be compromised. Please reply to this alert immediately or reach out to security@veriworkly.com. + | +
+ ${desc} +
+ + +|
+
+
+ One-Time Code
+ ${escapeHtml(formattedOtp)}
+
+
+ This code will expire in 10 minutes and can only be used once. If you didn't request this sign-in, you can safely ignore this email.
+
+ |
+
+ VeriWorkly uses passwordless OTP authentication to guarantee maximum credential security. +
+ `; + + return getBaseLayout({ title, preheader, bodyHtml }); +} diff --git a/apps/server/src/mail/welcome.ts b/apps/server/src/mail/welcome.ts new file mode 100644 index 00000000..b02550fd --- /dev/null +++ b/apps/server/src/mail/welcome.ts @@ -0,0 +1,107 @@ +import { getBaseLayout, escapeHtml } from "./layout"; + +export function renderWelcomeEmail(name: string, dashboardUrl: string): string { + const sanitizedName = escapeHtml(name || "there"); + const title = "Welcome to VeriWorkly"; + const preheader = + "Welcome to VeriWorkly! Start building your professional documents and portfolio today."; + + const bodyHtml = ` ++ We're thrilled to have you. VeriWorkly is designed to help you construct pristine, ATS-optimized resumes and publish premium developer portfolios. +
+ + +
+
|
+
+
|
+ ||
+
|
+ ||
+
|
+
| + + Get Started & Create Document → + + | +
+ Need any assistance or have feedback? Drop us a line at support@veriworkly.com. We read every email. +
+ `; + + return getBaseLayout({ title, preheader, bodyHtml }); +} diff --git a/apps/server/src/services/atsAiService.ts b/apps/server/src/services/atsAiService.ts index 5cbb73bc..0cca1a4a 100644 --- a/apps/server/src/services/atsAiService.ts +++ b/apps/server/src/services/atsAiService.ts @@ -10,84 +10,123 @@ import { EntitlementService } from "#services/entitlementService"; import { ApiError } from "#utils/errors"; import { logger } from "#utils/logger"; +const nullableString = (maxLen: number) => + z + .string() + .max(maxLen) + .nullable() + .optional() + .transform((val) => val ?? ""); + +const nullableBoolean = z + .boolean() + .nullable() + .optional() + .transform((val) => val ?? false); + +const nullableStringArray = (maxItemLen: number, maxItems: number) => + z + .array( + z + .string() + .max(maxItemLen) + .nullable() + .optional() + .transform((val) => val ?? ""), + ) + .max(maxItems) + .nullable() + .optional() + .transform((val) => val ?? []); + const insightsSchema = z.object({ - explanation: z.string().min(1).max(4_000), - missingEvidence: z.array(z.string().min(1).max(500)).max(12), - keywordOpportunities: z.array(z.string().min(1).max(200)).max(20), - recommendedImprovements: z.array(z.string().min(1).max(500)).max(12), - priorityOrder: z.array(z.string().min(1).max(500)).max(12), + explanation: nullableString(4_000), + missingEvidence: nullableStringArray(500, 12), + keywordOpportunities: nullableStringArray(200, 20), + recommendedImprovements: nullableStringArray(500, 12), + priorityOrder: nullableStringArray(500, 12), }); const convertedResumeSchema = z.object({ basics: z.object({ - fullName: z.string().max(200).default(""), - role: z.string().max(200).default(""), - headline: z.string().max(500).default(""), - email: z.string().max(320).default(""), - phone: z.string().max(100).default(""), - location: z.string().max(300).default(""), + fullName: nullableString(200), + role: nullableString(200), + headline: nullableString(500), + email: nullableString(320), + phone: nullableString(100), + location: nullableString(300), }), links: z .array( z.object({ - label: z.string().max(100), - url: z.string().max(2_048), + label: nullableString(100), + url: nullableString(2_048), }), ) .max(20) - .default([]), - summary: z.string().max(4_000).default(""), + .nullable() + .optional() + .transform((val) => val ?? []), + summary: nullableString(4_000), experience: z .array( z.object({ - company: z.string().max(300).default(""), - role: z.string().max(300).default(""), - location: z.string().max(300).default(""), - startDate: z.string().max(20).default(""), - endDate: z.string().max(20).default(""), - current: z.boolean().default(false), - summary: z.string().max(2_000).default(""), - highlights: z.array(z.string().max(1_000)).max(20).default([]), + company: nullableString(300), + role: nullableString(300), + location: nullableString(300), + startDate: nullableString(20), + endDate: nullableString(20), + current: nullableBoolean, + summary: nullableString(2_000), + highlights: nullableStringArray(1_000, 20), }), ) .max(30) - .default([]), + .nullable() + .optional() + .transform((val) => val ?? []), education: z .array( z.object({ - school: z.string().max(300).default(""), - degree: z.string().max(300).default(""), - field: z.string().max(300).default(""), - startDate: z.string().max(20).default(""), - endDate: z.string().max(20).default(""), - current: z.boolean().default(false), - summary: z.string().max(2_000).default(""), + school: nullableString(300), + degree: nullableString(300), + field: nullableString(300), + startDate: nullableString(20), + endDate: nullableString(20), + current: nullableBoolean, + summary: nullableString(2_000), }), ) .max(20) - .default([]), + .nullable() + .optional() + .transform((val) => val ?? []), projects: z .array( z.object({ - name: z.string().max(300).default(""), - role: z.string().max(300).default(""), - link: z.string().max(2_048).default(""), - summary: z.string().max(2_000).default(""), - highlights: z.array(z.string().max(1_000)).max(20).default([]), - skills: z.array(z.string().max(100)).max(30).default([]), + name: nullableString(300), + role: nullableString(300), + link: nullableString(2_048), + summary: nullableString(2_000), + highlights: nullableStringArray(1_000, 20), + skills: nullableStringArray(100, 30), }), ) .max(30) - .default([]), + .nullable() + .optional() + .transform((val) => val ?? []), skills: z .array( z.object({ - name: z.string().max(200), - keywords: z.array(z.string().max(100)).max(50), + name: nullableString(200), + keywords: nullableStringArray(100, 50), }), ) .max(30) - .default([]), + .nullable() + .optional() + .transform((val) => val ?? []), }); function complexity(report: AtsReport, resumeChars: number, jobChars: number): AtsComplexity { diff --git a/apps/server/src/services/atsQuotaService.ts b/apps/server/src/services/atsQuotaService.ts index 2049967f..ecab0f71 100644 --- a/apps/server/src/services/atsQuotaService.ts +++ b/apps/server/src/services/atsQuotaService.ts @@ -53,15 +53,16 @@ export class AtsQuotaService { const key = paid?.key ?? `ats:quota:${tier}:${userId ?? anonymousId(ip)}`; const redis = getRedis(); const used = Number((await redis.get(key)) ?? 0); - const ttl = paid + const rawTtl = paid ? Math.max(1, Math.ceil((paid.end.getTime() - Date.now()) / 1000)) - : Math.max(1, await redis.ttl(key)); + : await redis.ttl(key); + const ttl = rawTtl > 0 ? rawTtl : windowSeconds; return { tier, limit, used, remaining: Math.max(0, limit - used), - resetsAt: new Date(Date.now() + (ttl > 0 ? ttl : windowSeconds) * 1000).toISOString(), + resetsAt: new Date(Date.now() + ttl * 1000).toISOString(), canConvertResume: Boolean(paid), pricing: publicAtsPolicy(), }; diff --git a/apps/server/tests/ats/ai-service.test.ts b/apps/server/tests/ats/ai-service.test.ts index 85b7f19b..142099b6 100644 --- a/apps/server/tests/ats/ai-service.test.ts +++ b/apps/server/tests/ats/ai-service.test.ts @@ -155,6 +155,64 @@ describe("ATS AI service", () => { expect(result.creditsSpent).toBe(25); }); + it("resiliently parses LLM conversion outputs containing nulls or missing fields", async () => { + completionCreate.mockResolvedValue({ + id: "conversion_resilient", + choices: [ + { + message: { + content: JSON.stringify({ + basics: { + fullName: "Jane Doe", + role: null, + headline: undefined, + email: "jane@example.com", + phone: null, + location: null, + }, + links: null, + summary: null, + experience: [ + { + company: "Corp", + role: null, + location: undefined, + startDate: "2020", + endDate: null, + current: null, + summary: null, + highlights: [null, "achieved sales target", undefined], + }, + ], + education: null, + projects: undefined, + skills: null, + }), + }, + }, + ], + }); + const { AtsAiService } = await import("../../src/services/atsAiService"); + + const result = await AtsAiService.convertResume("user_1", "request_resilient", "Old resume"); + + expect(result.resume.basics.fullName).toBe("Jane Doe"); + expect(result.resume.basics.role).toBe(""); + expect(result.resume.basics.headline).toBe(""); + expect(result.resume.basics.phone).toBe(""); + expect(result.resume.basics.location).toBe(""); + expect(result.resume.links).toEqual([]); + expect(result.resume.summary).toBe(""); + expect(result.resume.experience[0].role).toBe(""); + expect(result.resume.experience[0].location).toBe(""); + expect(result.resume.experience[0].endDate).toBe(""); + expect(result.resume.experience[0].current).toBe(false); + expect(result.resume.experience[0].highlights).toEqual(["", "achieved sales target", ""]); + expect(result.resume.education).toEqual([]); + expect(result.resume.projects).toEqual([]); + expect(result.resume.skills).toEqual([]); + }); + it("releases conversion credits when provider output is invalid", async () => { completionCreate.mockResolvedValue({ id: "conversion_2", diff --git a/apps/server/tests/auth/better-auth-config.test.ts b/apps/server/tests/auth/better-auth-config.test.ts new file mode 100644 index 00000000..22a58729 --- /dev/null +++ b/apps/server/tests/auth/better-auth-config.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; + +const { mockGetRedis, mockSendWelcomeEmail, mockSendLoginAlertEmail, mockPrisma } = vi.hoisted( + () => { + return { + mockGetRedis: vi.fn(), + mockSendWelcomeEmail: vi.fn(), + mockSendLoginAlertEmail: vi.fn(), + mockPrisma: { + user: { + findUnique: vi.fn(), + }, + }, + }; + }, +); + +vi.mock("#utils/redis", () => ({ + getRedis: mockGetRedis, + cacheGet: vi.fn(), + cacheSet: vi.fn(), + cacheDel: vi.fn(), +})); + +vi.mock("#utils/prisma", () => ({ + prisma: mockPrisma, +})); + +vi.mock("#auth/mailer", () => ({ + sendAuthOtpEmail: vi.fn(), + sendWelcomeEmail: mockSendWelcomeEmail, + sendLoginAlertEmail: mockSendLoginAlertEmail, +})); + +import { auth } from "#auth/index"; + +describe("Better Auth configuration", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = auth.options as any; + + it("has rate limiting enabled with correct settings and custom rules", () => { + expect(options.rateLimit).toBeDefined(); + expect(options.rateLimit?.enabled).toBe(true); + expect(options.rateLimit?.storage).toBe("secondary-storage"); + expect(options.rateLimit?.customRules).toEqual({ + "/email-otp/send-verification-otp": { + window: 60, + max: 3, + }, + "/email-otp/verify-otp": { + window: 60, + max: 5, + }, + }); + }); + + it("has useSecureCookies defined under advanced settings", () => { + expect(options.advanced).toBeDefined(); + // In test environment, nodeEnv is not production, so useSecureCookies should be false + expect(options.advanced?.useSecureCookies).toBe(false); + }); + + it("has storeSessionInDatabase enabled to keep DB sessions active", () => { + expect(options.session).toBeDefined(); + expect(options.session?.storeSessionInDatabase).toBe(true); + }); + + it("implements secondaryStorage adapter that interacts with Redis", async () => { + expect(options.secondaryStorage).toBeDefined(); + const storage = options.secondaryStorage!; + + const redisMockInstance = { + get: vi.fn().mockResolvedValue("cached-val"), + set: vi.fn().mockResolvedValue("OK"), + del: vi.fn().mockResolvedValue(1), + }; + mockGetRedis.mockReturnValue(redisMockInstance); + + // Test get + const getVal = await storage.get("test-key"); + expect(mockGetRedis).toHaveBeenCalled(); + expect(redisMockInstance.get).toHaveBeenCalledWith("test-key"); + expect(getVal).toBe("cached-val"); + + // Test set without TTL + await storage.set("test-key", "value"); + expect(redisMockInstance.set).toHaveBeenCalledWith("test-key", "value"); + + // Test set with TTL + await storage.set("test-key", "value", 120); + expect(redisMockInstance.set).toHaveBeenCalledWith("test-key", "value", { EX: 120 }); + + // Test delete + await storage.delete("test-key"); + expect(redisMockInstance.del).toHaveBeenCalledWith("test-key"); + }); + + it("sends a welcome email on user.create.after", async () => { + const userCreateAfter = options.databaseHooks?.user?.create?.after; + expect(userCreateAfter).toBeDefined(); + + const mockUser = { + email: "newuser@example.com", + name: "John Doe", + }; + + await userCreateAfter(mockUser); + expect(mockSendWelcomeEmail).toHaveBeenCalledWith("newuser@example.com", "John Doe"); + }); + + it("sends a login alert email on session.create.after", async () => { + const sessionCreateAfter = options.databaseHooks?.session?.create?.after; + expect(sessionCreateAfter).toBeDefined(); + + mockPrisma.user.findUnique.mockResolvedValue({ + email: "user@example.com", + }); + + const mockSession = { + userId: "user-123", + ipAddress: "192.168.1.1", + userAgent: "Mozilla/5.0", + createdAt: new Date("2026-06-25T12:00:00.000Z"), + id: "session-123", + }; + + await sessionCreateAfter(mockSession); + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user-123" }, + select: { email: true }, + }); + expect(mockSendLoginAlertEmail).toHaveBeenCalledWith("user@example.com", { + ip: "192.168.1.1", + device: "Mozilla/5.0", + timestamp: mockSession.createdAt.toISOString(), + }); + }); +}); diff --git a/apps/server/tests/auth/mailer.test.ts b/apps/server/tests/auth/mailer.test.ts new file mode 100644 index 00000000..e17ff2a2 --- /dev/null +++ b/apps/server/tests/auth/mailer.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mockSendMail, mockCreateTransport } = vi.hoisted(() => { + const sendMail = vi.fn(); + return { + mockSendMail: sendMail, + mockCreateTransport: vi.fn().mockReturnValue({ + sendMail, + }), + }; +}); + +vi.mock("nodemailer", () => ({ + default: { + createTransport: mockCreateTransport, + }, +})); + +const { mockLogger } = vi.hoisted(() => { + return { + mockLogger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock("#utils/logger", () => ({ + logger: mockLogger, +})); + +// Mock config values +const mockConfig = vi.hoisted(() => ({ + auth: { + emailProvider: "console", + emailFrom: "VeriWorkly{itemText(item, "summary")}
+{itemText(item, "summary")}
+{itemText(item, "summary")}
+{itemText(item, "summary")}
+