From 9a88d6825a061509f52d430378320f95665a1272 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 23 Dec 2025 02:59:09 +0300 Subject: [PATCH 01/10] Refactor newsletter, gdpr, and download api endpoints to use Astro Actions API for type safety --- _TODO.md | 15 +- eslint.config.ts | 1 + src/actions/_contracts/gdpr.contracts.ts | 67 +++ .../_environment/environmentActions.ts | 68 +++ src/actions/_environment/siteUrlActions.ts | 15 + src/actions/_errors/ActionsFunctionError.ts | 160 +++++++ src/actions/_sentry/index.ts | 34 ++ src/actions/_utils/rateLimit.ts | 126 ++++++ src/actions/_utils/rateLimitStore.ts | 95 ++++ src/actions/_utils/requestContext.ts | 99 +++++ src/actions/contact.ts | 299 +++++++++++++ src/actions/downloads.ts | 46 ++ src/actions/gdpr.ts | 411 ++++++++++++++++++ src/actions/gdpr/_dsarVerificationEmails.ts | 97 +++++ src/actions/gdpr/_utils/consentStore.ts | 101 +++++ src/actions/gdpr/_utils/dsarStore.ts | 63 +++ src/actions/index.ts | 11 + src/actions/newsletter.ts | 237 ++++++++++ src/actions/newsletter/_email.ts | 378 ++++++++++++++++ src/actions/newsletter/_token.ts | 172 ++++++++ .../Newsletter/client/__tests__/index.spec.ts | 40 +- .../CallToAction/Newsletter/client/index.ts | 21 +- .../Forms/Contact/client/@types/index.ts | 1 - .../client/__tests__/formSubmission.spec.ts | 52 +-- .../Contact/client/__tests__/utils.spec.ts | 1 - .../Forms/Contact/client/formSubmission.ts | 18 +- src/components/Forms/Contact/client/index.ts | 3 +- .../Download/client/__tests__/index.spec.ts | 79 ++-- src/components/Forms/Download/client/index.ts | 19 +- src/pages/newsletter/confirm/[token].astro | 12 +- src/pages/privacy/my-data.astro | 81 ++-- tsconfig.json | 1 + 32 files changed, 2651 insertions(+), 172 deletions(-) create mode 100644 src/actions/_contracts/gdpr.contracts.ts create mode 100644 src/actions/_environment/environmentActions.ts create mode 100644 src/actions/_environment/siteUrlActions.ts create mode 100644 src/actions/_errors/ActionsFunctionError.ts create mode 100644 src/actions/_sentry/index.ts create mode 100644 src/actions/_utils/rateLimit.ts create mode 100644 src/actions/_utils/rateLimitStore.ts create mode 100644 src/actions/_utils/requestContext.ts create mode 100644 src/actions/contact.ts create mode 100644 src/actions/downloads.ts create mode 100644 src/actions/gdpr.ts create mode 100644 src/actions/gdpr/_dsarVerificationEmails.ts create mode 100644 src/actions/gdpr/_utils/consentStore.ts create mode 100644 src/actions/gdpr/_utils/dsarStore.ts create mode 100644 src/actions/index.ts create mode 100644 src/actions/newsletter.ts create mode 100644 src/actions/newsletter/_email.ts create mode 100644 src/actions/newsletter/_token.ts diff --git a/_TODO.md b/_TODO.md index 5f58cca6f..39129fe57 100644 --- a/_TODO.md +++ b/_TODO.md @@ -29,10 +29,7 @@ ### Endpoints: -- cron/cleanup-confirmations → GET -- cron/cleanup-dsar-requests → GET -- cron/run-all → GET -- social-card/ → GET +- social-card/ → GET // not refactoring to an action - this stays as an api endpoint - contact/ → POST (contact form submission) and OPTIONS (CORS pre-flight) - downloads/submit → POST @@ -48,8 +45,6 @@ - _utils/rateLimit.ts - _utils/rateLimitStore.ts -- cron/cleanup-confirmations.ts -- cron/cleanup-dsar-requests.ts - gdpr/_utils/consentStore.ts - gdpr/_utils/dsarStore.ts - newsletter/_token.ts @@ -133,7 +128,7 @@ import { defineConfig } from 'astro/config' export default defineConfig({ prefetch: { - prefetchAll: true + c: true } }) ``` @@ -144,6 +139,10 @@ You can then opt-out of prefetching for individual links by setting data-astro-p About ``` +## Service Worker + +Evaluate the service worker configuration for whether it's sensible. + ## Email Templates Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo. @@ -201,6 +200,8 @@ Add Upstash Search as a Vercel Marketplace Integration. Google Calendar, Apple Calendar, Microsoft Outlook and Teams, and generate iCal/ics files (for all other calendars and cases). +## Troubleshooting deploy workflow issues + `https://github.com/add2cal/add-to-calendar-button` `https://add-to-calendar-button.com/` diff --git a/eslint.config.ts b/eslint.config.ts index c452f33bd..19c86a59f 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -380,6 +380,7 @@ export default [ { files: [ '.github/actions/**/*', + 'src/actions/newsletter.ts', 'src/lib/config/pwa.ts', 'src/lib/config/serviceWorker.ts', 'src/components/scripts/store/__tests__/socialEmbeds.spec.ts', diff --git a/src/actions/_contracts/gdpr.contracts.ts b/src/actions/_contracts/gdpr.contracts.ts new file mode 100644 index 000000000..1524281f2 --- /dev/null +++ b/src/actions/_contracts/gdpr.contracts.ts @@ -0,0 +1,67 @@ +/** + * GDPR Actions Types + * + * Type definitions for GDPR-related Astro Actions. + */ + +export interface ConsentRecord { + id: string + DataSubjectId: string + email?: string + purposes: Array<'contact' | 'marketing' | 'analytics' | 'downloads'> + timestamp: string + source: 'contact_form' | 'newsletter_form' | 'download_form' | 'cookies_modal' | 'preferences_page' + userAgent: string + ipAddress?: string + privacyPolicyVersion: string + consentText?: string + verified: boolean +} + +export interface ConsentRequest { + DataSubjectId: string + email?: string + purposes: Array<'contact' | 'marketing' | 'analytics' | 'downloads'> + source: string + userAgent: string + ipAddress?: string + consentText?: string + verified?: boolean +} + +export interface ConsentResponse { + success: true + record: ConsentRecord +} + +export interface ErrorResponse { + success: false + error: { + code: string + message: string + requestId?: string + correlationId?: string + retryable?: boolean + details?: Record + } +} + +export interface DSARRequest { + id: string + token: string + email: string + requestType: 'ACCESS' | 'DELETE' + expiresAt: string + fulfilledAt?: string + createdAt: string +} + +export interface DSARRequestInput { + email: string + requestType: 'ACCESS' | 'DELETE' +} + +export interface DSARResponse { + success: true + message: string +} diff --git a/src/actions/_environment/environmentActions.ts b/src/actions/_environment/environmentActions.ts new file mode 100644 index 000000000..e90137e0d --- /dev/null +++ b/src/actions/_environment/environmentActions.ts @@ -0,0 +1,68 @@ +import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError' +import { getOptionalEnv, isUnitTest } from '@lib/config/environmentServer' +export { + isCI, + isE2eTest, + isGitHub, + isTest, + isUnitTest, + isVercel, +} from '@lib/config/environmentServer' + +export const isDev = () => { + return import.meta.env.MODE === 'development' +} + +export const isProd = () => { + return import.meta.env.MODE === 'production' && !isUnitTest() +} + +export function getPrivacyPolicyVersion(): string { + const version = import.meta.env['PRIVACY_POLICY_VERSION'] + if (!version) { + throw new ActionsFunctionError( + 'PRIVACY_POLICY_VERSION environment variable is not set. This should be injected by the PrivacyPolicyVersion integration.' + ) + } + return version +} + +export function getPackageRelease(): string { + const release = import.meta.env['PACKAGE_RELEASE_VERSION'] + if (!release) { + throw new ActionsFunctionError( + 'PACKAGE_RELEASE_VERSION environment variable is not set. This should be injected by the PackageRelease integration.' + ) + } + return release +} + +export function getConvertkitApiKey(): string { + const secret = getOptionalEnv('CONVERTKIT_API_KEY') + if (!secret) { + throw new ActionsFunctionError( + 'CONVERTKIT_API_KEY environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' + ) + } + return secret +} + +export function getResendApiKey(): string { + const key = getOptionalEnv('RESEND_API_KEY') + if (!key) { + throw new ActionsFunctionError( + 'RESEND_API_KEY environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' + ) + } + return key +} + +export function getSentryDsn(): string { + const key = getOptionalEnv('PUBLIC_SENTRY_DSN') + if (!key) { + throw new ActionsFunctionError( + 'PUBLIC_SENTRY_DSN environment variable is not set. This is either set in a .env file locally during development, in GitHub Secrets and made available in CI runs by the .github/workflows actions, or by Vercel as an env var made available to serverless functions in deployment.' + ) + } + return key +} diff --git a/src/actions/_environment/siteUrlActions.ts b/src/actions/_environment/siteUrlActions.ts new file mode 100644 index 000000000..dbaea9613 --- /dev/null +++ b/src/actions/_environment/siteUrlActions.ts @@ -0,0 +1,15 @@ +import packageJson from '../../../package.json' with { type: 'json' } +import { isVercel } from './environmentActions' +import { getOptionalEnv } from '@lib/config/environmentServer' + +const devServerPort = getOptionalEnv('DEV_SERVER_PORT')?.trim() +const resolvedDevServerPort = devServerPort && devServerPort.length > 0 ? devServerPort : '4321' +const { domain } = packageJson + +export const getSiteUrl = (): string => { + if (isVercel() && domain) { + return `https://${domain}` + } + + return `http://localhost:${resolvedDevServerPort}` +} diff --git a/src/actions/_errors/ActionsFunctionError.ts b/src/actions/_errors/ActionsFunctionError.ts new file mode 100644 index 000000000..f47e7879d --- /dev/null +++ b/src/actions/_errors/ActionsFunctionError.ts @@ -0,0 +1,160 @@ +const DEFAULT_ERROR_MESSAGE = 'Internal server error' +const MIN_ERROR_STATUS = 400 +const MAX_ERROR_STATUS = 599 +const DEFAULT_ERROR_STATUS = 500 +const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]) + +export interface ActionsFunctionErrorParams { + message: string + stack?: string | undefined + cause?: unknown + status?: number | undefined + code?: string | undefined + route?: string | undefined + operation?: string | undefined + requestId?: string | undefined + correlationId?: string | undefined + details?: Record | undefined + retryable?: boolean | undefined +} + +const cloneDetails = (details?: Record): Record | undefined => + details ? { ...details } : undefined + +const normalizeStatus = (status?: number): number => { + if (typeof status !== 'number' || Number.isNaN(status)) { + return DEFAULT_ERROR_STATUS + } + + const truncated = Math.trunc(status) + if (truncated < MIN_ERROR_STATUS) { + return MIN_ERROR_STATUS + } + if (truncated > MAX_ERROR_STATUS) { + return DEFAULT_ERROR_STATUS + } + + return truncated +} + +const isRetryableStatus = (status: number): boolean => RETRYABLE_STATUS_CODES.has(status) + +function normalizeActionsFunctionError(message: unknown): ActionsFunctionErrorParams { + if (message instanceof ActionsFunctionError) { + return message.toParams() + } + + if (message instanceof Error) { + return { + message: message.message || DEFAULT_ERROR_MESSAGE, + stack: message.stack, + cause: message.cause, + } + } + + if (typeof message === 'string') { + const normalized = message.trim() + return { message: normalized || DEFAULT_ERROR_MESSAGE } + } + + if (message && typeof message === 'object') { + const params = message as Partial + return { + message: + typeof params.message === 'string' && params.message.trim() + ? params.message.trim() + : DEFAULT_ERROR_MESSAGE, + stack: params.stack, + cause: params.cause, + status: params.status, + code: params.code, + route: params.route, + operation: params.operation, + requestId: params.requestId, + correlationId: params.correlationId, + details: cloneDetails(params.details), + retryable: params.retryable, + } + } + + if (message === undefined || message === null) { + return { message: DEFAULT_ERROR_MESSAGE } + } + + return { message: String(message) } +} + +export class ActionsFunctionError extends Error { + status: number + isClientError: boolean + isServerError: boolean + retryable: boolean + code?: string | undefined + route?: string | undefined + operation?: string | undefined + requestId?: string | undefined + correlationId?: string | undefined + details?: Record | undefined + + constructor(message?: unknown, context?: Partial) { + const normalized = normalizeActionsFunctionError(message) + const merged: ActionsFunctionErrorParams = { + ...normalized, + ...(context || {}), + } + + super(merged.message) + + Object.defineProperty(this, 'name', { + value: 'ActionsFunctionError', + enumerable: false, + configurable: true, + }) + + Object.setPrototypeOf(this, new.target.prototype) + + if ('captureStackTrace' in Error) Error.captureStackTrace(this, ActionsFunctionError) + if ('stackTraceLimit' in Error) Error.stackTraceLimit = Infinity + + this.message = merged.message + this.cause = merged.cause + this.status = normalizeStatus(merged.status) + this.isClientError = this.status >= MIN_ERROR_STATUS && this.status < DEFAULT_ERROR_STATUS + this.isServerError = this.status >= DEFAULT_ERROR_STATUS + this.retryable = + typeof merged.retryable === 'boolean' ? merged.retryable : isRetryableStatus(this.status) + this.code = merged.code + this.route = merged.route + this.operation = merged.operation + this.requestId = merged.requestId + this.correlationId = merged.correlationId + this.details = cloneDetails(merged.details) + } + + static from(error: unknown, overrides?: Partial): ActionsFunctionError { + if (error instanceof ActionsFunctionError) { + return new ActionsFunctionError(error.toParams(), overrides) + } + return new ActionsFunctionError(error, overrides) + } + + toParams(): ActionsFunctionErrorParams { + return { + message: this.message, + stack: this.stack, + cause: this.cause, + status: this.status, + code: this.code, + route: this.route, + operation: this.operation, + requestId: this.requestId, + correlationId: this.correlationId, + details: cloneDetails(this.details), + retryable: this.retryable, + } + } + + getSafeMessage(fallbackMessage = DEFAULT_ERROR_MESSAGE): string { + return this.isClientError ? this.message : fallbackMessage + } +} diff --git a/src/actions/_sentry/index.ts b/src/actions/_sentry/index.ts new file mode 100644 index 000000000..37d0fd710 --- /dev/null +++ b/src/actions/_sentry/index.ts @@ -0,0 +1,34 @@ +import { init as sentryInit } from '@sentry/astro' +import { getPackageRelease, getSentryDsn, isDev, isProd } from '@actions/_environment/environmentActions' + +let initialized = false + +export function ensureActionsSentry(): void { + if (initialized) { + return + } + + if (!isProd()) { + return + } + + sentryInit({ + dsn: getSentryDsn(), + release: getPackageRelease(), + environment: 'production', + tracesSampleRate: 1.0, + sendDefaultPii: false, + attachStacktrace: true, + maxBreadcrumbs: 100, + beforeSend(event) { + if (isDev()) { + return null + } + return event + }, + }) + + initialized = true +} + +ensureActionsSentry() diff --git a/src/actions/_utils/rateLimit.ts b/src/actions/_utils/rateLimit.ts new file mode 100644 index 000000000..5424aa67f --- /dev/null +++ b/src/actions/_utils/rateLimit.ts @@ -0,0 +1,126 @@ +import { isDbError } from 'astro:db' +import { isDev, isTest } from '@actions/_environment/environmentActions' +import { withRateLimitWindow } from '@actions/_utils/rateLimitStore' + +export type RateLimiter = { + limit: (_identifier: string) => Promise<{ success: boolean; reset: number | undefined }> +} + +export type RateLimiterKey = 'consent' | 'consentRead' | 'export' | 'delete' | 'contact' + +export type RateLimiterMap = Record + +const rateLimitStore = new Map() + +type RateLimiterConfig = { + scope: RateLimiterKey + limit: number + windowMs: number +} + +const limiterConfigs: Record = { + consent: { scope: 'consent', limit: 10, windowMs: 60_000 }, + consentRead: { scope: 'consentRead', limit: 30, windowMs: 60_000 }, + export: { scope: 'export', limit: 5, windowMs: 60_000 }, + delete: { scope: 'delete', limit: 3, windowMs: 60_000 }, + contact: { scope: 'contact', limit: 5, windowMs: 15 * 60 * 1000 }, +} + +export const rateLimiters: RateLimiterMap = { + consent: createLimiter(limiterConfigs.consent), + consentRead: createLimiter(limiterConfigs.consentRead), + export: createLimiter(limiterConfigs.export), + delete: createLimiter(limiterConfigs.delete), + contact: createLimiter(limiterConfigs.contact), +} + +export async function checkRateLimit( + limiter: RateLimiter, + identifier: string, +): Promise<{ success: boolean; reset: number | undefined }> { + const result = await limiter.limit(identifier) + return { + success: result.success, + reset: result.reset, + } +} + +export function checkContactRateLimit(ipFingerprint: string): boolean { + if (isDev() || isTest()) { + return true + } + + const now = Date.now() + const windowMs = 15 * 60 * 1000 + const maxRequests = 5 + const key = `contact_rate_limit_${ipFingerprint}` + const requests = rateLimitStore.get(key) || [] + + const validRequests = requests.filter(timestamp => now - timestamp < windowMs) + + if (validRequests.length >= maxRequests) { + return false + } + + validRequests.push(now) + rateLimitStore.set(key, validRequests) + return true +} + +function createLimiter(config: RateLimiterConfig): RateLimiter { + return { + limit: identifier => applyRateLimit(config, identifier), + } +} + +async function applyRateLimit( + config: RateLimiterConfig, + identifier: string, +): Promise<{ success: boolean; reset: number | undefined }> { + if (isDev() || isTest()) { + return { + success: true, + reset: Date.now() + config.windowMs, + } + } + + if (!identifier) { + return { success: true, reset: Date.now() + config.windowMs } + } + + try { + return await withRateLimitWindow(config.scope, identifier, async context => { + const now = Date.now() + const currentWindow = context.window + + if (!currentWindow || currentWindow.windowExpiresAt <= now) { + const reset = now + config.windowMs + const nextWindow = await context.resetWindow({ + hits: 1, + limit: config.limit, + windowMs: config.windowMs, + windowExpiresAt: reset, + }) + return { success: true, reset: nextWindow.windowExpiresAt } + } + + if (currentWindow.hits < config.limit) { + const updatedWindow = await context.incrementHits() + return { success: true, reset: updatedWindow.windowExpiresAt } + } + + return { + success: false, + reset: currentWindow.windowExpiresAt, + } + }) + } catch (error) { + if (isDbError(error)) { + return { + success: false, + reset: Date.now() + config.windowMs, + } + } + throw error + } +} diff --git a/src/actions/_utils/rateLimitStore.ts b/src/actions/_utils/rateLimitStore.ts new file mode 100644 index 000000000..c7636db12 --- /dev/null +++ b/src/actions/_utils/rateLimitStore.ts @@ -0,0 +1,95 @@ +import { randomUUID } from 'node:crypto' +import { and, db, eq, rateLimitWindows } from 'astro:db' + +export type RateLimitWindowRecord = typeof rateLimitWindows.$inferSelect + +export type RateLimitWindowResetInput = { + hits: number + limit: number + windowMs: number + windowExpiresAt: number +} + +export type RateLimitWindowContext = { + window: RateLimitWindowRecord | undefined + resetWindow: (_input: RateLimitWindowResetInput) => Promise + incrementHits: () => Promise +} + +export async function withRateLimitWindow( + scope: string, + identifier: string, + handler: (_context: RateLimitWindowContext) => Promise, +): Promise { + return db.transaction(async tx => { + const result = await tx + .select() + .from(rateLimitWindows) + .where(and(eq(rateLimitWindows.scope, scope), eq(rateLimitWindows.identifier, identifier))) + .limit(1) + + let currentWindow = result[0] + + const resetWindow = async (input: RateLimitWindowResetInput): Promise => { + const upsertId = currentWindow?.id ?? randomUUID() + const payload: RateLimitWindowRecord = { + id: upsertId, + scope, + identifier, + hits: input.hits, + limit: input.limit, + windowMs: input.windowMs, + windowExpiresAt: input.windowExpiresAt, + updatedAt: new Date(), + } + + await tx + .insert(rateLimitWindows) + .values(payload) + .onConflictDoUpdate({ + target: [rateLimitWindows.scope, rateLimitWindows.identifier], + set: { + hits: input.hits, + limit: input.limit, + windowMs: input.windowMs, + windowExpiresAt: input.windowExpiresAt, + updatedAt: payload.updatedAt, + }, + }) + + currentWindow = payload + return payload + } + + const incrementHits = async (): Promise => { + if (!currentWindow) { + throw new Error('Rate limit window has not been initialized') + } + + const nextHits = currentWindow.hits + 1 + const updatedAt = new Date() + + await tx + .update(rateLimitWindows) + .set({ + hits: nextHits, + updatedAt, + }) + .where(and(eq(rateLimitWindows.scope, scope), eq(rateLimitWindows.identifier, identifier))) + + currentWindow = { + ...currentWindow, + hits: nextHits, + updatedAt, + } + + return currentWindow + } + + return handler({ + window: currentWindow, + resetWindow, + incrementHits, + }) + }) +} diff --git a/src/actions/_utils/requestContext.ts b/src/actions/_utils/requestContext.ts new file mode 100644 index 000000000..ee9c68f94 --- /dev/null +++ b/src/actions/_utils/requestContext.ts @@ -0,0 +1,99 @@ +import { createHash } from 'node:crypto' +import type { AstroCookies } from 'astro' + +const FUNCTIONAL_CONSENT_COOKIE = 'consent_functional' + +export function createRateLimitIdentifier(scope: string, fingerprint?: string): string { + return `${scope}:${fingerprint ?? 'anonymous'}` +} + +export function buildRequestFingerprint(options: { + route: string + request: Request + cookies?: AstroCookies + clientAddress?: string +}): { fingerprint?: string; consentFunctional: boolean } { + const hashSalt = options.route + const consentFunctional = readFunctionalConsent(options.cookies) + const requestMeta = buildRequestMetadata(options.request, hashSalt, consentFunctional, options.clientAddress) + const fingerprint = requestMeta.ipHash ?? requestMeta.userAgentHash + + const result: { fingerprint?: string; consentFunctional: boolean } = { + consentFunctional, + } + + if (fingerprint) { + result.fingerprint = fingerprint + } + + return result +} + +type RequestMetadata = { + method?: string + ip?: string + ipHash?: string + userAgent?: string + userAgentHash?: string +} + +function readFunctionalConsent(cookies?: AstroCookies): boolean { + const consentValue = cookies?.get(FUNCTIONAL_CONSENT_COOKIE)?.value + return consentValue === 'true' +} + +function buildRequestMetadata( + request: Request, + salt: string, + includeRawPII: boolean, + clientAddress?: string, +): RequestMetadata { + const method = request.method + const ip = extractClientIp(request) ?? clientAddress + const userAgent = request.headers.get('user-agent') ?? undefined + + const metadata: RequestMetadata = { + ...(method && { method }), + ...(ip && { ipHash: hashIdentifier(ip, salt) }), + ...(userAgent && { userAgentHash: hashIdentifier(userAgent, salt) }), + } + + if (includeRawPII) { + if (ip) { + metadata.ip = ip + } + if (userAgent) { + metadata.userAgent = userAgent + } + } + + return metadata +} + +function extractClientIp(request: Request): string | undefined { + const headers = request.headers + const candidates = [ + headers.get('x-forwarded-for'), + headers.get('cf-connecting-ip'), + headers.get('x-real-ip'), + headers.get('fastly-client-ip'), + ] + + for (const value of candidates) { + if (value) { + const [first] = value.split(',') + if (first) { + const normalized = first.trim() + if (normalized) { + return normalized + } + } + } + } + + return undefined +} + +function hashIdentifier(value: string, salt: string): string { + return createHash('sha256').update(`${salt}:${value}`).digest('hex') +} diff --git a/src/actions/contact.ts b/src/actions/contact.ts new file mode 100644 index 000000000..cd5f33a99 --- /dev/null +++ b/src/actions/contact.ts @@ -0,0 +1,299 @@ +import { Buffer } from 'node:buffer' +import { Resend } from 'resend' +import emailValidator from 'email-validator' +import { v4 as uuidv4, validate as uuidValidate } from 'uuid' +import { ActionError, defineAction } from 'astro:actions' +import { checkContactRateLimit } from '@actions/_utils/rateLimit' +import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/_utils/requestContext' +import { getPrivacyPolicyVersion, getResendApiKey, isDev, isTest } from '@actions/_environment/environmentActions' +import { createConsentRecord } from '@actions/gdpr/_utils/consentStore' + +type ContactFormData = { + name: string + email: string + phone?: string + message: string + consent?: boolean + DataSubjectId?: string + service?: string + budget?: string + timeline?: string + website?: string +} + +type FileAttachment = { + filename: string + content: Buffer + contentType: string + size: number +} + +type EmailData = { + from: string + to: string + subject: string + html: string +} + +function escapeHtml(text: string): string { + const map: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + } + return text.replace(/[&<>"']/g, char => map[char] || char) +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` + return `${(bytes / (1024 * 1024)).toFixed(2)} MB` +} + +function validateInput(body: ContactFormData): string[] { + const errors: string[] = [] + + if (!body.name?.trim()) { + errors.push('Name is required') + } else if (body.name.length < 2) { + errors.push('Name must be at least 2 characters') + } else if (body.name.length > 100) { + errors.push('Name must be less than 100 characters') + } + + if (!body.email?.trim()) { + errors.push('Email is required') + } else if (!emailValidator.validate(body.email.trim())) { + errors.push('Invalid email address') + } + + if (!body.message?.trim()) { + errors.push('Message is required') + } else if (body.message.length < 10) { + errors.push('Message must be at least 10 characters') + } else if (body.message.length > 2000) { + errors.push('Message must be less than 2000 characters') + } + + const spamPatterns = ['viagra', 'cialis', 'casino', 'poker', 'lottery'] + const messageContent = `${body.name} ${body.email} ${body.message}`.toLowerCase() + if (spamPatterns.some(pattern => messageContent.includes(pattern))) { + errors.push('Message appears to contain spam') + } + + return errors +} + +function generateEmailContent(data: ContactFormData, files: FileAttachment[]): string { + const fields = [ + `

Name: ${escapeHtml(data.name)}

`, + `

Email: ${escapeHtml(data.email)}

`, + ] + + if (data.phone) fields.push(`

Phone: ${escapeHtml(data.phone)}

`) + if (data.service) fields.push(`

Service: ${escapeHtml(data.service)}

`) + if (data.budget) fields.push(`

Budget: ${escapeHtml(data.budget)}

`) + if (data.timeline) fields.push(`

Timeline: ${escapeHtml(data.timeline)}

`) + if (data.website) fields.push(`

Website: ${escapeHtml(data.website)}

`) + + fields.push('

Message:

') + fields.push(`

${escapeHtml(data.message).replace(/\n/g, '
')}

`) + + if (files.length > 0) { + fields.push('

Attachments:

') + fields.push('
    ') + files.forEach(file => { + fields.push(`
  • ${escapeHtml(file.filename)} (${formatFileSize(file.size)})
  • `) + }) + fields.push('
') + } + + fields.push(`

Consent Given: ${data.consent ? 'Yes' : 'No'}

`) + + return ` + + + + + + + +

New Contact Form Submission

+${fields.join('\n')} + + +`.trim() +} + +async function sendEmail(emailData: EmailData, files: FileAttachment[]): Promise { + if (isTest() || isDev()) { + return + } + + const resend = new Resend(getResendApiKey()) + const attachments = files.map(file => ({ filename: file.filename, content: file.content })) + + const result = await resend.emails.send({ + from: emailData.from, + to: emailData.to, + subject: emailData.subject, + html: emailData.html, + ...(attachments.length > 0 && { attachments }), + }) + + if (!result.data) { + throw new ActionError({ code: 'BAD_GATEWAY', message: 'Failed to send email. Please try again later.' }) + } +} + +function parseBoolean(value: FormDataEntryValue | null): boolean { + if (value === null) return false + if (typeof value === 'string') return value === 'true' + return false +} + +function readString(form: FormData, key: string): string { + const value = form.get(key) + return typeof value === 'string' ? value : '' +} + +async function parseAttachments(form: FormData): Promise { + const files: FileAttachment[] = [] + const allowedTypes = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ] + const maxFileSize = 10 * 1024 * 1024 + const maxFiles = 5 + + let fileCount = 0 + for (const [key, value] of form.entries()) { + if (key.startsWith('file') && value instanceof File && value.size > 0) { + fileCount++ + + if (fileCount > maxFiles) { + throw new ActionError({ code: 'BAD_REQUEST', message: `Maximum ${maxFiles} files allowed` }) + } + + if (value.size > maxFileSize) { + throw new ActionError({ code: 'BAD_REQUEST', message: `File ${value.name} exceeds 10MB limit` }) + } + + if (!allowedTypes.includes(value.type)) { + throw new ActionError({ code: 'BAD_REQUEST', message: `File type ${value.type} not allowed` }) + } + + const buffer = Buffer.from(await value.arrayBuffer()) + files.push({ + filename: value.name, + content: buffer, + contentType: value.type, + size: value.size, + }) + } + } + + return files +} + +export const contact = { + submit: defineAction({ + accept: 'form', + handler: async (form: FormData, context): Promise<{ success: true; message: string }> => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/contact/submit', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('contact', fingerprint) + if (!checkContactRateLimit(rateLimitIdentifier)) { + throw new ActionError({ + code: 'TOO_MANY_REQUESTS', + message: 'Too many form submissions. Please try again later.', + }) + } + + const formData: ContactFormData = { + name: readString(form, 'name'), + email: readString(form, 'email'), + message: readString(form, 'message'), + consent: parseBoolean(form.get('consent')), + } + + const phone = readString(form, 'phone') + const service = readString(form, 'service') + const budget = readString(form, 'budget') + const timeline = readString(form, 'timeline') + const website = readString(form, 'website') + + if (phone) formData.phone = phone + if (service) formData.service = service + if (budget) formData.budget = budget + if (timeline) formData.timeline = timeline + if (website) formData.website = website + + const files = await parseAttachments(form) + + const validationErrors = validateInput(formData) + if (validationErrors.length > 0) { + throw new ActionError({ code: 'BAD_REQUEST', message: validationErrors[0] ?? 'Invalid form submission' }) + } + + const userAgent = context.request.headers.get('user-agent') || 'unknown' + const ip = + context.clientAddress || + context.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + context.request.headers.get('x-real-ip') || + 'unknown' + + if (formData.consent) { + let subjectId = formData.DataSubjectId + if (!subjectId) { + subjectId = uuidv4() + } else if (!uuidValidate(subjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId format' }) + } + + await createConsentRecord({ + dataSubjectId: subjectId, + email: formData.email.trim(), + purposes: ['contact'], + source: 'contact_form', + userAgent, + ipAddress: ip !== 'unknown' ? ip : null, + privacyPolicyVersion: getPrivacyPolicyVersion(), + consentText: null, + verified: true, + }) + } + + const htmlContent = generateEmailContent(formData, files) + await sendEmail( + { + from: 'contact@webstackbuilders.com', + to: 'info@webstackbuilders.com', + subject: `Contact Form: ${formData.name}`, + html: htmlContent, + }, + files, + ) + + return { + success: true, + message: 'Thank you for your message. We will get back to you soon!', + } + }, + }), +} diff --git a/src/actions/downloads.ts b/src/actions/downloads.ts new file mode 100644 index 000000000..8c5d1c3b0 --- /dev/null +++ b/src/actions/downloads.ts @@ -0,0 +1,46 @@ +import emailValidator from 'email-validator' +import { defineAction } from 'astro:actions' +import { z } from 'astro:schema' + +type DownloadFormData = { + firstName: string + lastName: string + workEmail: string + jobTitle: string + companyName: string +} + +const inputSchema = z.object({ + firstName: z.string().trim().min(1), + lastName: z.string().trim().min(1), + workEmail: z + .string() + .trim() + .min(1) + .refine(value => emailValidator.validate(value), 'Invalid email address'), + jobTitle: z.string().trim().min(1), + companyName: z.string().trim().min(1), +}) + +export const downloads = { + submit: defineAction({ + accept: 'json', + input: inputSchema, + handler: async (input): Promise<{ success: true; message: string }> => { + const data = input as DownloadFormData + + console.log('Download form submission:', { + name: `${data.firstName} ${data.lastName}`, + email: data.workEmail, + jobTitle: data.jobTitle, + company: data.companyName, + timestamp: new Date().toISOString(), + }) + + return { + success: true, + message: 'Form submitted successfully', + } + }, + }), +} diff --git a/src/actions/gdpr.ts b/src/actions/gdpr.ts new file mode 100644 index 000000000..8ac9737a4 --- /dev/null +++ b/src/actions/gdpr.ts @@ -0,0 +1,411 @@ +import emailValidator from 'email-validator' +import { validate as uuidValidate } from 'uuid' +import { ActionError, defineAction } from 'astro:actions' +import { z } from 'astro:schema' +import { getPrivacyPolicyVersion } from '@actions/_environment/environmentActions' +import { checkRateLimit, rateLimiters } from '@actions/_utils/rateLimit' +import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/_utils/requestContext' +import type { ConsentRequest, ConsentResponse, DSARRequest, DSARRequestInput, DSARResponse } from '@actions/_contracts/gdpr.contracts' +import { + createConsentRecord, + deleteConsentRecords, + deleteConsentRecordsByEmail, + findConsentRecords, + findConsentRecordsByEmail, + type ConsentEventRecord, +} from '@actions/gdpr/_utils/consentStore' +import { + createDsarRequest, + findActiveRequestByEmail, + findDsarRequestByToken, + markDsarRequestFulfilled, +} from '@actions/gdpr/_utils/dsarStore' +import { sendDsarVerificationEmail } from '@actions/gdpr/_dsarVerificationEmails' +import { deleteNewsletterConfirmationsByEmail } from '@actions/newsletter/_token' + +const CONSENT_PURPOSES = ['contact', 'marketing', 'analytics', 'downloads'] as const +type ConsentPurpose = (typeof CONSENT_PURPOSES)[number] + +const CONSENT_SOURCES = ['contact_form', 'newsletter_form', 'download_form', 'cookies_modal', 'preferences_page'] as const +type ConsentSource = (typeof CONSENT_SOURCES)[number] + +const DEFAULT_SOURCE: ConsentSource = 'cookies_modal' +const DEFAULT_USER_AGENT = 'unknown' + +const isConsentPurpose = (value: unknown): value is ConsentPurpose => + typeof value === 'string' && CONSENT_PURPOSES.includes(value as ConsentPurpose) + +const isConsentSource = (value: unknown): value is ConsentSource => + typeof value === 'string' && CONSENT_SOURCES.includes(value as ConsentSource) + +const sanitizePurposes = (purposes: unknown): ConsentPurpose[] => (Array.isArray(purposes) ? purposes.filter(isConsentPurpose) : []) +const sanitizeSource = (source: unknown): ConsentSource => (isConsentSource(source) ? source : DEFAULT_SOURCE) + +const normalizeNullableString = (value?: string | null): string | null => { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +const normalizeUserAgent = (value?: string | null): string => normalizeNullableString(value) ?? DEFAULT_USER_AGENT + +const buildRateLimitError = (reset: number | undefined, message?: string) => { + const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 + const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) + throw new ActionError({ + code: 'TOO_MANY_REQUESTS', + message: message ?? `Try again in ${retryAfterSeconds}s`, + }) +} + +const mapConsentRecord = (record: ConsentEventRecord): ConsentResponse['record'] => { + const normalizedEmail = normalizeNullableString(record.email) + const normalizedIpAddress = normalizeNullableString(record.ipAddress) + const normalizedConsentText = normalizeNullableString(record.consentText) + + const mapped: ConsentResponse['record'] = { + id: record.id, + DataSubjectId: record.dataSubjectId, + purposes: sanitizePurposes(record.purposes), + timestamp: record.createdAt instanceof Date ? record.createdAt.toISOString() : new Date(record.createdAt).toISOString(), + source: sanitizeSource(record.source), + userAgent: normalizeUserAgent(record.userAgent), + privacyPolicyVersion: record.privacyPolicyVersion ?? getPrivacyPolicyVersion(), + verified: record.verified, + } + + if (normalizedEmail) { + mapped.email = normalizedEmail + } + if (normalizedIpAddress) { + mapped.ipAddress = normalizedIpAddress + } + if (normalizedConsentText) { + mapped.consentText = normalizedConsentText + } + + return mapped +} + +const consentCreateSchema = z.custom() +const consentListSchema = z.object({ + DataSubjectId: z.string().min(1), + purpose: z.string().optional(), +}) +const consentDeleteSchema = z.object({ + DataSubjectId: z.string().min(1), +}) + +const dsarRequestSchema = z.object({ + email: z.string().min(1), + requestType: z.enum(['ACCESS', 'DELETE']), +}) + +export type DsarVerifyResult = + | { status: 'invalid' | 'expired' | 'already-completed' | 'error' } + | { status: 'deleted' } + | { status: 'download'; filename: string; json: string } + +export async function verifyDsarToken(token: string): Promise { + const dbRequest = await findDsarRequestByToken(token) + + if (!dbRequest) { + return { status: 'invalid' } + } + + const dsarRequest: DSARRequest = { + id: dbRequest.id, + token: dbRequest.token, + email: dbRequest.email, + requestType: dbRequest.requestType as DSARRequest['requestType'], + expiresAt: dbRequest.expiresAt.toISOString(), + createdAt: dbRequest.createdAt.toISOString(), + ...(dbRequest.fulfilledAt && { fulfilledAt: dbRequest.fulfilledAt.toISOString() }), + } + + if (dsarRequest.fulfilledAt) { + return { status: 'already-completed' } + } + + if (new Date(dsarRequest.expiresAt) < new Date()) { + return { status: 'expired' } + } + + const email = dsarRequest.email + const requestType = dsarRequest.requestType + + if (requestType === 'ACCESS') { + const consentRecords = await findConsentRecordsByEmail(email) + await markDsarRequestFulfilled(token) + + const exportData = { + email, + requestDate: dsarRequest.createdAt, + consentRecords: consentRecords.map(({ ipAddress: _ip, ...record }) => ({ + ...record, + createdAt: record.createdAt instanceof Date ? record.createdAt.toISOString() : record.createdAt, + })), + } + + return { + status: 'download', + filename: `my-data-${Date.now()}.json`, + json: JSON.stringify(exportData, null, 2), + } + } + + if (requestType === 'DELETE') { + await deleteConsentRecordsByEmail(email) + await deleteNewsletterConfirmationsByEmail(email) + await markDsarRequestFulfilled(token) + return { status: 'deleted' } + } + + return { status: 'error' } +} + +export const gdpr = { + verifyDsar: defineAction({ + accept: 'json', + input: z.object({ token: z.string().min(1) }), + handler: async (input, context): Promise => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/verifyDsar', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:dsar:verify', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset, 'Too many requests') + } + + try { + return await verifyDsarToken(input.token) + } catch (error) { + console.error('[gdpr.verifyDsar] failed:', error) + return { status: 'error' } + } + }, + }), + + consentCreate: defineAction({ + accept: 'json', + input: consentCreateSchema, + handler: async (body, context): Promise => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/consentCreate', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:post', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.consent, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset) + } + + if (!uuidValidate(body.DataSubjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId' }) + } + + const normalizedEmail = normalizeNullableString(body.email ?? null) + const normalizedPurposes = sanitizePurposes(body.purposes) + const normalizedSource = sanitizeSource(body.source) + const normalizedUserAgent = normalizeUserAgent(body.userAgent) + const normalizedIpAddress = normalizeNullableString(body.ipAddress ?? null) + const normalizedConsentText = normalizeNullableString(body.consentText ?? null) + + const dbRecord = await createConsentRecord({ + dataSubjectId: body.DataSubjectId, + email: normalizedEmail, + purposes: normalizedPurposes, + source: normalizedSource, + userAgent: normalizedUserAgent, + ipAddress: normalizedIpAddress, + privacyPolicyVersion: getPrivacyPolicyVersion(), + consentText: normalizedConsentText, + verified: body.verified ?? false, + }) + + return { + success: true, + record: mapConsentRecord(dbRecord), + } + }, + }), + + consentList: defineAction({ + accept: 'json', + input: consentListSchema, + handler: async (input, context): Promise<{ success: true; records: ConsentResponse['record'][]; hasActive?: boolean; activeRecord?: ConsentResponse['record'] }> => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/consentList', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:get', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.consentRead, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset) + } + + const { DataSubjectId, purpose } = input + + if (!DataSubjectId || !uuidValidate(DataSubjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Valid DataSubjectId required' }) + } + + const fetched = await findConsentRecords(DataSubjectId) + const filteredRecords = purpose ? fetched.filter(record => record.purposes.includes(purpose)) : fetched + const records = filteredRecords.map(mapConsentRecord) + + const response: { + success: true + records: ConsentResponse['record'][] + hasActive?: boolean + activeRecord?: ConsentResponse['record'] + } = { + success: true, + records, + } + + if (purpose) { + response.hasActive = records.length > 0 + if (records[0]) { + response.activeRecord = records[0] + } + } + + return response + }, + }), + + consentDelete: defineAction({ + accept: 'json', + input: consentDeleteSchema, + handler: async (input, context): Promise<{ success: true; deletedCount: number }> => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/consentDelete', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:delete', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.delete, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset) + } + + if (!uuidValidate(input.DataSubjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Valid DataSubjectId required' }) + } + + const deletedCount = await deleteConsentRecords(input.DataSubjectId) + return { success: true, deletedCount } + }, + }), + + requestData: defineAction({ + accept: 'json', + input: dsarRequestSchema, + handler: async (input: DSARRequestInput, context): Promise => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/requestData', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:dsar:request', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset, 'Too many requests. Try again later.') + } + + if (!emailValidator.validate(input.email)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid email format' }) + } + + const email = input.email.toLowerCase().trim() + const token = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) + + const existing = await findActiveRequestByEmail(email, input.requestType) + + if (existing) { + await sendDsarVerificationEmail(email, existing.token, input.requestType) + return { + success: true, + message: 'Verification email sent. Please check your inbox.', + } + } + + await createDsarRequest({ + token, + email, + requestType: input.requestType, + expiresAt, + }) + + await sendDsarVerificationEmail(email, token, input.requestType) + + return { + success: true, + message: + 'Verification email sent. Please check your inbox and click the link to complete your request.', + } + }, + }), + + exportByDataSubjectId: defineAction({ + accept: 'json', + input: z.object({ DataSubjectId: z.string().min(1) }), + handler: async (input, context): Promise<{ success: true; json: string; filename: string }> => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/gdpr/exportByDataSubjectId', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('gdpr:export:get', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) + if (!success) { + buildRateLimitError(reset) + } + + if (!uuidValidate(input.DataSubjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId' }) + } + + const consentRecords = await findConsentRecords(input.DataSubjectId) + const exportData = consentRecords.map(record => ({ + id: record.id, + 'data_subject_id': record.dataSubjectId, + email: record.email, + purposes: record.purposes, + source: record.source, + 'user_agent': record.userAgent, + 'privacy_policy_version': record.privacyPolicyVersion, + 'consent_text': record.consentText, + verified: record.verified, + 'created_at': record.createdAt.toISOString(), + })) + + return { + success: true, + filename: `my-data-${Date.now()}.json`, + json: JSON.stringify(exportData, null, 2), + } + }, + }), +} diff --git a/src/actions/gdpr/_dsarVerificationEmails.ts b/src/actions/gdpr/_dsarVerificationEmails.ts new file mode 100644 index 000000000..266b6609c --- /dev/null +++ b/src/actions/gdpr/_dsarVerificationEmails.ts @@ -0,0 +1,97 @@ +import { Resend } from 'resend' +import { dsarVerificationEmailHtml } from '@content/email/dsar.html' +import { dsarVerificationEmailText } from '@content/email/dsar.text' +import { getResendApiKey, isDev, isTest } from '@actions/_environment/environmentActions' +import { getSiteUrl } from '@actions/_environment/siteUrlActions' +import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError' + +export async function sendDsarVerificationEmail( + email: string, + token: string, + requestType: 'ACCESS' | 'DELETE', +): Promise { + if (isDev() || isTest()) { + console.log('[DEV/TEST MODE] DSAR verification email would be sent:', { email, token, requestType }) + return + } + + let resend: Resend + try { + resend = new Resend(getResendApiKey()) + } catch (error) { + const message = '[DSAR Email] Failed to initialize Resend client' + console.error(message, error) + throw new ActionsFunctionError({ + message, + cause: error, + code: 'DSAR_EMAIL_INIT_FAILED', + status: 500, + route: 'actions:gdpr', + operation: 'sendDsarVerificationEmail', + }) + } + + const verifyUrl = `${getSiteUrl()}/privacy/my-data?token=${token}` + const expiresIn = '24 hours' + const actionText = requestType === 'ACCESS' ? 'access your data' : 'delete your data' + const subject = requestType === 'ACCESS' ? 'Verify Your Data Access Request' : 'Verify Your Data Deletion Request' + + const html = dsarVerificationEmailHtml({ + subject, + requestType, + actionText, + verifyUrl, + expiresIn, + }) + + const text = dsarVerificationEmailText({ + requestType, + actionText, + verifyUrl, + expiresIn, + }) + + try { + const result = await resend.emails.send({ + from: 'Webstack Builders ', + to: email, + subject: `${subject} - Webstack Builders`, + html, + text, + tags: [ + { name: 'type', value: 'gdpr-verification' }, + { name: 'request-type', value: requestType.toLowerCase() }, + ], + }) + + if (result.error) { + const message = '[DSAR Email] Failed to send verification' + console.error(message, result.error) + throw new ActionsFunctionError({ + message, + cause: result.error, + code: 'DSAR_EMAIL_SEND_FAILED', + status: 502, + route: 'actions:gdpr', + operation: 'sendDsarVerificationEmail', + }) + } + + console.log('[DSAR Email] Verification sent successfully:', { + email, + requestType, + messageId: result.data?.id, + }) + } catch (error) { + const message = '[DSAR Email] Error sending verification' + console.error(message, error) + throw new ActionsFunctionError({ + message, + cause: error, + code: 'DSAR_EMAIL_SEND_FAILED', + status: 502, + route: 'actions:gdpr', + operation: 'sendDsarVerificationEmail', + }) + } +} diff --git a/src/actions/gdpr/_utils/consentStore.ts b/src/actions/gdpr/_utils/consentStore.ts new file mode 100644 index 000000000..2cf825adf --- /dev/null +++ b/src/actions/gdpr/_utils/consentStore.ts @@ -0,0 +1,101 @@ +import { randomUUID } from 'node:crypto' +import { and, consentEvents, db, desc, eq } from 'astro:db' + +type DbConsentRecord = typeof consentEvents.$inferSelect + +export type ConsentEventRecord = Omit & { + purposes: string[] +} + +const toConsentRecord = (record: DbConsentRecord): ConsentEventRecord => ({ + ...record, + purposes: record.purposes as string[], +}) + +export type CreateConsentRecordInput = { + dataSubjectId: string + email: string | null + purposes: string[] + source: string + userAgent: string + ipAddress: string | null + privacyPolicyVersion: string + consentText: string | null + verified: boolean +} + +export async function createConsentRecord(input: CreateConsentRecordInput): Promise { + const [record] = await db + .insert(consentEvents) + .values({ + id: randomUUID(), + ...input, + createdAt: new Date(), + }) + .returning() + + if (!record) { + throw new Error('Failed to create consent record') + } + + return toConsentRecord(record) +} + +export async function findConsentRecords(dataSubjectId: string): Promise { + const records = await db + .select() + .from(consentEvents) + .where(eq(consentEvents.dataSubjectId, dataSubjectId)) + .orderBy(desc(consentEvents.createdAt)) + + return records.map(toConsentRecord) +} + +export async function deleteConsentRecords(dataSubjectId: string): Promise { + const deleted = await db + .delete(consentEvents) + .where(eq(consentEvents.dataSubjectId, dataSubjectId)) + .returning({ id: consentEvents.id }) + + return deleted.length +} + +const normalizeEmail = (email: string): string => email.trim().toLowerCase() + +export async function findConsentRecordsByEmail(email: string): Promise { + const normalizedEmail = normalizeEmail(email) + const records = await db + .select() + .from(consentEvents) + .where(eq(consentEvents.email, normalizedEmail)) + .orderBy(desc(consentEvents.createdAt)) + + return records.map(toConsentRecord) +} + +export async function deleteConsentRecordsByEmail(email: string): Promise { + const normalizedEmail = normalizeEmail(email) + const deleted = await db + .delete(consentEvents) + .where(eq(consentEvents.email, normalizedEmail)) + .returning({ id: consentEvents.id }) + + return deleted.length +} + +export async function markConsentRecordsVerified(email: string, dataSubjectId: string): Promise { + const normalizedEmail = normalizeEmail(email) + const updated = await db + .update(consentEvents) + .set({ verified: true }) + .where( + and( + eq(consentEvents.email, normalizedEmail), + eq(consentEvents.dataSubjectId, dataSubjectId), + eq(consentEvents.verified, false), + ), + ) + .returning({ id: consentEvents.id }) + + return updated.length +} diff --git a/src/actions/gdpr/_utils/dsarStore.ts b/src/actions/gdpr/_utils/dsarStore.ts new file mode 100644 index 000000000..4c66f2207 --- /dev/null +++ b/src/actions/gdpr/_utils/dsarStore.ts @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto' +import { and, db, dsarRequests, eq, gt, isNull } from 'astro:db' +import type { DSARRequestInput } from '@actions/_contracts/gdpr.contracts' + +export type DsarRequestRecord = typeof dsarRequests.$inferSelect + +type RequestType = DSARRequestInput['requestType'] + +export type CreateDsarRequestInput = { + token: string + email: string + requestType: RequestType + expiresAt: Date +} + +export async function findActiveRequestByEmail( + email: string, + requestType: RequestType, +): Promise { + const [record] = await db + .select() + .from(dsarRequests) + .where( + and( + eq(dsarRequests.email, email), + eq(dsarRequests.requestType, requestType), + isNull(dsarRequests.fulfilledAt), + gt(dsarRequests.expiresAt, new Date()), + ), + ) + .limit(1) + + return record +} + +export async function createDsarRequest(input: CreateDsarRequestInput): Promise { + const [record] = await db + .insert(dsarRequests) + .values({ + id: randomUUID(), + token: input.token, + email: input.email, + requestType: input.requestType, + expiresAt: input.expiresAt, + createdAt: new Date(), + }) + .returning() + + if (!record) { + throw new Error('Failed to create DSAR request') + } + + return record +} + +export async function findDsarRequestByToken(token: string): Promise { + const [record] = await db.select().from(dsarRequests).where(eq(dsarRequests.token, token)).limit(1) + return record +} + +export async function markDsarRequestFulfilled(token: string): Promise { + await db.update(dsarRequests).set({ fulfilledAt: new Date() }).where(eq(dsarRequests.token, token)) +} diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 000000000..22b83e513 --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1,11 @@ +import { contact } from './contact' +import { downloads } from './downloads' +import { gdpr } from './gdpr' +import { newsletter } from './newsletter' + +export const server = { + contact, + downloads, + gdpr, + newsletter, +} diff --git a/src/actions/newsletter.ts b/src/actions/newsletter.ts new file mode 100644 index 000000000..b51bb4b41 --- /dev/null +++ b/src/actions/newsletter.ts @@ -0,0 +1,237 @@ +import emailValidator from 'email-validator' +import { v4 as uuidv4, validate as uuidValidate } from 'uuid' +import { ActionError, defineAction } from 'astro:actions' +import { z } from 'astro:schema' +import { getConvertkitApiKey, getPrivacyPolicyVersion, isDev, isTest } from '@actions/_environment/environmentActions' +import { checkRateLimit, rateLimiters } from '@actions/_utils/rateLimit' +import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/_utils/requestContext' +import { createConsentRecord, markConsentRecordsVerified } from '@actions/gdpr/_utils/consentStore' +import { createPendingSubscription, confirmSubscription } from '@actions/newsletter/_token' +import { sendConfirmationEmail, sendWelcomeEmail } from '@actions/newsletter/_email' + +type NewsletterFormData = { + email: string + firstName?: string + consentGiven?: boolean + DataSubjectId?: string +} + +type ConvertKitSubscriber = { + 'email_address': string + 'first_name'?: string + state?: 'active' | 'inactive' + fields?: Record +} + +type ConvertKitResponse = { + subscriber: { + id: number + 'first_name': string | null + 'email_address': string + state: string + 'created_at': string + fields: Record + } +} + +type ConvertKitErrorResponse = { + errors: string[] +} + +function validateEmail(email: string): string { + if (!email) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Email address is required.' }) + } + + if (email.length > 254) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Email address is too long' }) + } + + if (!emailValidator.validate(email)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Email address is invalid' }) + } + + return email.trim().toLowerCase() +} + +export async function subscribeToConvertKit(data: NewsletterFormData): Promise { + if (isDev() || isTest()) { + console.log('[DEV/TEST MODE] Newsletter subscription would be created:', { email: data.email }) + return { + subscriber: { + id: 999999, + state: 'active', + 'email_address': data.email, + 'first_name': data.firstName || null, + 'created_at': new Date().toISOString(), + fields: {}, + }, + } + } + + const subscriberData: ConvertKitSubscriber = { + 'email_address': data.email, + state: 'active', + } + + if (data.firstName) { + subscriberData['first_name'] = data.firstName.trim() + } + + const response = await fetch('https://api.kit.com/v4/subscribers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Kit-Api-Key': getConvertkitApiKey(), + }, + body: JSON.stringify(subscriberData), + }) + + const responseData = await response.json() + + if (response.status === 401) { + const errorData = responseData as ConvertKitErrorResponse + console.error('ConvertKit API authentication failed:', errorData.errors) + throw new ActionError({ + code: 'BAD_GATEWAY', + message: 'Newsletter service configuration error. Please contact support.', + }) + } + + if (response.status === 422) { + const errorData = responseData as ConvertKitErrorResponse + throw new ActionError({ code: 'BAD_REQUEST', message: errorData.errors[0] || 'Invalid email address' }) + } + + if (response.status === 200 || response.status === 201 || response.status === 202) { + return responseData as ConvertKitResponse + } + + throw new ActionError({ code: 'BAD_GATEWAY', message: 'An unexpected error occurred. Please try again later.' }) +} + +const subscribeSchema = z.object({ + email: z.string(), + firstName: z.string().optional(), + consentGiven: z.boolean().optional(), + DataSubjectId: z.string().optional(), +}) + +const confirmSchema = z.object({ + token: z.string().min(1), +}) + +export const newsletter = { + subscribe: defineAction({ + accept: 'json', + input: subscribeSchema, + handler: async ( + body: z.infer, + context, + ): Promise<{ success: true; message: string; requiresConfirmation: true }> => { + const { fingerprint } = buildRequestFingerprint({ + route: '/_actions/newsletter/subscribe', + request: context.request, + cookies: context.cookies, + clientAddress: context.clientAddress, + }) + + const rateLimitIdentifier = createRateLimitIdentifier('newsletter:consent', fingerprint) + const { success, reset } = await checkRateLimit(rateLimiters.consent, rateLimitIdentifier) + + if (!success) { + const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 + const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) + throw new ActionError({ code: 'TOO_MANY_REQUESTS', message: `Try again in ${retryAfterSeconds}s` }) + } + + const validatedEmail = validateEmail(body.email) + + if (!body.consentGiven) { + throw new ActionError({ + code: 'BAD_REQUEST', + message: 'You must consent to receive marketing emails to subscribe.', + }) + } + + const userAgent = context.request.headers.get('user-agent') || 'unknown' + + let subjectId = body.DataSubjectId + if (!subjectId) { + subjectId = uuidv4() + } else if (!uuidValidate(subjectId)) { + throw new ActionError({ code: 'BAD_REQUEST', message: 'Invalid DataSubjectId format' }) + } + + await createConsentRecord({ + dataSubjectId: subjectId, + email: validatedEmail, + purposes: ['marketing'], + source: 'newsletter_form', + userAgent, + ipAddress: context.clientAddress && context.clientAddress !== 'unknown' ? context.clientAddress : null, + privacyPolicyVersion: getPrivacyPolicyVersion(), + consentText: null, + verified: false, + }) + + const token = await createPendingSubscription({ + email: validatedEmail, + ...(body.firstName && { firstName: body.firstName }), + DataSubjectId: subjectId, + userAgent, + ...(context.clientAddress && context.clientAddress !== 'unknown' && { ipAddress: context.clientAddress }), + source: 'newsletter_form', + }) + + await sendConfirmationEmail(validatedEmail, token, body.firstName) + + return { + success: true, + message: 'Please check your email to confirm your subscription.', + requiresConfirmation: true, + } + }, + }), + + confirm: defineAction({ + accept: 'json', + input: confirmSchema, + handler: async (input): Promise<{ success: boolean; status: 'success' | 'expired'; email?: string; message: string }> => { + const token = input.token + const subscription = await confirmSubscription(token) + + if (!subscription) { + return { + success: false, + status: 'expired', + message: 'This confirmation link has expired or been used already.', + } + } + + await markConsentRecordsVerified(subscription.email, subscription.DataSubjectId) + + try { + await sendWelcomeEmail(subscription.email, subscription.firstName) + } catch (emailError) { + console.error('[newsletter.confirm] welcome email failed:', emailError) + } + + try { + await subscribeToConvertKit({ + email: subscription.email, + ...(subscription.firstName ? { firstName: subscription.firstName } : {}), + }) + } catch (convertKitError) { + console.error('[newsletter.confirm] convertkit subscribe failed:', convertKitError) + } + + return { + success: true, + status: 'success', + email: subscription.email, + message: 'Your subscription has been confirmed!', + } + }, + }), +} diff --git a/src/actions/newsletter/_email.ts b/src/actions/newsletter/_email.ts new file mode 100644 index 000000000..a29a2d640 --- /dev/null +++ b/src/actions/newsletter/_email.ts @@ -0,0 +1,378 @@ +import { Resend } from 'resend' +import { getResendApiKey, isDev, isTest } from '@actions/_environment/environmentActions' +import { getSiteUrl } from '@actions/_environment/siteUrlActions' +import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError' + +function getResendClient(): Resend { + return new Resend(getResendApiKey()) +} + +function generateConfirmationEmailHtml( + firstName: string | undefined, + confirmUrl: string, + expiresIn: string = '24 hours', +): string { + const greeting = firstName ? `Hi ${firstName}` : 'Hello' + + return ` + + + + + + Confirm Your Newsletter Subscription + + + +
+ +
+ +
+

Confirm Your Subscription

+ +

${greeting},

+ +

Thank you for subscribing to the Webstack Builders newsletter! To complete your subscription and start receiving our latest articles, insights, and updates, please confirm your email address.

+ + + +

Or copy and paste this link into your browser:

+

${confirmUrl}

+ +
+

Why did I receive this?

+

You're receiving this email because someone (hopefully you!) entered this email address on our website to subscribe to our newsletter. If you didn't request this, you can safely ignore this email.

+
+ +
+

⏰ This confirmation link expires in ${expiresIn}

+

For security reasons, this confirmation link will only work once and will expire after ${expiresIn}.

+
+ +

What You're Consenting To

+
    +
  • Purpose: Receiving marketing emails and newsletters
  • +
  • Frequency: Weekly articles and occasional updates
  • +
  • Your Rights: You can unsubscribe at any time using the link in every email
  • +
  • Data Usage: We'll only use your email to send you the content you signed up for
  • +
+
+ + + + + `.trim() +} + +function generateConfirmationEmailText( + firstName: string | undefined, + confirmUrl: string, + expiresIn: string = '24 hours', +): string { + const greeting = firstName ? `Hi ${firstName}` : 'Hello' + + return ` +Webstack Builders - Confirm Your Subscription + +${greeting}, + +Thank you for subscribing to the Webstack Builders newsletter! To complete your subscription and start receiving our latest articles, insights, and updates, please confirm your email address. + +Confirm your subscription by clicking this link: +${confirmUrl} + +WHY DID I RECEIVE THIS? +You're receiving this email because someone (hopefully you!) entered this email address on our website to subscribe to our newsletter. If you didn't request this, you can safely ignore this email. + +IMPORTANT: This confirmation link expires in ${expiresIn} +For security reasons, this confirmation link will only work once and will expire after ${expiresIn}. + +WHAT YOU'RE CONSENTING TO: +- Purpose: Receiving marketing emails and newsletters +- Frequency: Weekly articles and occasional updates +- Your Rights: You can unsubscribe at any time using the link in every email +- Data Usage: We'll only use your email to send you the content you signed up for + +Questions? Contact us at hello@webstackbuilders.com +Privacy Policy: ${getSiteUrl()}/privacy +Unsubscribe: ${getSiteUrl()}/privacy#unsubscribe + +© ${new Date().getFullYear()} Webstack Builders. All rights reserved. + `.trim() +} + +export async function sendConfirmationEmail(email: string, token: string, firstName?: string): Promise { + const siteUrl = getSiteUrl() + const confirmUrl = `${siteUrl}/newsletter/confirm/${token}` + const expiresIn = '24 hours' + + if (isDev() || isTest()) { + console.log('[DEV/TEST MODE] Newsletter confirmation email would be sent:', { email, token }) + return + } + + const resendPayload = { + from: 'Webstack Builders ', + to: email, + subject: 'Confirm your newsletter subscription - Webstack Builders', + html: generateConfirmationEmailHtml(firstName, confirmUrl, expiresIn), + text: generateConfirmationEmailText(firstName, confirmUrl, expiresIn), + tags: [ + { name: 'type', value: 'newsletter-confirmation' }, + { name: 'flow', value: 'double-optin' }, + ], + } + + const resend = getResendClient() + + try { + const result = await resend.emails.send(resendPayload) + + if (result.error) { + console.error('[Newsletter Email] Failed to send confirmation:', result.error) + throw new ActionsFunctionError({ + message: `Failed to send confirmation email: ${result.error.message}`, + code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED', + status: 502, + route: 'actions:newsletter', + operation: 'sendConfirmationEmail', + }) + } + } catch (error) { + console.error('[Newsletter Email] Error sending confirmation:', error) + throw new ActionsFunctionError(error, { + message: 'Failed to send confirmation email. Please try again later.', + code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED', + status: 502, + route: 'actions:newsletter', + operation: 'sendConfirmationEmail', + }) + } +} + +export async function sendWelcomeEmail(email: string, firstName?: string): Promise { + if (isDev() || isTest()) { + console.log('[DEV/TEST MODE] Newsletter welcome email would be sent:', { email }) + return + } + + const resend = getResendClient() + const greeting = firstName ? `Hi ${firstName}` : 'Hello' + + const html = ` + + + + + + Welcome to Webstack Builders + + + +
+ +
+ +
+

🎉 Welcome to Webstack Builders!

+ +

${greeting},

+ +

Your subscription is now confirmed! Thank you for joining our community of developers, designers, and tech enthusiasts.

+ +

You'll now receive our latest articles, tutorials, and insights directly in your inbox. We're committed to delivering high-quality content that helps you build better web experiences.

+ + + +

What to Expect

+
    +
  • Weekly articles on web development, design, and best practices
  • +
  • Tutorials and guides for modern web technologies
  • +
  • Case studies and real-world examples
  • +
  • Occasional updates about new features and offerings
  • +
+ +

Need to manage your subscription? You can unsubscribe at any time using the link at the bottom of any email we send you.

+

If you'd like to unsubscribe right now, click here.

+
+ + + + + `.trim() + + const text = ` +Webstack Builders - Welcome! + +${greeting}, + +Your subscription is now confirmed! Thank you for joining our community of developers, designers, and tech enthusiasts. + +You'll now receive our latest articles, tutorials, and insights directly in your inbox. We're committed to delivering high-quality content that helps you build better web experiences. + +Browse our articles: ${getSiteUrl()}/articles + +WHAT TO EXPECT: +- Weekly articles on web development, design, and best practices +- Tutorials and guides for modern web technologies +- Case studies and real-world examples +- Occasional updates about new features and offerings + +Need to manage your subscription? You can unsubscribe at any time using the link at the bottom of any email we send you. +Unsubscribe: ${getSiteUrl()}/privacy#unsubscribe + +Questions? Reply to this email or contact us at hello@webstackbuilders.com + +© ${new Date().getFullYear()} Webstack Builders. All rights reserved. + `.trim() + + const resendPayload = { + from: 'Webstack Builders ', + to: email, + subject: '🎉 Welcome to Webstack Builders!', + html, + text, + tags: [ + { name: 'type', value: 'newsletter-welcome' }, + { name: 'flow', value: 'post-confirmation' }, + ], + } + + try { + const result = await resend.emails.send(resendPayload) + + if (result.error) { + console.error('[Newsletter Email] Failed to send welcome email:', result.error) + throw new ActionsFunctionError({ + message: `Failed to send welcome email: ${result.error.message}`, + code: 'NEWSLETTER_WELCOME_EMAIL_FAILED', + status: 502, + route: 'actions:newsletter', + operation: 'sendWelcomeEmail', + }) + } + } catch (error) { + console.error('[Newsletter Email] Error sending welcome email:', error) + throw new ActionsFunctionError(error, { + message: 'Failed to send welcome email. Please try again later.', + code: 'NEWSLETTER_WELCOME_EMAIL_FAILED', + status: 502, + route: 'actions:newsletter', + operation: 'sendWelcomeEmail', + }) + } +} diff --git a/src/actions/newsletter/_token.ts b/src/actions/newsletter/_token.ts new file mode 100644 index 000000000..4e2cbec06 --- /dev/null +++ b/src/actions/newsletter/_token.ts @@ -0,0 +1,172 @@ +import { randomUUID } from 'node:crypto' +import { and, db, eq, isNull, newsletterConfirmations } from 'astro:db' +import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError' + +export interface PendingSubscription { + email: string + firstName?: string | undefined + DataSubjectId: string + token: string + createdAt: string + expiresAt: string + consentTimestamp: string + userAgent: string + ipAddress?: string | undefined + verified: boolean + source: 'newsletter_form' | 'contact_form' +} + +const pendingSubscriptions = new Map() + +export function generateConfirmationToken(): string { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return Buffer.from(array) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') +} + +export async function createPendingSubscription(data: { + email: string + firstName?: string + DataSubjectId: string + userAgent: string + ipAddress?: string + source: 'newsletter_form' | 'contact_form' +}): Promise { + const token = generateConfirmationToken() + const now = new Date() + const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000) + + const pending: PendingSubscription = { + email: data.email.toLowerCase().trim(), + ...(data.firstName && { firstName: data.firstName.trim() }), + DataSubjectId: data.DataSubjectId, + token, + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + consentTimestamp: now.toISOString(), + userAgent: data.userAgent, + ...(data.ipAddress && { ipAddress: data.ipAddress }), + verified: false, + source: data.source, + } + + try { + await db.insert(newsletterConfirmations).values({ + id: randomUUID(), + token, + email: pending.email, + dataSubjectId: pending.DataSubjectId, + firstName: pending.firstName ?? null, + source: pending.source, + userAgent: pending.userAgent, + ipAddress: pending.ipAddress ?? null, + consentTimestamp: new Date(pending.consentTimestamp), + expiresAt, + confirmedAt: null, + createdAt: now, + }) + } catch (error) { + throw new ActionsFunctionError({ + message: 'Failed to create subscription confirmation', + cause: error, + code: 'NEWSLETTER_TOKEN_CREATE_FAILED', + status: 500, + route: 'actions:newsletter', + operation: 'createPendingSubscription', + }) + } + + pendingSubscriptions.set(token, pending) + cleanExpiredTokens() + return token +} + +export async function validateToken(token: string): Promise { + const [dbRecord] = await db + .select() + .from(newsletterConfirmations) + .where(and(eq(newsletterConfirmations.token, token), isNull(newsletterConfirmations.confirmedAt))) + .limit(1) + + if (dbRecord) { + const now = new Date() + const expiresAt = new Date(dbRecord.expiresAt) + if (now > expiresAt) { + return null + } + + return { + email: dbRecord.email, + firstName: dbRecord.firstName ?? undefined, + DataSubjectId: dbRecord.dataSubjectId, + token: dbRecord.token, + createdAt: dbRecord.createdAt.toISOString(), + expiresAt: dbRecord.expiresAt.toISOString(), + consentTimestamp: dbRecord.consentTimestamp.toISOString(), + userAgent: dbRecord.userAgent ?? 'unknown', + ipAddress: dbRecord.ipAddress ?? undefined, + verified: false, + source: dbRecord.source as PendingSubscription['source'], + } + } + + const pending = pendingSubscriptions.get(token) + if (!pending) { + return null + } + + const now = new Date() + const expiresAt = new Date(pending.expiresAt) + + if (now > expiresAt) { + pendingSubscriptions.delete(token) + return null + } + + if (pending.verified) { + return null + } + + return pending +} + +export async function confirmSubscription(token: string): Promise { + const pending = await validateToken(token) + if (!pending) { + return null + } + + await db.update(newsletterConfirmations).set({ confirmedAt: new Date() }).where(eq(newsletterConfirmations.token, token)) + + pending.verified = true + pendingSubscriptions.delete(token) + return pending +} + +function cleanExpiredTokens(): void { + const now = new Date() + for (const [token, pending] of pendingSubscriptions.entries()) { + const expiresAt = new Date(pending.expiresAt) + if (now > expiresAt) { + pendingSubscriptions.delete(token) + } + } +} + +export function getPendingCount(): number { + return pendingSubscriptions.size +} + +export async function deleteNewsletterConfirmationsByEmail(email: string): Promise { + const normalizedEmail = email.trim().toLowerCase() + const deleted = await db + .delete(newsletterConfirmations) + .where(eq(newsletterConfirmations.email, normalizedEmail)) + .returning({ id: newsletterConfirmations.id }) + + return deleted.length +} diff --git a/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts b/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts index a985589ba..399269e03 100644 --- a/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts +++ b/src/components/CallToAction/Newsletter/client/__tests__/index.spec.ts @@ -7,6 +7,16 @@ import type { NewsletterFormElement } from '@components/CallToAction/Newsletter/ import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' import { executeRender } from '@test/unit/helpers/litRuntime' +const newsletterSubscribeMock = vi.fn() + +vi.mock('astro:actions', () => ({ + actions: { + newsletter: { + subscribe: newsletterSubscribeMock, + }, + }, +})) + type NewsletterComponentModule = WebComponentModule const flushPromises = async () => { @@ -45,17 +55,13 @@ const getElements = (root: NewsletterFormElement) => { describe('NewsletterFormElement web component', () => { let container: AstroContainer - let fetchMock: ReturnType - const originalFetch = globalThis.fetch beforeEach(async () => { - fetchMock = vi.fn() - globalThis.fetch = fetchMock as unknown as typeof fetch + newsletterSubscribeMock.mockReset() container = await AstroContainer.create() }) afterEach(() => { - globalThis.fetch = originalFetch vi.restoreAllMocks() }) @@ -128,9 +134,8 @@ describe('NewsletterFormElement web component', () => { }) test('submits to the newsletter API and shows success feedback', async () => { - fetchMock.mockResolvedValueOnce({ - ok: true, - json: () => Promise.resolve({ success: true, message: 'Subscribed successfully!' }), + newsletterSubscribeMock.mockResolvedValueOnce({ + data: { success: true, message: 'Subscribed successfully!' }, }) await renderNewsletter(async ({ elements }) => { @@ -143,13 +148,7 @@ describe('NewsletterFormElement web component', () => { elements.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await flushPromises() - expect(fetchMock).toHaveBeenCalledWith('/api/newsletter', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ email: 'test@example.com', consentGiven: true }), - }) + expect(newsletterSubscribeMock).toHaveBeenCalledWith({ email: 'test@example.com', consentGiven: true }) expect(elements.message.textContent).toBe('Subscribed successfully!') expect(elements.message.getAttribute('role')).toBe('status') expect(elements.message.getAttribute('aria-live')).toBe('polite') @@ -162,9 +161,8 @@ describe('NewsletterFormElement web component', () => { }) test('handles API error responses gracefully', async () => { - fetchMock.mockResolvedValueOnce({ - ok: false, - json: () => Promise.resolve({ success: false, error: 'Subscription failed' }), + newsletterSubscribeMock.mockResolvedValueOnce({ + error: { message: 'Subscription failed' }, }) await renderNewsletter(async ({ elements }) => { @@ -174,13 +172,13 @@ describe('NewsletterFormElement web component', () => { elements.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await flushPromises() - expect(fetchMock).toHaveBeenCalled() + expect(newsletterSubscribeMock).toHaveBeenCalled() expect(elements.message.textContent).toBe('Subscription failed') }) }) test('shows a network error message when fetch rejects', async () => { - fetchMock.mockRejectedValueOnce(new TestError('Network error')) + newsletterSubscribeMock.mockRejectedValueOnce(new TestError('Network error')) await renderNewsletter(async ({ elements }) => { elements.emailInput.value = 'test@example.com' @@ -189,7 +187,7 @@ describe('NewsletterFormElement web component', () => { elements.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await flushPromises() - expect(fetchMock).toHaveBeenCalled() + expect(newsletterSubscribeMock).toHaveBeenCalled() expect(elements.message.textContent).toBe('Network error. Please check your connection and try again.') }) }) diff --git a/src/components/CallToAction/Newsletter/client/index.ts b/src/components/CallToAction/Newsletter/client/index.ts index 52382dd71..12f48ae6d 100644 --- a/src/components/CallToAction/Newsletter/client/index.ts +++ b/src/components/CallToAction/Newsletter/client/index.ts @@ -6,6 +6,7 @@ import { LitElement } from 'lit' import emailValidator from 'email-validator' +import { actions } from 'astro:actions' import { addScriptBreadcrumb, ClientScriptError } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' import { getNewsletterElements } from './selectors' @@ -256,22 +257,14 @@ export class NewsletterFormElement extends LitElement { this.showMessage('Sending confirmation email...', 'info') try { - const response = await fetch('/api/newsletter', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email, - consentGiven, - }), + const result = await actions.newsletter.subscribe({ + email, + consentGiven, }) - const data = await response.json() - - if (response.ok && data.success) { + if (result.data?.success) { this.showMessage( - data.message || 'Check your email! Click the confirmation link to complete your subscription.', + result.data.message || 'Check your email! Click the confirmation link to complete your subscription.', 'success' ) this.submitButton.dispatchEvent(new CustomEvent('confetti:fire', { bubbles: true, composed: true })) @@ -279,7 +272,7 @@ export class NewsletterFormElement extends LitElement { this.setFieldInvalid(this.emailInput, false) this.setFieldInvalid(this.consentCheckbox, false) } else { - this.showMessage(data.error || 'Failed to subscribe. Please try again.', 'error') + this.showMessage(result.error?.message || 'Failed to subscribe. Please try again.', 'error') } } catch (error) { handleScriptError(error, { scriptName: 'NewsletterFormElement', operation: 'apiSubmission' }) diff --git a/src/components/Forms/Contact/client/@types/index.ts b/src/components/Forms/Contact/client/@types/index.ts index 6d8a3f011..0b9d886bc 100644 --- a/src/components/Forms/Contact/client/@types/index.ts +++ b/src/components/Forms/Contact/client/@types/index.ts @@ -34,5 +34,4 @@ export interface ContactFormConfig { maxCharacters: number warningThreshold: number errorThreshold: number - apiEndpoint: string } diff --git a/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts b/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts index 86ab31201..1b73aa50f 100644 --- a/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts +++ b/src/components/Forms/Contact/client/__tests__/formSubmission.spec.ts @@ -1,6 +1,16 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { TestError } from '@test/errors' -import { renderContactForm, type RenderContactFormContext } from './testUtils' +import type { RenderContactFormContext } from './testUtils' + +const contactSubmitMock = vi.fn() + +vi.mock('astro:actions', () => ({ + actions: { + contact: { + submit: contactSubmitMock, + }, + }, +})) vi.mock('@components/scripts/errors', () => ({ addScriptBreadcrumb: vi.fn(), @@ -12,9 +22,16 @@ vi.mock('@components/scripts/errors/handler', () => ({ const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)) +let renderContactForm: typeof import('./testUtils').renderContactForm + +beforeAll(async () => { + ;({ renderContactForm } = await import('./testUtils')) +}) + describe('ContactForm submission', () => { beforeEach(() => { vi.clearAllMocks() + contactSubmitMock.mockReset() }) const fillValidFields = (context: RenderContactFormContext): void => { @@ -33,17 +50,13 @@ describe('ContactForm submission', () => { it('shows error banner and skips request when validations fail', async () => { await renderContactForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch') - const submitEvent = new window.Event('submit', { bubbles: true, cancelable: true }) elements.form.dispatchEvent(submitEvent) await flushPromises() - expect(fetchSpy).not.toHaveBeenCalled() + expect(contactSubmitMock).not.toHaveBeenCalled() expect(elements.formErrorBanner.classList.contains('hidden')).toBe(false) - - fetchSpy.mockRestore() }) }) @@ -51,10 +64,9 @@ describe('ContactForm submission', () => { await renderContactForm(async context => { fillValidFields(context) - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - json: async () => ({ success: true }), - } as Response) + contactSubmitMock.mockResolvedValue({ + data: { success: true, message: 'Thank you for your message. We will get back to you soon!' }, + }) let confettiEvent: Event | undefined context.elements.submitBtn.addEventListener('confetti:fire', (event) => { @@ -66,11 +78,8 @@ describe('ContactForm submission', () => { await flushPromises() - expect(fetchSpy).toHaveBeenCalledTimes(1) - expect(fetchSpy).toHaveBeenCalledWith( - '/api/contact', - expect.objectContaining({ method: 'POST' }), - ) + expect(contactSubmitMock).toHaveBeenCalledTimes(1) + expect(contactSubmitMock.mock.calls[0]?.[0]).toBeInstanceOf(FormData) expect(context.elements.messages.style.display).toBe('block') expect(context.elements.successMessage.classList.contains('hidden')).toBe(false) expect(context.elements.errorMessage.classList.contains('hidden')).toBe(true) @@ -87,8 +96,6 @@ describe('ContactForm submission', () => { expect(confettiEvent?.target).toBe(context.elements.submitBtn) expect(confettiEvent?.bubbles).toBe(true) expect((confettiEvent as CustomEvent)?.composed).toBe(true) - - fetchSpy.mockRestore() }) }) @@ -97,17 +104,14 @@ describe('ContactForm submission', () => { fillValidFields(context) context.elements.charCount.textContent = '42' - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: false, - json: async () => ({ success: false, message: 'Server error' }), - } as Response) + contactSubmitMock.mockResolvedValue({ error: { message: 'Server error' } }) const submitEvent = new context.window.Event('submit', { bubbles: true, cancelable: true }) context.elements.form.dispatchEvent(submitEvent) await flushPromises() - expect(fetchSpy).toHaveBeenCalled() + expect(contactSubmitMock).toHaveBeenCalled() expect(context.elements.messages.style.display).toBe('block') expect(context.elements.errorMessage.classList.contains('hidden')).toBe(false) expect(context.elements.errorMessage.style.display).toBe('flex') @@ -115,8 +119,6 @@ describe('ContactForm submission', () => { expect(context.elements.successMessage.classList.contains('hidden')).toBe(true) expect(context.elements.charCount.textContent).toBe('42') expect(context.elements.submitBtn.disabled).toBe(false) - - fetchSpy.mockRestore() }) }) }) diff --git a/src/components/Forms/Contact/client/__tests__/utils.spec.ts b/src/components/Forms/Contact/client/__tests__/utils.spec.ts index 782254604..b609d469d 100644 --- a/src/components/Forms/Contact/client/__tests__/utils.spec.ts +++ b/src/components/Forms/Contact/client/__tests__/utils.spec.ts @@ -8,7 +8,6 @@ const baseConfig: ContactFormConfig = { maxCharacters: 2000, warningThreshold: 1500, errorThreshold: 1800, - apiEndpoint: '/api/contact', } describe('ContactForm utils', () => { diff --git a/src/components/Forms/Contact/client/formSubmission.ts b/src/components/Forms/Contact/client/formSubmission.ts index 865fc1486..463ac8b4d 100644 --- a/src/components/Forms/Contact/client/formSubmission.ts +++ b/src/components/Forms/Contact/client/formSubmission.ts @@ -2,8 +2,9 @@ import { addScriptBreadcrumb } from '@components/scripts/errors' import { handleScriptError } from '@components/scripts/errors/handler' import { hideErrorBanner, showErrorBanner, clearFieldFeedback, type LabelController } from './feedback' import { validateGenericFields, validateNameField, validateMessageField } from './validation' -import type { ContactFormConfig, ContactFormElements } from './@types' +import type { ContactFormElements } from './@types' import { validateEmailField } from './email' +import { actions } from 'astro:actions' interface SubmissionControllers { labelController: LabelController @@ -62,7 +63,6 @@ const resetFormState = (elements: ContactFormElements, controllers: SubmissionCo export const initFormSubmission = ( elements: ContactFormElements, - config: ContactFormConfig, controllers: SubmissionControllers, ): void => { const context = { scriptName: 'ContactFormElement', operation: 'handleFormSubmission' } @@ -85,14 +85,9 @@ export const initFormSubmission = ( try { const formData = new FormData(elements.form) - const response = await fetch(config.apiEndpoint, { - method: 'POST', - body: formData, - }) + const result = await actions.contact.submit(formData) - const result = await response.json() - - if (response.ok && result.success) { + if (result.data?.success) { showSuccessMessage(elements) elements.submitBtn.dispatchEvent( @@ -104,7 +99,10 @@ export const initFormSubmission = ( resetFormState(elements, controllers) } else { - showErrorMessage(elements, result.message || 'An error occurred while sending your message.') + showErrorMessage( + elements, + result.error?.message || result.data?.message || 'An error occurred while sending your message.', + ) } } catch (error) { handleScriptError(error, context) diff --git a/src/components/Forms/Contact/client/index.ts b/src/components/Forms/Contact/client/index.ts index 7ad610fb9..1bd88b579 100644 --- a/src/components/Forms/Contact/client/index.ts +++ b/src/components/Forms/Contact/client/index.ts @@ -28,7 +28,6 @@ export class ContactFormElement extends LitElement { maxCharacters: 2000, warningThreshold: 1500, errorThreshold: 1800, - apiEndpoint: '/api/contact', } override createRenderRoot() { @@ -58,7 +57,7 @@ export class ContactFormElement extends LitElement { initNameLengthHandler(elements.fields.name) initMssgLengthHandler(elements.fields.message) initGenericValidation(elements.form) - initFormSubmission(elements, this.config, { + initFormSubmission(elements, { labelController: this.labelController, }) this.setViewTransitionsHandlers() diff --git a/src/components/Forms/Download/client/__tests__/index.spec.ts b/src/components/Forms/Download/client/__tests__/index.spec.ts index faaf0db39..36ec63d26 100644 --- a/src/components/Forms/Download/client/__tests__/index.spec.ts +++ b/src/components/Forms/Download/client/__tests__/index.spec.ts @@ -1,5 +1,15 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { renderDownloadForm, type DownloadFormElements } from './testUtils' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { DownloadFormElements } from './testUtils' + +const downloadsSubmitMock = vi.fn() + +vi.mock('astro:actions', () => ({ + actions: { + downloads: { + submit: downloadsSubmitMock, + }, + }, +})) // Mock the logger to suppress error output in tests vi.mock('@lib/logger', () => ({ @@ -12,6 +22,12 @@ vi.mock('@lib/logger', () => ({ })) const flushPromises = () => new Promise(resolve => setTimeout(resolve, 0)) +let renderDownloadForm: typeof import('./testUtils').renderDownloadForm + +beforeAll(async () => { + ;({ renderDownloadForm } = await import('./testUtils')) +}) + const defaultFormValues = { firstName: 'Jane', lastName: 'Doe', @@ -38,21 +54,14 @@ const submitForm = (window: Window & typeof globalThis, form: HTMLFormElement) = form.dispatchEvent(submitEvent) } -const successfulResponse = (): Response => - ({ - ok: true, - json: async () => ({ success: true }), - } as Response) - describe('download-form web component', () => { afterEach(() => { vi.restoreAllMocks() + downloadsSubmitMock.mockReset() }) it('does not submit when native form validation fails', async () => { await renderDownloadForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulResponse()) - vi.spyOn(elements.form, 'checkValidity').mockReturnValue(false) vi.spyOn(elements.form, 'reportValidity').mockReturnValue(false) @@ -60,44 +69,31 @@ describe('download-form web component', () => { submitForm(window, elements.form) await flushPromises() - expect(fetchSpy).not.toHaveBeenCalled() + expect(downloadsSubmitMock).not.toHaveBeenCalled() expect(elements.firstName.getAttribute('aria-invalid')).toBe('true') expect(elements.statusDiv.classList.contains('hidden')).toBe(false) expect(elements.statusDiv.classList.contains('error')).toBe(true) expect(elements.statusDiv.getAttribute('role')).toBe('alert') expect(elements.statusDiv.getAttribute('aria-live')).toBe('assertive') - - fetchSpy.mockRestore() }) }) - it('submits download requests via fetch', async () => { + it('submits download requests via actions', async () => { await renderDownloadForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulResponse()) + downloadsSubmitMock.mockResolvedValue({ data: { success: true } }) const payload = fillDownloadForm(elements) submitForm(window, elements.form) await flushPromises() - expect(fetchSpy).toHaveBeenCalledWith( - '/api/downloads/submit', - expect.objectContaining({ - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }), - ) - - fetchSpy.mockRestore() + expect(downloadsSubmitMock).toHaveBeenCalledWith(payload) }) }) it('shows success message and reveals download button', async () => { await renderDownloadForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulResponse()) + downloadsSubmitMock.mockResolvedValue({ data: { success: true } }) fillDownloadForm(elements) submitForm(window, elements.form) @@ -113,14 +109,12 @@ describe('download-form web component', () => { expect(elements.firstName.value).toBe('') expect(elements.lastName.value).toBe('') expect(elements.workEmail.value).toBe('') - - fetchSpy.mockRestore() }) }) it('dispatches a confetti:fire event from the submit button on success', async () => { await renderDownloadForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(successfulResponse()) + downloadsSubmitMock.mockResolvedValue({ data: { success: true } }) fillDownloadForm(elements) let confettiEvent: Event | undefined @@ -135,23 +129,18 @@ describe('download-form web component', () => { expect(confettiEvent?.target).toBe(elements.submitButton) expect(confettiEvent?.bubbles).toBe(true) expect((confettiEvent as CustomEvent)?.composed).toBe(true) - - fetchSpy.mockRestore() }) }) it('displays error state when API fails', async () => { await renderDownloadForm(async ({ elements, window }) => { - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: false, - json: async () => ({ message: 'Server error' }), - } as Response) + downloadsSubmitMock.mockResolvedValue({ error: { message: 'Server error' } }) fillDownloadForm(elements) submitForm(window, elements.form) await flushPromises() - expect(fetchSpy).toHaveBeenCalled() + expect(downloadsSubmitMock).toHaveBeenCalled() expect(elements.statusDiv.classList.contains('hidden')).toBe(false) expect(elements.statusDiv.classList.contains('error')).toBe(true) expect(elements.statusDiv.textContent).toContain('There was an error processing your request') @@ -159,18 +148,16 @@ describe('download-form web component', () => { expect(elements.statusDiv.getAttribute('aria-live')).toBe('assertive') expect(elements.downloadButtonWrapper.classList.contains('hidden')).toBe(true) expect(elements.submitButton.classList.contains('hidden')).toBe(false) - - fetchSpy.mockRestore() }) }) it('disables submit button while request is pending', async () => { await renderDownloadForm(async ({ elements, window }) => { - let resolveFetch: (() => void) | undefined - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( + let resolveSubmit: (() => void) | undefined + downloadsSubmitMock.mockImplementation( () => new Promise(resolve => { - resolveFetch = () => resolve(successfulResponse()) + resolveSubmit = () => resolve({ data: { success: true } }) }), ) @@ -180,14 +167,12 @@ describe('download-form web component', () => { expect(elements.submitButton.disabled).toBe(true) expect(elements.submitButton.textContent).toBe('Processing...') - resolveFetch?.() + resolveSubmit?.() await flushPromises() - expect(fetchSpy).toHaveBeenCalled() + expect(downloadsSubmitMock).toHaveBeenCalled() expect(elements.submitButton.disabled).toBe(false) expect(elements.submitButton.textContent).toBe('Download Now') - - fetchSpy.mockRestore() }) }) }) diff --git a/src/components/Forms/Download/client/index.ts b/src/components/Forms/Download/client/index.ts index 7d634af4e..911428793 100644 --- a/src/components/Forms/Download/client/index.ts +++ b/src/components/Forms/Download/client/index.ts @@ -1,4 +1,5 @@ import { LitElement } from 'lit' +import { actions } from 'astro:actions' import { getDownloadButtonWrapper, getDownloadFormElement, @@ -114,21 +115,11 @@ export class DownloadFormElement extends LitElement { } try { - const response = await fetch('/api/downloads/submit', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }) - - if (!response.ok) { - throw new ClientScriptError({ - message: 'Failed to submit form', - }) - } + const result = await actions.downloads.submit(payload) - await response.json() + if (result.error || !result.data?.success) { + throw new ClientScriptError({ message: result.error?.message || 'Failed to submit form' }) + } this.showStatus('success', 'Thank you! Click the button below to download your resource.') diff --git a/src/pages/newsletter/confirm/[token].astro b/src/pages/newsletter/confirm/[token].astro index 6b637e67c..99b2642ed 100644 --- a/src/pages/newsletter/confirm/[token].astro +++ b/src/pages/newsletter/confirm/[token].astro @@ -172,6 +172,8 @@ const subtitle = 'Please wait while we confirm your subscription' - - -

New Contact Form Submission

-${fields.join('\n')} - - -` -} - -/** - * Escape HTML special characters - */ -function escapeHtml(text: string): string { - const map: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - } - return text.replace(/[&<>"']/g, (char) => map[char] || char) -} - -/** - * Format file size for display - */ -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB` - return `${(bytes / (1024 * 1024)).toFixed(2)} MB` -} - -/** - * Send email via Resend - */ -async function sendEmail( - emailData: EmailData, - files: FileAttachment[] -): Promise { - if (isTest() || isDev()) { - return - } - - const resendPayload = { - from: emailData.from, - to: emailData.to, - subject: emailData.subject, - html: emailData.html, - ...(files.length > 0 && { - attachments: files.map((file) => ({ - filename: file.filename, - content: file.content, - })), - }), - } - - const handleSendError = (error: unknown) => { - console.error('[contact] Resend delivery error:', error) - throw new ApiFunctionError({ - message: 'Failed to send email. Please try again later.', - cause: error, - code: 'RESEND_SEND_FAILED', - status: 502, - route: '/api/contact', - operation: 'sendEmail' - }) - } - - const resend = new Resend(getResendApiKey()) - - try { - // Prepare attachments for Resend - const attachments = files.map((file) => ({ - filename: file.filename, - content: file.content, - })) - - const response = await resend.emails.send({ - ...resendPayload, - ...(attachments.length > 0 && { attachments }), - }) - - if (!response.data) { - throw new Error(response.error?.message || 'Failed to send email') - } - } catch (error) { - handleSendError(error) - } -} - -/** - * Main API handler for contact form submissions - */ -export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: '/api/contact', - operation: 'POST', - request, - cookies, - clientAddress, - }) - - const userAgent = request.headers.get('user-agent') || 'unknown' - const ip = - clientAddress || - request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || - request.headers.get('x-real-ip') || - 'unknown' - - try { - const rateLimitIdentifier = createRateLimitIdentifier('contact', fingerprint) - if (!checkContactRateLimit(rateLimitIdentifier)) { - throw new ApiFunctionError({ - message: 'Too many form submissions. Please try again later.', - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - }) - } - - const contentType = request.headers.get('content-type') || '' - let formData: ContactFormData - const files: FileAttachment[] = [] - - if (contentType.includes('multipart/form-data')) { - // Handle file uploads - const form = await request.formData() - formData = { - name: form.get('name') as string, - email: form.get('email') as string, - message: form.get('message') as string, - consent: form.get('consent') === 'true', - } - - // Add optional fields if present - const phone = form.get('phone') as string - const service = form.get('service') as string - const budget = form.get('budget') as string - const timeline = form.get('timeline') as string - const website = form.get('website') as string - - if (phone) formData.phone = phone - if (service) formData.service = service - if (budget) formData.budget = budget - if (timeline) formData.timeline = timeline - if (website) formData.website = website - - // Process file attachments - const allowedTypes = [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'application/pdf', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - ] - const maxFileSize = 10 * 1024 * 1024 // 10MB - const maxFiles = 5 - - let fileCount = 0 - for (const [key, value] of form as unknown as Iterable<[ - string, - FormDataEntryValue, - ]>) { - if (key.startsWith('file') && value instanceof File && value.size > 0) { - fileCount++ - - if (fileCount > maxFiles) { - throw new ApiFunctionError({ - message: `Maximum ${maxFiles} files allowed`, - status: 400, - code: 'FILE_COUNT_EXCEEDED', - details: { maxFiles }, - }) - } - - if (value.size > maxFileSize) { - throw new ApiFunctionError({ - message: `File ${value.name} exceeds 10MB limit`, - status: 400, - code: 'FILE_TOO_LARGE', - details: { file: value.name, maxBytes: maxFileSize }, - }) - } - - if (!allowedTypes.includes(value.type)) { - throw new ApiFunctionError({ - message: `File type ${value.type} not allowed`, - status: 400, - code: 'FILE_TYPE_NOT_ALLOWED', - details: { file: value.name, type: value.type }, - }) - } - - const buffer = Buffer.from(await value.arrayBuffer()) - files.push({ - filename: value.name, - content: buffer, - contentType: value.type, - size: value.size, - }) - } - } - } else { - try { - formData = (await request.json()) as ContactFormData - } catch { - throw new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - }) - } - } - - const validationErrors = validateInput(formData) - if (validationErrors.length > 0) { - throw new ApiFunctionError({ - message: validationErrors[0], - status: 400, - code: 'INVALID_REQUEST', - details: { errors: validationErrors }, - }) - } - - if (formData.consent) { - let subjectId = formData.DataSubjectId - if (!subjectId) { - subjectId = uuidv4() - } else if (!uuidValidate(subjectId)) { - throw new ApiFunctionError({ - message: 'Invalid DataSubjectId format', - status: 400, - code: 'INVALID_UUID', - }) - } - - const consentPayload = { - DataSubjectId: subjectId, - email: formData.email, - purposes: ['contact'], - source: 'contact_form', - userAgent, - ...(ip !== 'unknown' && { ipAddress: ip }), - verified: true, - } - - try { - const consentResponse = await fetch(`${new URL(request.url).origin}/api/gdpr/consent`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(consentPayload), - }) - - if (!consentResponse.ok) { - throw new ApiFunctionError({ - message: 'Failed to record consent. Please try again later.', - status: 502, - code: 'CONSENT_RECORD_FAILED', - details: { consentPayload }, - }) - } - } catch (consentError) { - handleApiFunctionError(consentError, { - ...apiContext, - operation: 'POST:consent', - status: 502, - code: 'CONSENT_RECORD_FAILED', - }) - } - } - - const htmlContent = generateEmailContent(formData, files) - - const emailData: EmailData = { - from: 'contact@webstackbuilders.com', - to: 'info@webstackbuilders.com', - subject: `Contact Form: ${formData.name}`, - html: htmlContent, - } - - await sendEmail(emailData, files) - - return new Response( - JSON.stringify({ - success: true, - message: 'Thank you for your message. We will get back to you soon!', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ) - } catch (error) { - const serverError = handleApiFunctionError(error, apiContext) - - return buildApiErrorResponse(serverError, { - fallbackMessage: 'An unexpected error occurred. Please try again.', - }) - } -} - -// Handle OPTIONS for CORS -export const OPTIONS: APIRoute = async () => { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }, - }) -} diff --git a/src/pages/api/cron/__tests__/cleanup.spec.ts b/src/pages/api/cron/__tests__/cleanup.spec.ts index 6fbcf9ca3..265df74da 100644 --- a/src/pages/api/cron/__tests__/cleanup.spec.ts +++ b/src/pages/api/cron/__tests__/cleanup.spec.ts @@ -22,7 +22,7 @@ vi.mock('astro:db', () => ({ })) vi.mock('@pages/api/_environment/environmentApi', async () => { - const actual = await vi.importActual( + const actual = await vi.importActual( '@pages/api/_environment/environmentApi', ) return { diff --git a/src/pages/api/cron/__tests__/runner.spec.ts b/src/pages/api/cron/__tests__/runner.spec.ts index c99e1f8a4..9fca3fc74 100644 --- a/src/pages/api/cron/__tests__/runner.spec.ts +++ b/src/pages/api/cron/__tests__/runner.spec.ts @@ -6,7 +6,7 @@ const getCronSecretMock = vi.hoisted(() => vi.fn(() => 'cron-secret')) const getSiteUrlMock = vi.hoisted(() => vi.fn(() => 'https://example.com')) vi.mock('@pages/api/_environment/environmentApi', async () => { - const actual = await vi.importActual( + const actual = await vi.importActual( '@pages/api/_environment/environmentApi', ) return { diff --git a/src/pages/api/cron/cleanup-confirmations.ts b/src/pages/api/cron/cleanup-confirmations.ts index 6da7b7ae5..364f75d99 100644 --- a/src/pages/api/cron/cleanup-confirmations.ts +++ b/src/pages/api/cron/cleanup-confirmations.ts @@ -1,8 +1,11 @@ import type { APIRoute } from 'astro' import { db, lt, newsletterConfirmations } from 'astro:db' -import { getCronSecret } from '@pages/api/_environment/environmentApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { getCronSecret } from '@pages/api/_utils/environment' +import { + ApiFunctionError, + buildApiErrorResponse, + handleApiFunctionError +} from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false diff --git a/src/pages/api/cron/cleanup-dsar-requests.ts b/src/pages/api/cron/cleanup-dsar-requests.ts index c69a34a80..3457f02d3 100644 --- a/src/pages/api/cron/cleanup-dsar-requests.ts +++ b/src/pages/api/cron/cleanup-dsar-requests.ts @@ -1,8 +1,11 @@ import type { APIRoute } from 'astro' import { and, db, dsarRequests, isNull, lt } from 'astro:db' -import { getCronSecret } from '@pages/api/_environment/environmentApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { getCronSecret } from '@pages/api/_utils/environment' +import { + ApiFunctionError, + buildApiErrorResponse, + handleApiFunctionError +} from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false diff --git a/src/pages/api/cron/run-all.ts b/src/pages/api/cron/run-all.ts index fe76e37ec..5c6036a66 100644 --- a/src/pages/api/cron/run-all.ts +++ b/src/pages/api/cron/run-all.ts @@ -1,8 +1,10 @@ import type { APIRoute } from 'astro' -import { getCronSecret } from '@pages/api/_environment/environmentApi' -import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { getCronSecret, getSiteUrl } from '@pages/api/_utils/environment' +import { + ApiFunctionError, + buildApiErrorResponse, + handleApiFunctionError +} from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false diff --git a/src/pages/api/downloads/__tests__/_submit.spec.ts b/src/pages/api/downloads/__tests__/_submit.spec.ts deleted file mode 100644 index b8e8b5d14..000000000 --- a/src/pages/api/downloads/__tests__/_submit.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Unit tests for downloads form API endpoint - */ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { POST } from '@pages/api/downloads/submit' - -describe('Downloads API - POST /api/downloads/submit', () => { - beforeEach(() => { - // Suppress console output - vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - // Reset any state if needed - }) - - it('should accept valid download form submission', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Doe', - workEmail: 'john.doe@company.com', - jobTitle: 'Software Engineer', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.message).toContain('success') - }) - - it('should reject submission without firstName', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - lastName: 'Doe', - workEmail: 'john.doe@company.com', - jobTitle: 'Software Engineer', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('required') - }) - - it('should reject submission without lastName', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - workEmail: 'john.doe@company.com', - jobTitle: 'Software Engineer', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('required') - }) - - it('should reject submission without workEmail', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Doe', - jobTitle: 'Software Engineer', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('required') - }) - - it('should reject submission without jobTitle', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Doe', - workEmail: 'john.doe@company.com', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('required') - }) - - it('should reject submission without companyName', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Doe', - workEmail: 'john.doe@company.com', - jobTitle: 'Software Engineer', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('required') - }) - - it('should reject submission with invalid email format', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - firstName: 'John', - lastName: 'Doe', - workEmail: 'invalid-email', - jobTitle: 'Software Engineer', - companyName: 'Acme Corp', - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Invalid email') - }) - - it('should handle malformed JSON gracefully', async () => { - const request = new Request('http://localhost/api/downloads/submit', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: 'invalid json', - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Invalid JSON payload') - }) -}) diff --git a/src/pages/api/downloads/submit.ts b/src/pages/api/downloads/submit.ts deleted file mode 100644 index de81935fb..000000000 --- a/src/pages/api/downloads/submit.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * API endpoint for download form submissions - */ -import type { APIRoute } from 'astro' -import emailValidator from 'email-validator' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext } from '@pages/api/_utils/requestContext' - -export const prerender = false - -interface DownloadFormData { - firstName: string - lastName: string - workEmail: string - jobTitle: string - companyName: string -} - -const JSON_HEADERS = { - 'Content-Type': 'application/json', -} - -const REQUIRED_FIELDS: Array = [ - 'firstName', - 'lastName', - 'workEmail', - 'jobTitle', - 'companyName', -] - -const buildJsonResponse = (body: Record, status: number) => - new Response(JSON.stringify(body), { - status, - headers: JSON_HEADERS, - }) - -const validateDownloadForm = (payload: Partial): DownloadFormData => { - const normalized: DownloadFormData = { - firstName: payload.firstName?.trim() ?? '', - lastName: payload.lastName?.trim() ?? '', - workEmail: payload.workEmail?.trim() ?? '', - jobTitle: payload.jobTitle?.trim() ?? '', - companyName: payload.companyName?.trim() ?? '', - } - - const missingFields = REQUIRED_FIELDS.filter((field) => !normalized[field]) - - if (missingFields.length) { - throw new ApiFunctionError({ - message: 'All fields are required', - status: 400, - code: 'MISSING_FIELDS', - details: { missingFields }, - }) - } - - if (!emailValidator.validate(normalized.workEmail)) { - throw new ApiFunctionError({ - message: 'Invalid email address', - status: 400, - code: 'INVALID_EMAIL', - }) - } - - return normalized -} - -export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { - const { context: apiContext } = createApiFunctionContext({ - route: '/api/downloads/submit', - operation: 'POST', - request, - cookies, - clientAddress, - }) - - try { - let payload: Partial - try { - payload = await request.json() - } catch { - throw new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - details: { - route: '/api/downloads/submit', - }, - }) - } - - const data = validateDownloadForm(payload) - - // TODO: Integrate with email service (e.g., SendGrid, Mailchimp, HubSpot) - // TODO: Store submission in database or CRM - // For now, just log the submission - console.log('Download form submission:', { - name: `${data.firstName} ${data.lastName}`, - email: data.workEmail, - jobTitle: data.jobTitle, - company: data.companyName, - timestamp: new Date().toISOString(), - }) - - return buildJsonResponse( - { - success: true, - message: 'Form submitted successfully', - }, - 200, - ) - } catch (rawError) { - const normalizedError = - rawError instanceof ApiFunctionError - ? rawError - : rawError instanceof SyntaxError - ? new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - }) - : rawError - - const serverError = handleApiFunctionError(normalizedError, apiContext) - - return buildApiErrorResponse(serverError, { - fallbackMessage: 'Internal server error', - }) - } -} diff --git a/src/pages/api/gdpr/__tests__/dsarVerificationEmails.spec.ts b/src/pages/api/gdpr/__tests__/dsarVerificationEmails.spec.ts deleted file mode 100644 index a4276320d..000000000 --- a/src/pages/api/gdpr/__tests__/dsarVerificationEmails.spec.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' -import { TestError } from '@test/errors' -import { sendDSARVerificationEmail } from '@pages/api/gdpr/_dsarVerificationEmails' - -const { envMocks, siteUrlMock } = vi.hoisted(() => ({ - envMocks: { - isDev: vi.fn(() => false), - isTest: vi.fn(() => false), - getResendApiKey: vi.fn(() => 'test-resend-key'), - }, - siteUrlMock: vi.fn(() => 'https://webstackbuilders.com'), -})) - -vi.mock('@pages/api/_environment/environmentApi', () => envMocks) - -vi.mock('@pages/api/_environment/siteUrlApi', () => ({ - getSiteUrl: siteUrlMock, -})) - -// Create mock send function at module level -const mockSend = vi.fn() - -// Mock the Resend module -vi.mock('resend', () => { - return { - Resend: vi.fn(function ResendMock(_apiKey) { - return { - emails: { - send: mockSend, - }, - } - }), - } -}) - -describe('GDPR Email Utils', () => { - let consoleLogSpy: ReturnType - let consoleErrorSpy: ReturnType - - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - - envMocks.isDev.mockReturnValue(false) - envMocks.isTest.mockReturnValue(false) - envMocks.getResendApiKey.mockReturnValue('test-resend-key') - siteUrlMock.mockReturnValue('https://webstackbuilders.com') - - consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - }) - - afterEach(() => { - consoleLogSpy.mockRestore() - consoleErrorSpy.mockRestore() - }) - - describe('sendDSARVerificationEmail', () => { - describe('in development/test environment', () => { - it('should log email details instead of sending when isDev() returns true', async () => { - envMocks.isDev.mockReturnValue(true) - - await sendDSARVerificationEmail('test@example.com', 'test-token', 'ACCESS') - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEV/TEST MODE] DSAR verification email would be sent:', - { - email: 'test@example.com', - token: 'test-token', - requestType: 'ACCESS', - } - ) - expect(mockSend).not.toHaveBeenCalled() - }) - - it('should log email details instead of sending when isTest() returns true', async () => { - envMocks.isTest.mockReturnValue(true) - - await sendDSARVerificationEmail('test@example.com', 'test-token', 'DELETE') - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DEV/TEST MODE] DSAR verification email would be sent:', - { - email: 'test@example.com', - token: 'test-token', - requestType: 'DELETE', - } - ) - expect(mockSend).not.toHaveBeenCalled() - }) - }) - - describe('in production environment', () => { - - it('should send ACCESS verification email successfully', async () => { - mockSend.mockResolvedValue({ - data: { id: 'message-id-123' }, - error: null, - }) - - await sendDSARVerificationEmail('user@example.com', 'verification-token-123', 'ACCESS') - - expect(mockSend).toHaveBeenCalledTimes(1) - const callArgs = mockSend.mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - - expect(callArgs!.from).toBe('Webstack Builders ') - expect(callArgs!.to).toBe('user@example.com') - expect(callArgs!.subject).toBe('Verify Your Data Access Request - Webstack Builders') - expect(callArgs!.html).toContain('Data Access Request') - expect(callArgs!.html).toContain('access your data') - expect(callArgs!.html).toContain('https://webstackbuilders.com/api/gdpr/verify?token=verification-token-123') - expect(callArgs!.text).toContain('Data Access Request') - expect(callArgs!.tags).toEqual([ - { name: 'type', value: 'gdpr-verification' }, - { name: 'request-type', value: 'access' }, - ]) - - expect(consoleLogSpy).toHaveBeenCalledWith( - '[DSAR Email] Verification sent successfully:', - { - email: 'user@example.com', - requestType: 'ACCESS', - messageId: 'message-id-123', - } - ) - }) - - it('should send DELETE verification email successfully with warning', async () => { - mockSend.mockResolvedValue({ - data: { id: 'message-id-456' }, - error: null, - }) - - await sendDSARVerificationEmail('user@example.com', 'delete-token-456', 'DELETE') - - expect(mockSend).toHaveBeenCalledTimes(1) - const callArgs = mockSend.mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - - expect(callArgs!.subject).toBe('Verify Your Data Deletion Request - Webstack Builders') - expect(callArgs!.html).toContain('Data Deletion Request') - expect(callArgs!.html).toContain('delete your data') - expect(callArgs!.html).toContain('⚠️ Important') - expect(callArgs!.html).toContain('permanently delete all your data') - expect(callArgs!.text).toContain('⚠️ IMPORTANT') - expect(callArgs!.tags).toEqual([ - { name: 'type', value: 'gdpr-verification' }, - { name: 'request-type', value: 'delete' }, - ]) - }) - - it('should use getSiteUrl() return value for verification URL', async () => { - siteUrlMock.mockReturnValue('http://localhost:4321') - mockSend.mockResolvedValue({ - data: { id: 'message-id-789' }, - error: null, - }) - - await sendDSARVerificationEmail('user@example.com', 'token-789', 'ACCESS') - - const callArgs = mockSend.mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - expect(callArgs!.html).toContain('http://localhost:4321/api/gdpr/verify?token=token-789') - expect(callArgs!.text).toContain('http://localhost:4321/api/gdpr/verify?token=token-789') - }) - - it('should handle Resend API error response', async () => { - mockSend.mockResolvedValue({ - data: null, - error: { - message: 'Invalid API key', - name: 'validation_error', - }, - }) - - await expect( - sendDSARVerificationEmail('user@example.com', 'token-123', 'ACCESS') - ).rejects.toThrow() - - expect(consoleErrorSpy).toHaveBeenCalledWith( - '[DSAR Email] Failed to send verification', - expect.objectContaining({ - message: 'Invalid API key', - name: 'validation_error', - }) - ) - }) - - it('should handle Resend API network error', async () => { - const networkError = new TestError('Network failure') - mockSend.mockRejectedValue(networkError) - - await expect( - sendDSARVerificationEmail('user@example.com', 'token-123', 'ACCESS') - ).rejects.toThrow() - - expect(consoleErrorSpy).toHaveBeenCalledWith( - '[DSAR Email] Error sending verification', - networkError - ) - }) - - it('should include current year in email content', async () => { - const currentYear = new Date().getFullYear() - mockSend.mockResolvedValue({ - data: { id: 'message-id-year' }, - error: null, - }) - - await sendDSARVerificationEmail('user@example.com', 'token-year', 'ACCESS') - - const callArgs = mockSend.mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - expect(callArgs!.html).toContain(`© ${currentYear} Webstack Builders`) - expect(callArgs!.text).toContain(`© ${currentYear} Webstack Builders`) - }) - - it('should generate proper verification URLs with tokens', async () => { - siteUrlMock.mockReturnValue('https://example.com') - mockSend.mockResolvedValue({ - data: { id: 'message-id-url' }, - error: null, - }) - - await sendDSARVerificationEmail('user@example.com', 'special-token-123', 'DELETE') - - const callArgs = mockSend.mock.calls[0]?.[0] - expect(callArgs).toBeDefined() - const expectedUrl = 'https://example.com/api/gdpr/verify?token=special-token-123' - - expect(callArgs!.html).toContain(`href="${expectedUrl}"`) - expect(callArgs!.html).toContain(expectedUrl) // Also as plain text in email - expect(callArgs!.text).toContain(expectedUrl) - }) - }) - }) -}) \ No newline at end of file diff --git a/src/pages/api/gdpr/__tests__/request-data.spec.ts b/src/pages/api/gdpr/__tests__/request-data.spec.ts deleted file mode 100644 index 432cfdff8..000000000 --- a/src/pages/api/gdpr/__tests__/request-data.spec.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import type { AstroCookies } from 'astro' -import type { DSARRequestInput } from '@pages/api/_contracts/gdpr.contracts' - -const MOCK_TOKEN = 'mock-token' - -const mockCheckRateLimit = vi.fn() -const mockSendEmail = vi.fn() -const mockFindActiveRequest = vi.fn() -const mockCreateDsarRequest = vi.fn() - -vi.mock('uuid', () => ({ - v4: vi.fn(() => MOCK_TOKEN), -})) - -vi.mock('@pages/api/_utils', () => ({ - rateLimiters: { - export: { name: 'export' }, - }, - checkRateLimit: (...args: unknown[]) => mockCheckRateLimit(...args), -})) - -vi.mock('@pages/api/gdpr/_dsarVerificationEmails', () => ({ - sendDSARVerificationEmail: (...args: unknown[]) => mockSendEmail(...args), -})) - -vi.mock('@pages/api/gdpr/_utils/dsarStore', () => ({ - findActiveRequestByEmail: (...args: unknown[]) => mockFindActiveRequest(...args), - createDsarRequest: (...args: unknown[]) => mockCreateDsarRequest(...args), -})) - -import { POST } from '../request-data' - -const defaultRequestBody: DSARRequestInput = { - email: 'User@Example.com', - requestType: 'ACCESS', -} - -const createRequest = (body: DSARRequestInput) => - new Request('http://localhost/api/gdpr/request-data', { - method: 'POST', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json', - }, - }) - -const cookies = { - get: vi.fn(() => undefined), -} as unknown as AstroCookies - -type PostArgs = Parameters[0] - -const createContext = (overrides?: Partial) => ({ - request: createRequest(defaultRequestBody), - clientAddress: '127.0.0.1', - cookies, - ...overrides, -}) as PostArgs - -describe('POST /api/gdpr/request-data', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue({ success: true, reset: undefined }) - mockSendEmail.mockResolvedValue(undefined) - mockFindActiveRequest.mockResolvedValue(undefined) - mockCreateDsarRequest.mockResolvedValue(undefined) - }) - - it('reuses an existing DSAR token when one is still active', async () => { - mockFindActiveRequest.mockResolvedValue({ token: 'existing-token' }) - - const response = await POST(createContext()) - const payload = await response.json() - - expect(response.status).toBe(200) - expect(payload).toEqual({ - success: true, - message: expect.stringContaining('Verification email sent'), - }) - expect(mockFindActiveRequest).toHaveBeenCalledWith('user@example.com', 'ACCESS') - expect(mockCreateDsarRequest).not.toHaveBeenCalled() - expect(mockSendEmail).toHaveBeenCalledWith('user@example.com', 'existing-token', 'ACCESS') - }) - - it('creates a new DSAR request when none exist', async () => { - mockFindActiveRequest.mockResolvedValue(undefined) - - const response = await POST( - createContext({ - request: createRequest({ - email: 'requester@example.com', - requestType: 'DELETE', - }), - }), - ) - const payload = await response.json() - - expect(response.status).toBe(201) - expect(payload).toEqual({ - success: true, - message: expect.stringContaining('Verification email sent'), - }) - expect(mockCreateDsarRequest).toHaveBeenCalledWith({ - token: MOCK_TOKEN, - email: 'requester@example.com', - requestType: 'DELETE', - expiresAt: expect.any(Date), - }) - expect(mockSendEmail).toHaveBeenLastCalledWith('requester@example.com', MOCK_TOKEN, 'DELETE') - }) -}) diff --git a/src/pages/api/gdpr/_dsarVerificationEmails.ts b/src/pages/api/gdpr/_dsarVerificationEmails.ts deleted file mode 100644 index d9afafd05..000000000 --- a/src/pages/api/gdpr/_dsarVerificationEmails.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * DSAR (Data Subject Access Request) email service - * Sends verification emails for data access and deletion requests using Resend - */ -import { Resend } from 'resend' -import { dsarVerificationEmailHtml } from '@content/email/dsar.html' -import { dsarVerificationEmailText } from '@content/email/dsar.text' -import { getResendApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' -import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' - -/** - * Send verification email for DSAR request - * - * @param email - User's email address - * @param token - Verification token - * @param requestType - Type of request (ACCESS or DELETE) - * @returns Promise that resolves when email is sent - */ -export async function sendDSARVerificationEmail( - email: string, - token: string, - requestType: 'ACCESS' | 'DELETE' -): Promise { - // Skip actual email sending in dev/test environments - if (isDev() || isTest()) { - console.log('[DEV/TEST MODE] DSAR verification email would be sent:', { email, token, requestType }) - return - } - - let resend: Resend - try { - resend = new Resend(getResendApiKey()) - } catch (error) { - const message = `[DSAR Email] Failed to initialize Resend client` - console.error(message, error) - throw new ApiFunctionError({ - message, - cause: error, - code: 'DSAR_EMAIL_INIT_FAILED', - status: 500, - route: '/api/gdpr', - operation: 'sendDSARVerificationEmail' - }) - } - - const verifyUrl = `${getSiteUrl()}/api/gdpr/verify?token=${token}` - const expiresIn = '24 hours' - const actionText = requestType === 'ACCESS' ? 'access your data' : 'delete your data' - const subject = requestType === 'ACCESS' - ? 'Verify Your Data Access Request' - : 'Verify Your Data Deletion Request' - - const html = dsarVerificationEmailHtml({ - subject, - requestType, - actionText, - verifyUrl, - expiresIn, - }) - - const text = dsarVerificationEmailText({ - requestType, - actionText, - verifyUrl, - expiresIn, - }) - - try { - const result = await resend.emails.send({ - from: 'Webstack Builders ', - to: email, - subject: `${subject} - Webstack Builders`, - html, - text, - tags: [ - { name: 'type', value: 'gdpr-verification' }, - { name: 'request-type', value: requestType.toLowerCase() }, - ], - }) - - if (result.error) { - const message = `[DSAR Email] Failed to send verification` - console.error(message, result.error) - throw new ApiFunctionError({ - message, - cause: result.error, - code: 'DSAR_EMAIL_SEND_FAILED', - status: 502, - route: '/api/gdpr', - operation: 'sendDSARVerificationEmail' - }) - } - - console.log('[DSAR Email] Verification sent successfully:', { - email, - requestType, - messageId: result.data?.id, - }) - } catch (error) { - const message = `[DSAR Email] Error sending verification` - console.error(message, error) - throw new ApiFunctionError({ - message, - cause: error, - code: 'DSAR_EMAIL_SEND_FAILED', - status: 502, - route: '/api/gdpr', - operation: 'sendDSARVerificationEmail' - }) - } -} \ No newline at end of file diff --git a/src/pages/api/gdpr/_utils/consentStore.ts b/src/pages/api/gdpr/_utils/consentStore.ts deleted file mode 100644 index 7c8362ab7..000000000 --- a/src/pages/api/gdpr/_utils/consentStore.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { and, consentEvents, db, desc, eq } from 'astro:db' - -type DbConsentRecord = typeof consentEvents.$inferSelect - -export type ConsentEventRecord = Omit & { - purposes: string[] -} - -const toConsentRecord = (record: DbConsentRecord): ConsentEventRecord => ({ - ...record, - purposes: record.purposes as string[], -}) - -export type CreateConsentRecordInput = { - dataSubjectId: string - email: string | null - purposes: string[] - source: string - userAgent: string - ipAddress: string | null - privacyPolicyVersion: string - consentText: string | null - verified: boolean -} - -export async function createConsentRecord(input: CreateConsentRecordInput): Promise { - const [record] = await db - .insert(consentEvents) - .values({ - id: randomUUID(), - ...input, - createdAt: new Date(), - }) - .returning() - - if (!record) { - throw new Error('Failed to create consent record') - } - - return toConsentRecord(record) -} - -export async function findConsentRecords(dataSubjectId: string): Promise { - const records = await db - .select() - .from(consentEvents) - .where(eq(consentEvents.dataSubjectId, dataSubjectId)) - .orderBy(desc(consentEvents.createdAt)) - - return records.map(toConsentRecord) -} - -export async function deleteConsentRecords(dataSubjectId: string): Promise { - const deleted = await db - .delete(consentEvents) - .where(eq(consentEvents.dataSubjectId, dataSubjectId)) - .returning({ id: consentEvents.id }) - - return deleted.length -} - -const normalizeEmail = (email: string): string => email.trim().toLowerCase() - -export async function findConsentRecordsByEmail(email: string): Promise { - const normalizedEmail = normalizeEmail(email) - const records = await db - .select() - .from(consentEvents) - .where(eq(consentEvents.email, normalizedEmail)) - .orderBy(desc(consentEvents.createdAt)) - - return records.map(toConsentRecord) -} - -export async function deleteConsentRecordsByEmail(email: string): Promise { - const normalizedEmail = normalizeEmail(email) - const deleted = await db - .delete(consentEvents) - .where(eq(consentEvents.email, normalizedEmail)) - .returning({ id: consentEvents.id }) - - return deleted.length -} - -export async function markConsentRecordsVerified( - email: string, - dataSubjectId: string, -): Promise { - const normalizedEmail = normalizeEmail(email) - const updated = await db - .update(consentEvents) - .set({ verified: true }) - .where( - and( - eq(consentEvents.email, normalizedEmail), - eq(consentEvents.dataSubjectId, dataSubjectId), - eq(consentEvents.verified, false), - ), - ) - .returning({ id: consentEvents.id }) - - return updated.length -} diff --git a/src/pages/api/gdpr/_utils/dsarStore.ts b/src/pages/api/gdpr/_utils/dsarStore.ts deleted file mode 100644 index 81df67872..000000000 --- a/src/pages/api/gdpr/_utils/dsarStore.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { randomUUID } from 'node:crypto' -import { and, db, dsarRequests, eq, gt, isNull } from 'astro:db' -import type { DSARRequestInput } from '@pages/api/_contracts/gdpr.contracts' - -export type DsarRequestRecord = typeof dsarRequests.$inferSelect - -type RequestType = DSARRequestInput['requestType'] - -export type CreateDsarRequestInput = { - token: string - email: string - requestType: RequestType - expiresAt: Date -} - -export async function findActiveRequestByEmail( - email: string, - requestType: RequestType, -): Promise { - const [record] = await db - .select() - .from(dsarRequests) - .where( - and( - eq(dsarRequests.email, email), - eq(dsarRequests.requestType, requestType), - isNull(dsarRequests.fulfilledAt), - gt(dsarRequests.expiresAt, new Date()), - ), - ) - .limit(1) - - return record -} - -export async function createDsarRequest( - input: CreateDsarRequestInput, -): Promise { - const [record] = await db - .insert(dsarRequests) - .values({ - id: randomUUID(), - token: input.token, - email: input.email, - requestType: input.requestType, - expiresAt: input.expiresAt, - createdAt: new Date(), - }) - .returning() - - if (!record) { - throw new Error('Failed to create DSAR request') - } - - return record -} - -export async function findDsarRequestByToken(token: string): Promise { - const [record] = await db - .select() - .from(dsarRequests) - .where(eq(dsarRequests.token, token)) - .limit(1) - - return record -} - -export async function markDsarRequestFulfilled(token: string): Promise { - await db - .update(dsarRequests) - .set({ fulfilledAt: new Date() }) - .where(eq(dsarRequests.token, token)) -} diff --git a/src/pages/api/gdpr/consent.ts b/src/pages/api/gdpr/consent.ts deleted file mode 100644 index 9267d06d0..000000000 --- a/src/pages/api/gdpr/consent.ts +++ /dev/null @@ -1,321 +0,0 @@ -import type { APIRoute } from 'astro' -import { getPrivacyPolicyVersion } from '@pages/api/_environment/environmentApi' -import { rateLimiters, checkRateLimit } from '@pages/api/_utils' -import { validate as uuidValidate } from 'uuid' -import type { ConsentRequest, ConsentResponse } from '@pages/api/_contracts/gdpr.contracts' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' -import { - createConsentRecord, - deleteConsentRecords, - findConsentRecords, - type ConsentEventRecord, -} from '@pages/api/gdpr/_utils/consentStore' - -export const prerender = false // Force SSR for this endpoint - -const ROUTE = '/api/gdpr/consent' - -const CONSENT_PURPOSES = ['contact', 'marketing', 'analytics', 'downloads'] as const -type ConsentPurpose = (typeof CONSENT_PURPOSES)[number] - -const CONSENT_SOURCES = ['contact_form', 'newsletter_form', 'download_form', 'cookies_modal', 'preferences_page'] as const -type ConsentSource = (typeof CONSENT_SOURCES)[number] - -const DEFAULT_SOURCE: ConsentSource = 'cookies_modal' -const DEFAULT_USER_AGENT = 'unknown' - -const isConsentPurpose = (value: unknown): value is ConsentPurpose => - typeof value === 'string' && CONSENT_PURPOSES.includes(value as ConsentPurpose) - -const isConsentSource = (value: unknown): value is ConsentSource => - typeof value === 'string' && CONSENT_SOURCES.includes(value as ConsentSource) - -const sanitizePurposes = (purposes: unknown): ConsentPurpose[] => - Array.isArray(purposes) ? purposes.filter(isConsentPurpose) : [] - -const sanitizeSource = (source: unknown): ConsentSource => (isConsentSource(source) ? source : DEFAULT_SOURCE) - -const normalizeNullableString = (value?: string | null): string | null => { - if (typeof value !== 'string') { - return null - } - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : null -} - -const normalizeUserAgent = (value?: string | null): string => normalizeNullableString(value) ?? DEFAULT_USER_AGENT - -const jsonResponse = (body: unknown, status: number, headers?: Record) => - new Response(JSON.stringify(body), { - status, - headers: { - 'Content-Type': 'application/json', - ...(headers || {}), - }, - }) - -const buildRateLimitError = (reset: number | undefined, message?: string) => { - const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 - const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) - return new ApiFunctionError({ - message: message ?? `Try again in ${retryAfterSeconds}s`, - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - details: { retryAfterSeconds }, - }) -} - -const mapConsentRecord = (record: ConsentEventRecord): ConsentResponse['record'] => { - const normalizedEmail = normalizeNullableString(record.email) - const normalizedIpAddress = normalizeNullableString(record.ipAddress) - const normalizedConsentText = normalizeNullableString(record.consentText) - - const mapped: ConsentResponse['record'] = { - id: record.id, - DataSubjectId: record.dataSubjectId, - purposes: sanitizePurposes(record.purposes), - timestamp: record.createdAt instanceof Date ? record.createdAt.toISOString() : new Date(record.createdAt).toISOString(), - source: sanitizeSource(record.source), - userAgent: normalizeUserAgent(record.userAgent), - privacyPolicyVersion: record.privacyPolicyVersion ?? getPrivacyPolicyVersion(), - verified: record.verified, - } - - if (normalizedEmail) { - mapped.email = normalizedEmail - } - if (normalizedIpAddress) { - mapped.ipAddress = normalizedIpAddress - } - if (normalizedConsentText) { - mapped.consentText = normalizedConsentText - } - - return mapped -} - -const buildErrorResponse = ( - error: unknown, - context: ReturnType['context'], - fallbackMessage: string, -) => { - const serverError = handleApiFunctionError(error, context) - const retryAfterSecondsRaw = serverError.details?.['retryAfterSeconds'] - const options: { - fallbackMessage: string - headers?: HeadersInit - } = { - fallbackMessage, - } - - if (typeof retryAfterSecondsRaw === 'number') { - options.headers = { 'Retry-After': String(Math.max(1, Math.ceil(retryAfterSecondsRaw))) } - } - - return buildApiErrorResponse(serverError, options) -} - -export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'POST', - request, - cookies, - clientAddress, - }) - - try { - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:post', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.consent, rateLimitIdentifier) - if (!success) { - throw buildRateLimitError(reset) - } - - let body: ConsentRequest - try { - body = await request.json() - } catch { - throw new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - }) - } - - if (!uuidValidate(body.DataSubjectId)) { - throw new ApiFunctionError({ - message: 'Invalid DataSubjectId', - status: 400, - code: 'INVALID_UUID', - }) - } - - const normalizedEmail = normalizeNullableString(body.email ?? null) - const normalizedPurposes = sanitizePurposes(body.purposes) - const normalizedSource = sanitizeSource(body.source) - const normalizedUserAgent = normalizeUserAgent(body.userAgent) - const normalizedIpAddress = normalizeNullableString(body.ipAddress ?? null) - const normalizedConsentText = normalizeNullableString(body.consentText ?? null) - - let record: ConsentResponse['record'] - try { - const dbRecord = await createConsentRecord({ - dataSubjectId: body.DataSubjectId, - email: normalizedEmail, - purposes: normalizedPurposes, - source: normalizedSource, - userAgent: normalizedUserAgent, - ipAddress: normalizedIpAddress, - privacyPolicyVersion: getPrivacyPolicyVersion(), - consentText: normalizedConsentText, - verified: body.verified ?? false, - }) - record = mapConsentRecord(dbRecord) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'insert-consent', - status: 500, - details: { - dataSubjectId: body.DataSubjectId, - purposes: body.purposes, - }, - }) - } - - return jsonResponse( - { - success: true, - record, - } satisfies ConsentResponse, - 201, - ) - } catch (error) { - return buildErrorResponse(error, apiContext, 'Failed to record consent') - } -} - - -export const GET: APIRoute = async ({ clientAddress, url, request, cookies }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'GET', - request, - cookies, - clientAddress, - }) - - const DataSubjectId = url.searchParams.get('DataSubjectId') - const purpose = url.searchParams.get('purpose') - - try { - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:get', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.consentRead, rateLimitIdentifier) - if (!success) { - throw buildRateLimitError(reset) - } - - if (!DataSubjectId || !uuidValidate(DataSubjectId)) { - throw new ApiFunctionError({ - message: 'Valid DataSubjectId required', - status: 400, - code: 'INVALID_UUID', - }) - } - - let fetchRecords: ConsentEventRecord[] - try { - fetchRecords = await findConsentRecords(DataSubjectId) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'GET.fetch-consent-records', - status: 500, - details: { - dataSubjectId: DataSubjectId, - purpose, - }, - }) - } - const filteredRecords = purpose - ? fetchRecords.filter(record => record.purposes.includes(purpose)) - : fetchRecords - const records = filteredRecords.map(mapConsentRecord) - - return jsonResponse( - { - success: true, - records, - hasActive: purpose ? records.length > 0 : undefined, - activeRecord: purpose && records.length > 0 ? records[0] : undefined, - }, - 200, - ) - } catch (error) { - apiContext.extra = { - ...(apiContext.extra || {}), - dataSubjectId: DataSubjectId, - purpose, - } - return buildErrorResponse(error, apiContext, 'Failed to retrieve consent') - } -} - - -export const DELETE: APIRoute = async ({ clientAddress, url, request, cookies }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'DELETE', - request, - cookies, - clientAddress, - }) - - const DataSubjectId = url.searchParams.get('DataSubjectId') - - try { - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:consent:delete', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.delete, rateLimitIdentifier) - if (!success) { - throw buildRateLimitError(reset) - } - - if (!DataSubjectId || !uuidValidate(DataSubjectId)) { - throw new ApiFunctionError({ - message: 'Valid DataSubjectId required', - status: 400, - code: 'INVALID_UUID', - }) - } - - let deletedCount: number - try { - deletedCount = await deleteConsentRecords(DataSubjectId) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'DELETE.remove-consent', - status: 500, - details: { - dataSubjectId: DataSubjectId, - }, - }) - } - - return jsonResponse( - { - success: true, - deletedCount, - }, - 200, - ) - } catch (error) { - apiContext.extra = { - ...(apiContext.extra || {}), - dataSubjectId: DataSubjectId, - } - return buildErrorResponse(error, apiContext, 'Failed to delete consent') - } -} diff --git a/src/pages/api/gdpr/export.ts b/src/pages/api/gdpr/export.ts deleted file mode 100644 index 93b4c2f48..000000000 --- a/src/pages/api/gdpr/export.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { APIRoute } from 'astro' -import { rateLimiters, checkRateLimit } from '@pages/api/_utils' -import { validate as uuidValidate } from 'uuid' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' -import { findConsentRecords } from '@pages/api/gdpr/_utils/consentStore' - -export const prerender = false // Force SSR for this endpoint - -const ROUTE = '/api/gdpr/export' - -const buildRateLimitError = (reset: number | undefined) => { - const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 - const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) - return new ApiFunctionError({ - message: `Try again in ${retryAfterSeconds}s`, - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - details: { retryAfterSeconds }, - }) -} - -const buildErrorResponse = ( - error: unknown, - context: ReturnType['context'], - fallbackMessage: string, -) => { - const serverError = handleApiFunctionError(error, context) - const retryAfterSecondsRaw = serverError.details?.['retryAfterSeconds'] - const options: { - fallbackMessage: string - headers?: HeadersInit - } = { - fallbackMessage, - } - - if (typeof retryAfterSecondsRaw === 'number') { - options.headers = { 'Retry-After': String(Math.max(1, Math.ceil(retryAfterSecondsRaw))) } - } - - return buildApiErrorResponse(serverError, options) -} - - -export const GET: APIRoute = async ({ clientAddress, url, request, cookies }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'GET', - request, - cookies, - clientAddress, - }) - - const DataSubjectId = url.searchParams.get('DataSubjectId') - - try { - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:export:get', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) - if (!success) { - throw buildRateLimitError(reset) - } - - if (!DataSubjectId || !uuidValidate(DataSubjectId)) { - throw new ApiFunctionError({ - message: 'Invalid DataSubjectId', - status: 400, - code: 'INVALID_UUID', - }) - } - - const consentRecords = await findConsentRecords(DataSubjectId) - const exportData = consentRecords.map((record) => ({ - id: record.id, - data_subject_id: record.dataSubjectId, - email: record.email, - purposes: record.purposes, - source: record.source, - user_agent: record.userAgent, - privacy_policy_version: record.privacyPolicyVersion, - consent_text: record.consentText, - verified: record.verified, - created_at: record.createdAt.toISOString(), - })) - - return new Response(JSON.stringify(exportData, null, 2), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Content-Disposition': `attachment; filename="my-data-${Date.now()}.json"` - } - }) - } catch (error) { - apiContext.extra = { - ...(apiContext.extra || {}), - dataSubjectId: DataSubjectId, - } - return buildErrorResponse(error, apiContext, 'Failed to export data') - } -} diff --git a/src/pages/api/gdpr/request-data.ts b/src/pages/api/gdpr/request-data.ts deleted file mode 100644 index c81d6c2d2..000000000 --- a/src/pages/api/gdpr/request-data.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { APIRoute } from 'astro' -import emailValidator from 'email-validator' -import { v4 as uuidv4 } from 'uuid' -import { rateLimiters, checkRateLimit } from '@pages/api/_utils' -import { sendDSARVerificationEmail } from '@pages/api/gdpr/_dsarVerificationEmails' -import type { DSARRequestInput, DSARResponse } from '@pages/api/_contracts/gdpr.contracts' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' -import { createDsarRequest, findActiveRequestByEmail } from '@pages/api/gdpr/_utils/dsarStore' - -export const prerender = false // Force SSR for this endpoint - -const ROUTE = '/api/gdpr/request-data' - -const jsonResponse = (body: unknown, status: number, headers?: Record) => - new Response(JSON.stringify(body), { - status, - headers: { - 'Content-Type': 'application/json', - ...(headers || {}), - }, - }) - -const buildRateLimitError = (reset: number | undefined) => { - const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 - const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) - return new ApiFunctionError({ - message: `Too many requests. Try again in ${retryAfterSeconds}s`, - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - details: { retryAfterSeconds }, - }) -} - -const buildValidationError = (message: string) => - new ApiFunctionError({ - message, - status: 400, - code: 'INVALID_REQUEST', - }) - -const buildErrorResponse = ( - error: unknown, - context: ReturnType['context'], - fallbackMessage: string, -) => { - const serverError = handleApiFunctionError(error, context) - const retryAfterSecondsRaw = serverError.details?.['retryAfterSeconds'] - const options: { - fallbackMessage: string - headers?: HeadersInit - } = { - fallbackMessage, - } - - if (typeof retryAfterSecondsRaw === 'number') { - options.headers = { 'Retry-After': String(Math.max(1, Math.ceil(retryAfterSecondsRaw))) } - } - - return buildApiErrorResponse(serverError, options) -} - -/** - * POST /api/gdpr/request-data - * Initiates a DSAR (Data Subject Access Request) for data access or deletion - * Sends verification email with token - */ - -export const POST: APIRoute = async ({ request, clientAddress, cookies }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'POST', - request, - cookies, - clientAddress, - }) - - try { - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:dsar:request', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) - if (!success) { - throw buildRateLimitError(reset) - } - - let body: DSARRequestInput - try { - body = await request.json() - } catch { - throw new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - }) - } - - if (!body.email || !body.requestType) { - throw buildValidationError('Email and request type are required') - } - - if (!emailValidator.validate(body.email)) { - throw buildValidationError('Invalid email format') - } - - if (!['ACCESS', 'DELETE'].includes(body.requestType)) { - throw buildValidationError('Request type must be ACCESS or DELETE') - } - - const email = body.email.toLowerCase().trim() - const token = uuidv4() - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours - - let existingRequest - try { - existingRequest = await findActiveRequestByEmail(email, body.requestType) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'fetch-existing-request', - status: 500, - details: { - email, - requestType: body.requestType, - }, - }) - } - - if (existingRequest) { - // Resend verification email with existing token - await sendDSARVerificationEmail(email, existingRequest.token, body.requestType) - - return jsonResponse( - { - success: true, - message: 'Verification email sent. Please check your inbox.', - } satisfies DSARResponse, - 200, - ) - } - - try { - await createDsarRequest({ - token, - email, - requestType: body.requestType, - expiresAt, - }) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'create-request', - status: 500, - details: { - email, - requestType: body.requestType, - }, - }) - } - - // Send verification email - await sendDSARVerificationEmail(email, token, body.requestType) - - return jsonResponse( - { - success: true, - message: 'Verification email sent. Please check your inbox and click the link to complete your request.', - } satisfies DSARResponse, - 201, - ) - } catch (error) { - return buildErrorResponse(error, apiContext, 'Failed to process request. Please try again.') - } -} diff --git a/src/pages/api/gdpr/verify.ts b/src/pages/api/gdpr/verify.ts deleted file mode 100644 index 80bbf4c05..000000000 --- a/src/pages/api/gdpr/verify.ts +++ /dev/null @@ -1,218 +0,0 @@ -import type { APIRoute } from 'astro' -import { rateLimiters, checkRateLimit } from '@pages/api/_utils' -import type { DSARRequest } from '@pages/api/_contracts/gdpr.contracts' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' -import { - deleteConsentRecordsByEmail, - findConsentRecordsByEmail, -} from '@pages/api/gdpr/_utils/consentStore' -import { - findDsarRequestByToken, - markDsarRequestFulfilled, -} from '@pages/api/gdpr/_utils/dsarStore' -import { deleteNewsletterConfirmationsByEmail } from '@pages/api/newsletter/_token' - -export const prerender = false // Force SSR for this endpoint - -const ROUTE = '/api/gdpr/verify' - -const buildRateLimitError = (reset: number | undefined) => { - const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 - const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) - return new ApiFunctionError({ - message: `Too many requests. Try again in ${retryAfterSeconds}s`, - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - details: { retryAfterSeconds }, - }) -} - -const buildErrorResponse = ( - error: unknown, - context: ReturnType['context'], - fallbackMessage: string, -) => { - const serverError = handleApiFunctionError(error, context) - const retryAfterSecondsRaw = serverError.details?.['retryAfterSeconds'] - const options: { - fallbackMessage: string - headers?: HeadersInit - } = { - fallbackMessage, - } - - if (typeof retryAfterSecondsRaw === 'number') { - options.headers = { 'Retry-After': String(Math.max(1, Math.ceil(retryAfterSecondsRaw))) } - } - - return buildApiErrorResponse(serverError, options) -} - -/** - * GET /api/gdpr/verify?token=xxx - * Verifies DSAR token and fulfills the request (data access or deletion) - */ -export const GET: APIRoute = async ({ request, clientAddress, cookies, redirect }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: ROUTE, - operation: 'GET', - request, - cookies, - clientAddress, - }) - - const url = new URL(request.url) - const token = url.searchParams.get('token') - const rateLimitIdentifier = createRateLimitIdentifier('gdpr:dsar:verify', fingerprint) - const { success, reset } = await checkRateLimit(rateLimiters.export, rateLimitIdentifier) - - if (!success) { - return buildErrorResponse(buildRateLimitError(reset), apiContext, 'Too many requests') - } - - if (!token) { - return buildErrorResponse( - new ApiFunctionError({ - message: 'Verification token is required', - status: 400, - code: 'INVALID_REQUEST', - }), - apiContext, - 'Verification token is required', - ) - } - - apiContext.extra = { ...(apiContext.extra || {}), token } - - try { - const dbRequest = await findDsarRequestByToken(token) - - if (!dbRequest) { - return redirect('/privacy/my-data?status=invalid') - } - - const dsarRequest: DSARRequest = { - id: dbRequest.id, - token: dbRequest.token, - email: dbRequest.email, - requestType: dbRequest.requestType as DSARRequest['requestType'], - expiresAt: dbRequest.expiresAt.toISOString(), - fulfilledAt: dbRequest.fulfilledAt?.toISOString(), - createdAt: dbRequest.createdAt.toISOString(), - } - - // Check if already fulfilled - if (dsarRequest.fulfilledAt) { - return redirect('/privacy/my-data?status=already-completed') - } - - // Check if expired - if (new Date(dsarRequest.expiresAt) < new Date()) { - return redirect('/privacy/my-data?status=expired') - } - - const email = dsarRequest.email - const requestType = dsarRequest.requestType - - if (requestType === 'ACCESS') { - let consentRecords - try { - consentRecords = await findConsentRecordsByEmail(email) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'fetch-consent-records', - status: 500, - details: { - email, - }, - }) - } - - try { - await markDsarRequestFulfilled(token) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'mark-request-fulfilled', - status: 500, - details: { - token, - requestType, - }, - }) - } - - // Return data as JSON download - const exportData = { - email, - requestDate: dsarRequest.createdAt, - consentRecords: consentRecords.map(({ ipAddress: _ip, ...record }) => ({ - ...record, - createdAt: record.createdAt instanceof Date ? record.createdAt.toISOString() : record.createdAt, - })), - } - - return new Response(JSON.stringify(exportData, null, 2), { - status: 200, - headers: { - 'Content-Type': 'application/json', - 'Content-Disposition': `attachment; filename="my-data-${Date.now()}.json"` - } - }) - } else if (requestType === 'DELETE') { - try { - await deleteConsentRecordsByEmail(email) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'delete-consent-records', - status: 500, - details: { - email, - }, - }) - } - - try { - await deleteNewsletterConfirmationsByEmail(email) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'delete-newsletter-confirmations', - status: 500, - details: { - email, - }, - }) - } - - try { - await markDsarRequestFulfilled(token) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'mark-delete-request-fulfilled', - status: 500, - details: { - token, - }, - }) - } - - // Redirect to success page - return redirect('/privacy/my-data?status=deleted') - } - - return redirect('/privacy/my-data?status=error') - } catch (error) { - apiContext.extra = { - ...(apiContext.extra || {}), - clientAddress, - } - handleApiFunctionError(error, apiContext) - return redirect('/privacy/my-data?status=error') - } -} diff --git a/src/pages/api/health/index.ts b/src/pages/api/health/index.ts index 6d3454347..16a09c38d 100644 --- a/src/pages/api/health/index.ts +++ b/src/pages/api/health/index.ts @@ -6,7 +6,10 @@ */ import type { APIRoute } from 'astro' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { + buildApiErrorResponse, + handleApiFunctionError +} from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false diff --git a/src/pages/api/newsletter/__tests__/_confirm.spec.ts b/src/pages/api/newsletter/__tests__/_confirm.spec.ts deleted file mode 100644 index 289804e12..000000000 --- a/src/pages/api/newsletter/__tests__/_confirm.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Unit tests for newsletter confirmation API endpoint - */ -import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' -import type { APIContext } from 'astro' -import type { PendingSubscription } from '@pages/api/newsletter/_token' -import { TestError } from '@test/errors' -import { GET } from '@pages/api/newsletter/confirm' - -const consentMocks = vi.hoisted(() => ({ - markConsentRecordsVerified: vi.fn(), -})) - -// Mock dependencies -vi.mock('@pages/api/newsletter/_token', () => ({ - confirmSubscription: vi.fn(), -})) - -vi.mock('@pages/api/newsletter/_email', () => ({ - sendWelcomeEmail: vi.fn(), -})) - -vi.mock('@pages/api/gdpr/_utils/consentStore', () => consentMocks) - -const createRequestContext = (inputUrl: string): APIContext => { - const url = new URL(inputUrl) - const request = new Request(url.toString(), { - method: 'GET', - headers: { - 'user-agent': 'Test Browser', - }, - }) - - return { - request, - url, - params: {}, - locals: {}, - redirect: vi.fn(), - } as unknown as APIContext -} - -vi.mock('@pages/api/newsletter/index', () => ({ - subscribeToConvertKit: vi.fn(), -})) - -const tokenModule = await import('@pages/api/newsletter/_token') -const emailModule = await import('@pages/api/newsletter/_email') -const convertKitModule = await import('@pages/api/newsletter/index') -const consentStoreModule = await import('@pages/api/gdpr/_utils/consentStore') - -const mockConfirmSubscription = tokenModule.confirmSubscription as Mock -const mockSendWelcomeEmail = emailModule.sendWelcomeEmail as Mock -const mockSubscribeToConvertKit = convertKitModule.subscribeToConvertKit as Mock -const mockMarkConsentRecordsVerified = consentStoreModule.markConsentRecordsVerified as Mock - -const buildSubscription = (overrides: Partial = {}): PendingSubscription => ({ - email: 'test@example.com', - firstName: 'John', - DataSubjectId: 'data-subject-123', - token: 'valid-token-123', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), - consentTimestamp: new Date().toISOString(), - userAgent: 'Test Browser', - ipAddress: '192.168.1.1', - verified: true, - source: 'newsletter_form', - ...overrides, -}) - -describe('Newsletter Confirmation API - GET /api/newsletter/confirm', () => { - beforeEach(() => { - vi.clearAllMocks() - // Suppress console output - vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - mockSendWelcomeEmail.mockResolvedValue(undefined) - mockMarkConsentRecordsVerified.mockResolvedValue(1) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('should confirm valid token and activate subscription', async () => { - const mockSubscription = buildSubscription() - - mockConfirmSubscription.mockResolvedValue(mockSubscription) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.status).toBe('success') - expect(data.email).toBe('test@example.com') - expect(data.message).toContain('confirmed') - - // Verify consent verification helper call - expect(mockMarkConsentRecordsVerified).toHaveBeenCalledWith('test@example.com', 'data-subject-123') - expect(mockMarkConsentRecordsVerified).toHaveBeenCalledTimes(1) - - // Verify welcome email was sent (force mock disabled by default) - expect(mockSendWelcomeEmail).toHaveBeenCalledWith( - 'test@example.com', - 'John' - ) - - expect(mockSubscribeToConvertKit).toHaveBeenCalledWith( - expect.objectContaining({ email: 'test@example.com' }), - ) - }) - - it('should reject request without token', async () => { - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm')) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('No token provided') - }) - - it('should handle expired or invalid token', async () => { - mockConfirmSubscription.mockResolvedValue(null) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=expired-token')) - const body = await response.json() - - expect(response.status).toBe(200) - expect(body.success).toBe(false) - expect(body.status).toBe('expired') - expect(body.message).toContain('expired') - }) - - it('should handle subscription without firstName', async () => { - const mockSubscription = buildSubscription({ firstName: undefined }) - - mockConfirmSubscription.mockResolvedValue(mockSubscription) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(mockSendWelcomeEmail).toHaveBeenCalledWith( - 'test@example.com', - undefined - ) - }) - - it('should handle subscription without ipAddress', async () => { - const mockSubscription = buildSubscription({ ipAddress: undefined }) - - mockConfirmSubscription.mockResolvedValue(mockSubscription) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - - expect(response.status).toBe(200) - expect(mockMarkConsentRecordsVerified).toHaveBeenCalledWith('test@example.com', 'data-subject-123') - }) - - it('should continue even if welcome email fails', async () => { - const mockSubscription = buildSubscription() - - mockConfirmSubscription.mockResolvedValue(mockSubscription) - mockSendWelcomeEmail.mockRejectedValue(new TestError('Email service down')) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - const data = await response.json() - - // Should still succeed - expect(response.status).toBe(200) - expect(data.success).toBe(true) - }) - - it('should handle confirmation service errors', async () => { - mockConfirmSubscription.mockRejectedValue(new TestError('Database error')) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - const body = await response.json() - - expect(response.status).toBe(500) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Unable to confirm subscription.') - }) - - it('should surface errors when consent verification fails', async () => { - const mockSubscription = buildSubscription() - mockConfirmSubscription.mockResolvedValue(mockSubscription) - mockMarkConsentRecordsVerified.mockRejectedValue(new TestError('Consent DB offline')) - - const response = await GET(createRequestContext('http://localhost/api/newsletter/confirm?token=valid-token-123')) - const body = await response.json() - - expect(response.status).toBe(500) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Unable to confirm subscription.') - }) -}) diff --git a/src/pages/api/newsletter/__tests__/_index.spec.ts b/src/pages/api/newsletter/__tests__/_index.spec.ts deleted file mode 100644 index 577765fac..000000000 --- a/src/pages/api/newsletter/__tests__/_index.spec.ts +++ /dev/null @@ -1,263 +0,0 @@ -/** - * Unit tests for newsletter subscription API endpoint - */ -import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' -import { TestError } from '@test/errors' -import { POST, OPTIONS } from '@pages/api/newsletter' - -const rateLimitMocks = vi.hoisted(() => ({ - rateLimiters: { - consent: {}, - }, - checkRateLimit: vi.fn().mockResolvedValue({ success: true }), -})) - -const consentMocks = vi.hoisted(() => ({ - recordConsent: vi.fn(), -})) - -// Mock dependencies -vi.mock('@pages/api/newsletter/_token', () => ({ - createPendingSubscription: vi.fn(), -})) - -vi.mock('@pages/api/newsletter/_email', () => ({ - sendConfirmationEmail: vi.fn(), -})) - -vi.mock('@pages/api/_logger', () => consentMocks) - -vi.mock('@pages/api/_utils/rateLimit', () => ({ - rateLimiters: rateLimitMocks.rateLimiters, - checkRateLimit: rateLimitMocks.checkRateLimit, - checkContactRateLimit: vi.fn(), -})) - -const tokenModule = await import('@pages/api/newsletter/_token') -const emailModule = await import('@pages/api/newsletter/_email') -const mockRecordConsent = consentMocks.recordConsent as Mock - -const mockCreatePendingSubscription = tokenModule.createPendingSubscription as Mock -const mockSendConfirmationEmail = emailModule.sendConfirmationEmail as Mock - -describe('Newsletter API - POST /api/newsletter', () => { - beforeEach(() => { - vi.clearAllMocks() - // Suppress console output - vi.spyOn(console, 'log').mockImplementation(() => {}) - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - - mockCreatePendingSubscription.mockResolvedValue('test-token-123') - mockSendConfirmationEmail.mockResolvedValue(undefined) - mockRecordConsent.mockResolvedValue({ - id: 'test-consent-id', - email: 'test@example.com', - purposes: ['marketing'], - timestamp: '2025-10-31T00:00:00.000Z', - source: 'newsletter_form', - userAgent: 'test-agent', - privacyPolicyVersion: '2025-10-20', - verified: false - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('should accept valid newsletter subscription with consent', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-forwarded-for': '192.168.1.1', - 'user-agent': 'Test Browser', - }, - body: JSON.stringify({ - email: 'test@example.com', - firstName: 'John', - consentGiven: true, - }), - }) - - const response = await POST({ request } as any) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(data.message).toContain('check your email') - expect(data.requiresConfirmation).toBe(true) - - // Verify mocks were called correctly - expect(mockRecordConsent).toHaveBeenCalledWith( - expect.objectContaining({ - email: 'test@example.com', - purposes: ['marketing'], - source: 'newsletter_form', - verified: false, - }), - ) - expect(mockCreatePendingSubscription).toHaveBeenCalledWith( - expect.objectContaining({ - email: 'test@example.com', - firstName: 'John', - }), - ) - expect(mockSendConfirmationEmail).toHaveBeenCalledWith( - 'test@example.com', - 'test-token-123', - 'John' - ) - }) - - it('should reject subscription without email', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - consentGiven: true, - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Email address is required') - }) - - it('should reject subscription with invalid email format', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'invalid-email', - consentGiven: true, - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('invalid') - }) - - it('should reject subscription without consent', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'test@example.com', - consentGiven: false, - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(400) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('consent') - }) - - it('should normalize email to lowercase', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'TEST@EXAMPLE.COM', - consentGiven: true, - }), - }) - - await POST({ request } as any) - - expect(mockRecordConsent).toHaveBeenCalledWith( - expect.objectContaining({ - email: 'test@example.com', - }), - ) - }) - - it('should bypass rate limiting in test environment', async () => { - // In test/dev/CI environments, rate limiting is disabled - // This test verifies that we can make unlimited requests - const ip = '192.168.1.100' - const headers = { - 'Content-Type': 'application/json', - 'x-forwarded-for': ip, - } - - // Make 20 requests - normally limited to 10 per 15 minutes - // All should succeed because rate limiting is bypassed - for (let i = 0; i < 20; i++) { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers, - body: JSON.stringify({ - email: `test${i}@example.com`, - consentGiven: true, - }), - }) - const response = await POST({ request } as any) - expect(response.status).toBe(200) - } - }) - - it('should handle missing firstName gracefully', async () => { - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'test@example.com', - consentGiven: true, - }), - }) - - const response = await POST({ request } as any) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.success).toBe(true) - expect(mockSendConfirmationEmail).toHaveBeenCalledWith( - 'test@example.com', - 'test-token-123', - undefined - ) - }) - - it('should handle service errors gracefully', async () => { - mockCreatePendingSubscription.mockRejectedValue(new TestError('Service unavailable')) - - const request = new Request('http://localhost/api/newsletter', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: 'test@example.com', - consentGiven: true, - }), - }) - - const response = await POST({ request } as any) - const body = await response.json() - - expect(response.status).toBe(500) - expect(body.error).toBeDefined() - expect(body.error.message).toContain('Failed to process newsletter request.') - }) -}) - -describe('Newsletter API - OPTIONS /api/newsletter', () => { - it('should return CORS headers', async () => { - const response = await OPTIONS({} as any) - - expect(response.status).toBe(200) - expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*') - expect(response.headers.get('Access-Control-Allow-Methods')).toContain('POST') - expect(response.headers.get('Access-Control-Allow-Headers')).toContain('Content-Type') - }) -}) diff --git a/src/pages/api/newsletter/_email.ts b/src/pages/api/newsletter/_email.ts deleted file mode 100644 index 19512b3e0..000000000 --- a/src/pages/api/newsletter/_email.ts +++ /dev/null @@ -1,434 +0,0 @@ -/** - * Newsletter confirmation email service - * Sends double opt-in confirmation emails using Resend - */ - -import { Resend } from 'resend' -import { getResendApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' -import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' - -/** - * Initialize Resend client - */ -function getResendClient(): Resend { - return new Resend(getResendApiKey()) -} - -/** - * Generate the HTML content for the confirmation email - */ -function generateConfirmationEmailHtml( - firstName: string | undefined, - confirmUrl: string, - expiresIn: string = '24 hours' -): string { - const greeting = firstName ? `Hi ${firstName}` : 'Hello' - - return ` - - - - - - Confirm Your Newsletter Subscription - - - -
- -
- -
-

Confirm Your Subscription

- -

${greeting},

- -

Thank you for subscribing to the Webstack Builders newsletter! To complete your subscription and start receiving our latest articles, insights, and updates, please confirm your email address.

- - - -

Or copy and paste this link into your browser:

-

${confirmUrl}

- -
-

Why did I receive this?

-

You're receiving this email because someone (hopefully you!) entered this email address on our website to subscribe to our newsletter. If you didn't request this, you can safely ignore this email.

-
- -
-

⏰ This confirmation link expires in ${expiresIn}

-

For security reasons, this confirmation link will only work once and will expire after ${expiresIn}.

-
- -

What You're Consenting To

-
    -
  • Purpose: Receiving marketing emails and newsletters
  • -
  • Frequency: Weekly articles and occasional updates
  • -
  • Your Rights: You can unsubscribe at any time using the link in every email
  • -
  • Data Usage: We'll only use your email to send you the content you signed up for
  • -
-
- - - - - `.trim() -} - -/** - * Generate plain text version of the confirmation email - */ -function generateConfirmationEmailText( - firstName: string | undefined, - confirmUrl: string, - expiresIn: string = '24 hours' -): string { - const greeting = firstName ? `Hi ${firstName}` : 'Hello' - - return ` -Webstack Builders - Confirm Your Subscription - -${greeting}, - -Thank you for subscribing to the Webstack Builders newsletter! To complete your subscription and start receiving our latest articles, insights, and updates, please confirm your email address. - -Confirm your subscription by clicking this link: -${confirmUrl} - -WHY DID I RECEIVE THIS? -You're receiving this email because someone (hopefully you!) entered this email address on our website to subscribe to our newsletter. If you didn't request this, you can safely ignore this email. - -IMPORTANT: This confirmation link expires in ${expiresIn} -For security reasons, this confirmation link will only work once and will expire after ${expiresIn}. - -WHAT YOU'RE CONSENTING TO: -- Purpose: Receiving marketing emails and newsletters -- Frequency: Weekly articles and occasional updates -- Your Rights: You can unsubscribe at any time using the link in every email -- Data Usage: We'll only use your email to send you the content you signed up for - -Questions? Contact us at hello@webstackbuilders.com -Privacy Policy: ${getSiteUrl()}/privacy -Unsubscribe: ${getSiteUrl()}/privacy#unsubscribe - -© ${new Date().getFullYear()} Webstack Builders. All rights reserved. - `.trim() -} - -/** - * Send confirmation email to subscriber - * - * @param email - Subscriber's email address - * @param token - Confirmation token - * @param firstName - Optional subscriber first name for personalization - * @returns Promise that resolves when email is sent - * @throws {Error} If Resend API key is not configured or email fails to send - */ -export async function sendConfirmationEmail( - email: string, - token: string, - firstName?: string -): Promise { - const siteUrl = getSiteUrl() - const confirmUrl = `${siteUrl}/newsletter/confirm/${token}` - const expiresIn = '24 hours' - - // Skip actual email sending in dev/test (handled by Astro Actions later) - if (isDev() || isTest()) { - console.log('[DEV/TEST MODE] Newsletter confirmation email would be sent:', { email, token }) - return - } - - const resendPayload = { - from: 'Webstack Builders ', - to: email, - subject: 'Confirm your newsletter subscription - Webstack Builders', - html: generateConfirmationEmailHtml(firstName, confirmUrl, expiresIn), - text: generateConfirmationEmailText(firstName, confirmUrl, expiresIn), - tags: [ - { name: 'type', value: 'newsletter-confirmation' }, - { name: 'flow', value: 'double-optin' }, - ], - } - - const handleSendError = (error: unknown) => { - console.error('[Newsletter Email] Error sending confirmation:', error) - throw new ApiFunctionError(error, { - message: 'Failed to send confirmation email. Please try again later.', - code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED', - status: 502, - route: '/api/newsletter', - operation: 'sendConfirmationEmail' - }) - } - - const resend = getResendClient() - - try { - const result = await resend.emails.send(resendPayload) - - if (result.error) { - console.error('[Newsletter Email] Failed to send confirmation:', result.error) - throw new ApiFunctionError({ - message: `Failed to send confirmation email: ${result.error.message}`, - code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED', - status: 502, - route: '/api/newsletter', - operation: 'sendConfirmationEmail' - }) - } - - console.log('[Newsletter Email] Confirmation sent successfully:', { - email, - messageId: result.data?.id, - }) - } catch (error) { - handleSendError(error) - } -} - -/** - * Send welcome email after subscription is confirmed - * This is sent after the user clicks the confirmation link - * - * @param email - Subscriber's email address - * @param firstName - Optional subscriber first name for personalization - */ -export async function sendWelcomeEmail( - email: string, - firstName?: string -): Promise { - if (isDev() || isTest()) { - console.log('[DEV/TEST MODE] Newsletter welcome email would be sent:', { email }) - return - } - - const resend = getResendClient() - const greeting = firstName ? `Hi ${firstName}` : 'Hello' - - const html = ` - - - - - - Welcome to Webstack Builders - - - -
- -
- -
-

🎉 Welcome to Webstack Builders!

- -

${greeting},

- -

Your subscription is now confirmed! Thank you for joining our community of developers, designers, and tech enthusiasts.

- -

You'll now receive our latest articles, tutorials, and insights directly in your inbox. We're committed to delivering high-quality content that helps you build better web experiences.

- - - -

What to Expect

-
    -
  • Weekly articles on web development, design, and best practices
  • -
  • Tutorials and guides for modern web technologies
  • -
  • Case studies and real-world examples
  • -
  • Occasional updates about new features and offerings
  • -
- -

Need to manage your subscription? You can unsubscribe at any time using the link at the bottom of any email we send you.

-

If you'd like to unsubscribe right now, click here.

-
- - - - - `.trim() - - const text = ` -Webstack Builders - Welcome! - -${greeting}, - -Your subscription is now confirmed! Thank you for joining our community of developers, designers, and tech enthusiasts. - -You'll now receive our latest articles, tutorials, and insights directly in your inbox. We're committed to delivering high-quality content that helps you build better web experiences. - -Browse our articles: ${getSiteUrl()}/articles - -WHAT TO EXPECT: -- Weekly articles on web development, design, and best practices -- Tutorials and guides for modern web technologies -- Case studies and real-world examples -- Occasional updates about new features and offerings - -Need to manage your subscription? You can unsubscribe at any time using the link at the bottom of any email we send you. -Unsubscribe: ${getSiteUrl()}/privacy#unsubscribe - -Questions? Reply to this email or contact us at hello@webstackbuilders.com - -© ${new Date().getFullYear()} Webstack Builders. All rights reserved. - `.trim() - - const resendPayload = { - from: 'Webstack Builders ', - to: email, - subject: '🎉 Welcome to Webstack Builders!', - html, - text, - tags: [ - { name: 'type', value: 'newsletter-welcome' }, - { name: 'flow', value: 'post-confirmation' }, - ], - } - - const handleSendError = (error: unknown) => { - console.error('[Newsletter Email] Error sending welcome email:', error) - throw new ApiFunctionError(error, { - message: 'Failed to send welcome email. Please try again later.', - code: 'NEWSLETTER_WELCOME_EMAIL_FAILED', - status: 502, - route: '/api/newsletter', - operation: 'sendWelcomeEmail' - }) - } - - try { - const result = await resend.emails.send(resendPayload) - - if (result.error) { - console.error('[Newsletter Email] Failed to send welcome email:', result.error) - throw new ApiFunctionError({ - message: `Failed to send welcome email: ${result.error.message}`, - code: 'NEWSLETTER_WELCOME_EMAIL_FAILED', - status: 502, - route: '/api/newsletter', - operation: 'sendWelcomeEmail' - }) - } - - console.log('[Newsletter Email] Welcome email sent successfully:', { - email, - messageId: result.data?.id, - }) - } catch (error) { - handleSendError(error) - } -} diff --git a/src/pages/api/newsletter/_token.ts b/src/pages/api/newsletter/_token.ts deleted file mode 100644 index 02978436a..000000000 --- a/src/pages/api/newsletter/_token.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Newsletter subscription token management for double opt-in - * Generates and validates confirmation tokens for GDPR-compliant newsletter signups - */ - -import { randomUUID } from 'node:crypto' -import { and, db, eq, isNull, newsletterConfirmations } from 'astro:db' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' - -/** - * Pending subscription data stored temporarily until confirmed - */ -export interface PendingSubscription { - email: string - firstName?: string | undefined - DataSubjectId: string - token: string - createdAt: string // ISO 8601 - expiresAt: string // ISO 8601 - 24 hours from creation - consentTimestamp: string // ISO 8601 - userAgent: string - ipAddress?: string | undefined // Optional, for fraud prevention only - verified: boolean - source: 'newsletter_form' | 'contact_form' -} - -/** - * In-memory storage for pending subscriptions - * In production, use Redis, database, or Vercel KV - */ -const pendingSubscriptions = new Map() - -/** - * Generate cryptographically secure token - * Uses Web Crypto API for secure random generation - */ -export function generateConfirmationToken(): string { - // Generate 32 random bytes and encode as base64url (URL-safe) - const array = new Uint8Array(32) - crypto.getRandomValues(array) - return Buffer.from(array).toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=/g, '') -} - -/** - * Create and store a pending subscription - * Returns the confirmation token to be sent via email - */ -export async function createPendingSubscription(data: { - email: string - firstName?: string - DataSubjectId: string - userAgent: string - ipAddress?: string - source: 'newsletter_form' | 'contact_form' -}): Promise { - const token = generateConfirmationToken() - const now = new Date() - const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000) // 24 hours - - const pending: PendingSubscription = { - email: data.email.toLowerCase().trim(), - ...(data.firstName && { firstName: data.firstName.trim() }), - DataSubjectId: data.DataSubjectId, - token, - createdAt: now.toISOString(), - expiresAt: expiresAt.toISOString(), - consentTimestamp: now.toISOString(), - userAgent: data.userAgent, - ...(data.ipAddress && { ipAddress: data.ipAddress }), - verified: false, - source: data.source, - } - - try { - await db.insert(newsletterConfirmations).values({ - id: randomUUID(), - token, - email: pending.email, - dataSubjectId: pending.DataSubjectId, - firstName: pending.firstName ?? null, - source: pending.source, - userAgent: pending.userAgent, - ipAddress: pending.ipAddress ?? null, - consentTimestamp: new Date(pending.consentTimestamp), - expiresAt, - confirmedAt: null, - createdAt: now, - }) - } catch (error) { - throw new ApiFunctionError({ - message: 'Failed to create subscription confirmation', - cause: error, - code: 'NEWSLETTER_TOKEN_CREATE_FAILED', - status: 500, - route: '/api/newsletter', - operation: 'createPendingSubscription', - }) - } - - // Also keep in memory for backward compatibility (for now) - pendingSubscriptions.set(token, pending) - - // Clean up expired tokens (simple garbage collection) - cleanExpiredTokens() - - return token -} - -/** - * Validate and retrieve pending subscription by token - * Returns null if token is invalid or expired - */ -export async function validateToken( - token: string, -): Promise { - const [dbRecord] = await db - .select() - .from(newsletterConfirmations) - .where( - and( - eq(newsletterConfirmations.token, token), - isNull(newsletterConfirmations.confirmedAt), - ), - ) - .limit(1) - - if (dbRecord) { - const now = new Date() - const expiresAt = new Date(dbRecord.expiresAt) - - if (now > expiresAt) { - return null - } - - return { - email: dbRecord.email, - firstName: dbRecord.firstName ?? undefined, - DataSubjectId: dbRecord.dataSubjectId, - token: dbRecord.token, - createdAt: dbRecord.createdAt.toISOString(), - expiresAt: dbRecord.expiresAt.toISOString(), - consentTimestamp: dbRecord.consentTimestamp.toISOString(), - userAgent: dbRecord.userAgent ?? 'unknown', - ipAddress: dbRecord.ipAddress ?? undefined, - verified: false, - source: dbRecord.source as PendingSubscription['source'], - } - } - - // Fallback to in-memory (for backward compatibility) - const pending = pendingSubscriptions.get(token) - - if (!pending) { - return null - } - - // Check if expired - const now = new Date() - const expiresAt = new Date(pending.expiresAt) - - if (now > expiresAt) { - // Token expired, remove it - pendingSubscriptions.delete(token) - return null - } - - // Check if already verified - if (pending.verified) { - return null - } - - return pending -} - -/** - * Mark subscription as verified and remove from pending - * Returns the subscription data for processing - */ -export async function confirmSubscription( - token: string, -): Promise { - const pending = await validateToken(token) - - if (!pending) { - return null - } - - await db - .update(newsletterConfirmations) - .set({ confirmedAt: new Date() }) - .where(eq(newsletterConfirmations.token, token)) - - // Mark as verified - pending.verified = true - - // Remove from in-memory pending (one-time use token) - pendingSubscriptions.delete(token) - - return pending -} - -/** - * Clean up expired tokens from storage - * Should be called periodically or on each new subscription - */ -function cleanExpiredTokens(): void { - const now = new Date() - - for (const [token, pending] of pendingSubscriptions.entries()) { - const expiresAt = new Date(pending.expiresAt) - if (now > expiresAt) { - pendingSubscriptions.delete(token) - } - } -} - -/** - * Get all pending subscriptions (for testing/debugging) - * Should be removed or protected in production - */ -export function getPendingCount(): number { - return pendingSubscriptions.size -} - -export async function deleteNewsletterConfirmationsByEmail(email: string): Promise { - const normalizedEmail = email.trim().toLowerCase() - const deleted = await db - .delete(newsletterConfirmations) - .where(eq(newsletterConfirmations.email, normalizedEmail)) - .returning({ id: newsletterConfirmations.id }) - - return deleted.length -} diff --git a/src/pages/api/newsletter/confirm.ts b/src/pages/api/newsletter/confirm.ts deleted file mode 100644 index 4ae5d3c0f..000000000 --- a/src/pages/api/newsletter/confirm.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Newsletter Confirmation API Endpoint - * Handles token validation and subscription confirmation - * This is an Astro API route that runs server-side - */ -import type { APIRoute } from 'astro' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { createApiFunctionContext } from '@pages/api/_utils/requestContext' -import { markConsentRecordsVerified } from '@pages/api/gdpr/_utils/consentStore' - -// These imports work in Astro API routes because they run server-side -import { confirmSubscription } from './_token' -import { sendWelcomeEmail } from './_email' - -export const prerender = false // Force SSR for this endpoint - -const ROUTE = '/api/newsletter/confirm' - -const jsonResponse = (body: Record, status: number) => - new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }) - -export const GET: APIRoute = async ({ url, request, cookies, clientAddress }) => { - const { context: apiContext } = createApiFunctionContext({ - route: ROUTE, - operation: 'GET', - request, - cookies, - clientAddress, - }) - - const token = url.searchParams.get('token') - apiContext.extra = { ...(apiContext.extra || {}), token } - - try { - if (!token) { - throw new ApiFunctionError({ - message: 'No token provided', - status: 400, - code: 'TOKEN_REQUIRED', - }) - } - - // Validate and confirm the subscription - const subscription = await confirmSubscription(token) - - if (!subscription) { - return jsonResponse( - { - success: false, - status: 'expired', - message: 'This confirmation link has expired or been used already.', - }, - 200, - ) - } - - try { - await markConsentRecordsVerified(subscription.email, subscription.DataSubjectId) - } catch (error) { - throw new ApiFunctionError(error, { - route: ROUTE, - operation: 'verify-consent-record', - status: 500, - details: { - email: subscription.email, - dataSubjectId: subscription.DataSubjectId, - }, - }) - } - - // Send welcome email (non-blocking, don't fail if it errors) - try { - await sendWelcomeEmail(subscription.email, subscription.firstName) - } catch (emailError) { - handleApiFunctionError(emailError, { - ...apiContext, - operation: 'send-welcome-email', - extra: { - email: subscription.email, - }, - }) - } - - // Add to ConvertKit with verified status - try { - const { subscribeToConvertKit } = await import('@pages/api/newsletter/index') - await subscribeToConvertKit({ - email: subscription.email, - ...(subscription.firstName ? { firstName: subscription.firstName } : {}), - }) - } catch (convertKitError) { - handleApiFunctionError(convertKitError, { - ...apiContext, - operation: 'subscribe-convertkit', - extra: { - email: subscription.email, - }, - }) - } - - return jsonResponse( - { - success: true, - status: 'success', - email: subscription.email, - message: 'Your subscription has been confirmed!', - }, - 200, - ) - } catch (error) { - const serverError = handleApiFunctionError(error, apiContext) - - return buildApiErrorResponse(serverError, { - fallbackMessage: 'Unable to confirm subscription.', - }) - } -} diff --git a/src/pages/api/newsletter/index.ts b/src/pages/api/newsletter/index.ts deleted file mode 100644 index 5026bf282..000000000 --- a/src/pages/api/newsletter/index.ts +++ /dev/null @@ -1,317 +0,0 @@ -/** - * Astro API endpoint for ConvertKit newsletter subscription - * Implements GDPR-compliant double opt-in flow - * - * With Vercel adapter, this becomes a serverless function automatically - */ -import type { APIRoute } from 'astro' -import { v4 as uuidv4, validate as uuidValidate } from 'uuid' -import emailValidator from 'email-validator' -import { getConvertkitApiKey, isDev, isTest } from '@pages/api/_environment/environmentApi' -import { ApiFunctionError } from '@pages/api/_errors/ApiFunctionError' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' -import { rateLimiters, checkRateLimit } from '@pages/api/_utils/rateLimit' -import { createApiFunctionContext, createRateLimitIdentifier } from '@pages/api/_utils/requestContext' -import { recordConsent } from '@pages/api/_logger' -import { createPendingSubscription } from './_token' -import { sendConfirmationEmail } from './_email' - -export const prerender = false // Force SSR for this endpoint - -// Types -interface NewsletterFormData { - email: string - firstName?: string - consentGiven?: boolean - DataSubjectId?: string // Optional - will be generated if not provided -} - -interface ConvertKitSubscriber { - email_address: string - first_name?: string - state?: 'active' | 'inactive' - fields?: Record -} - -interface ConvertKitResponse { - subscriber: { - id: number - first_name: string | null - email_address: string - state: string - created_at: string - fields: Record - } -} - -interface ConvertKitErrorResponse { - errors: string[] -} - -/** - * Validate email address format and length - */ -function validateEmail(email: string): string { - if (!email) { - throw new ApiFunctionError({ - message: 'Email address is required.', - status: 400, - code: 'INVALID_EMAIL', - route: '/api/newsletter', - operation: 'validateEmail' - }) - } - - // RFC 5321 specifies max email length of 254 characters - if (email.length > 254) { - throw new ApiFunctionError({ - message: 'Email address is too long', - status: 400, - code: 'INVALID_EMAIL', - route: '/api/newsletter', - operation: 'validateEmail' - }) - } - - if (!emailValidator.validate(email)) { - throw new ApiFunctionError({ - message: 'Email address is invalid', - status: 400, - code: 'INVALID_EMAIL', - route: '/api/newsletter', - operation: 'validateEmail' - }) - } - - return email.trim().toLowerCase() -} - -/** - * Subscribe email to ConvertKit - */ -export async function subscribeToConvertKit( - data: NewsletterFormData -): Promise { - // Skip actual ConvertKit API call in dev/test environments - if (isDev() || isTest()) { - console.log('[DEV/TEST MODE] Newsletter subscription would be created:', { email: data.email }) - // Return mock success response - return { - subscriber: { - id: 999999, - state: 'active', - email_address: data.email, - first_name: data.firstName || null, - created_at: new Date().toISOString(), - fields: {}, - }, - } - } - - const subscriberData: ConvertKitSubscriber = { - email_address: data.email, - state: 'active', - } - - if (data.firstName) { - subscriberData.first_name = data.firstName.trim() - } - - try { - const response = await fetch('https://api.kit.com/v4/subscribers', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Kit-Api-Key': getConvertkitApiKey(), - }, - body: JSON.stringify(subscriberData), - }) - - const responseData = await response.json() - - if (response.status === 401) { - const errorData = responseData as ConvertKitErrorResponse - console.error('ConvertKit API authentication failed:', errorData.errors) - throw new ApiFunctionError({ - message: 'Newsletter service configuration error. Please contact support.', - status: 502, - code: 'CONVERTKIT_AUTH', - route: '/api/newsletter', - operation: 'subscribeToConvertKit' - }) - } - - if (response.status === 422) { - const errorData = responseData as ConvertKitErrorResponse - throw new ApiFunctionError({ - message: errorData.errors[0] || 'Invalid email address', - status: 400, - code: 'INVALID_EMAIL', - route: '/api/newsletter', - operation: 'subscribeToConvertKit' - }) - } - - if (response.status === 200 || response.status === 201 || response.status === 202) { - return responseData as ConvertKitResponse - } - - throw new ApiFunctionError({ - message: 'An unexpected error occurred. Please try again later.', - status: 502, - code: 'CONVERTKIT_UNKNOWN', - route: '/api/newsletter', - operation: 'subscribeToConvertKit' - }) - } catch (error) { - throw new ApiFunctionError({ - message: 'Failed to connect to newsletter service. Please try again later.', - cause: error, - status: 502, - code: 'CONVERTKIT_NETWORK', - route: '/api/newsletter', - operation: 'subscribeToConvertKit' - }) - } -} - -/** - * Main API handler for newsletter subscriptions - */ -export const POST: APIRoute = async ({ request, cookies, clientAddress }) => { - const { context: apiContext, fingerprint } = createApiFunctionContext({ - route: '/api/newsletter', - operation: 'POST', - request, - cookies, - clientAddress, - }) - - const userAgent = request.headers.get('user-agent') || 'unknown' - apiContext.extra = { ...(apiContext.extra || {}), userAgent } - - try { - const rateLimitIdentifier = createRateLimitIdentifier('newsletter:consent', fingerprint) - const consentLimiter = rateLimiters['consent'] - - if (!consentLimiter) { - throw new ApiFunctionError({ - message: 'Rate limiting is not configured for newsletter subscriptions.', - status: 500, - code: 'RATE_LIMIT_NOT_CONFIGURED', - }) - } - - const { success, reset } = await checkRateLimit(consentLimiter, rateLimitIdentifier) - - if (!success) { - const retryAfterMs = typeof reset === 'number' ? Math.max(0, reset - Date.now()) : 0 - const retryAfterSeconds = Math.max(1, Math.ceil(retryAfterMs / 1000)) - throw new ApiFunctionError({ - message: `Try again in ${retryAfterSeconds}s`, - status: 429, - code: 'RATE_LIMIT_EXCEEDED', - details: { retryAfterSeconds }, - }) - } - - let body: NewsletterFormData - try { - body = await request.json() - } catch { - throw new ApiFunctionError({ - message: 'Invalid JSON payload', - status: 400, - code: 'INVALID_JSON', - }) - } - - const { email, firstName, consentGiven, DataSubjectId } = body - const validatedEmail = validateEmail(email) - - if (!consentGiven) { - throw new ApiFunctionError({ - message: 'You must consent to receive marketing emails to subscribe.', - status: 400, - code: 'CONSENT_REQUIRED', - }) - } - - let subjectId = DataSubjectId - if (!subjectId) { - subjectId = uuidv4() - } else if (!uuidValidate(subjectId)) { - throw new ApiFunctionError({ - message: 'Invalid DataSubjectId format', - status: 400, - code: 'INVALID_UUID', - }) - } - - await recordConsent({ - origin: new URL(request.url).origin, - DataSubjectId: subjectId, - email: validatedEmail, - purposes: ['marketing'], - source: 'newsletter_form', - userAgent, - ...(clientAddress && clientAddress !== 'unknown' && { ipAddress: clientAddress }), - verified: false, - }) - - const token = await createPendingSubscription({ - email: validatedEmail, - ...(firstName && { firstName }), - DataSubjectId: subjectId, - userAgent, - ...(clientAddress && clientAddress !== 'unknown' && { ipAddress: clientAddress }), - source: 'newsletter_form', - }) - - await sendConfirmationEmail(validatedEmail, token, firstName) - - return new Response( - JSON.stringify({ - success: true, - message: 'Please check your email to confirm your subscription.', - requiresConfirmation: true, - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }, - ) - } catch (error) { - const serverError = handleApiFunctionError(error, apiContext) - const retryAfterSecondsRaw = serverError.details?.['retryAfterSeconds'] - const retryAfterSeconds = - typeof retryAfterSecondsRaw === 'number' - ? Math.max(1, Math.ceil(retryAfterSecondsRaw)) - : undefined - - const responseOptions: { - fallbackMessage: string - headers?: HeadersInit - } = { - fallbackMessage: 'Failed to process newsletter request.', - } - - if (retryAfterSeconds) { - responseOptions.headers = { 'Retry-After': String(retryAfterSeconds) } - } - - return buildApiErrorResponse(serverError, responseOptions) - } -} - -// Handle OPTIONS for CORS -export const OPTIONS: APIRoute = async () => { - return new Response(null, { - status: 200, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - }, - }) -} diff --git a/src/pages/api/social-card/index.ts b/src/pages/api/social-card/index.ts index a9a1fb097..e5b5b0a64 100644 --- a/src/pages/api/social-card/index.ts +++ b/src/pages/api/social-card/index.ts @@ -2,7 +2,10 @@ import { fileURLToPath } from 'node:url' import type { APIRoute } from 'astro' import { getCollection } from 'astro:content' import { generateOpenGraphImage } from 'astro-og-canvas' -import { buildApiErrorResponse, handleApiFunctionError } from '@pages/api/_errors/apiFunctionHandler' +import { + buildApiErrorResponse, + handleApiFunctionError +} from '@pages/api/_utils/errors' import { createApiFunctionContext } from '@pages/api/_utils/requestContext' export const prerender = false From 53f38498cb97bfe3897d8c266c72cb28dd16b4f9 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 23 Dec 2025 05:03:46 +0300 Subject: [PATCH 05/10] Move src/actions files into action responder domain pattern --- src/actions/{contact.ts => contact/responder.ts} | 2 +- src/actions/{downloads.ts => downloads/responder.ts} | 0 src/actions/gdpr/{_utils => domain}/consentStore.ts | 0 src/actions/gdpr/{_utils => domain}/dsarStore.ts | 0 src/actions/{gdpr.ts => gdpr/responder.ts} | 6 +++--- src/actions/index.ts | 8 ++++---- src/actions/newsletter/{_token.ts => action.ts} | 0 src/actions/newsletter/{_email.ts => entities.ts} | 0 src/actions/{newsletter.ts => newsletter/responder.ts} | 6 +++--- src/pages/rss.xml.ts | 2 +- 10 files changed, 12 insertions(+), 12 deletions(-) rename src/actions/{contact.ts => contact/responder.ts} (99%) rename src/actions/{downloads.ts => downloads/responder.ts} (100%) rename src/actions/gdpr/{_utils => domain}/consentStore.ts (100%) rename src/actions/gdpr/{_utils => domain}/dsarStore.ts (100%) rename src/actions/{gdpr.ts => gdpr/responder.ts} (99%) rename src/actions/newsletter/{_token.ts => action.ts} (100%) rename src/actions/newsletter/{_email.ts => entities.ts} (100%) rename src/actions/{newsletter.ts => newsletter/responder.ts} (98%) diff --git a/src/actions/contact.ts b/src/actions/contact/responder.ts similarity index 99% rename from src/actions/contact.ts rename to src/actions/contact/responder.ts index cd5f33a99..f366ce1b7 100644 --- a/src/actions/contact.ts +++ b/src/actions/contact/responder.ts @@ -6,7 +6,7 @@ import { ActionError, defineAction } from 'astro:actions' import { checkContactRateLimit } from '@actions/_utils/rateLimit' import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/_utils/requestContext' import { getPrivacyPolicyVersion, getResendApiKey, isDev, isTest } from '@actions/_environment/environmentActions' -import { createConsentRecord } from '@actions/gdpr/_utils/consentStore' +import { createConsentRecord } from '@actions/gdpr/domain/consentStore' type ContactFormData = { name: string diff --git a/src/actions/downloads.ts b/src/actions/downloads/responder.ts similarity index 100% rename from src/actions/downloads.ts rename to src/actions/downloads/responder.ts diff --git a/src/actions/gdpr/_utils/consentStore.ts b/src/actions/gdpr/domain/consentStore.ts similarity index 100% rename from src/actions/gdpr/_utils/consentStore.ts rename to src/actions/gdpr/domain/consentStore.ts diff --git a/src/actions/gdpr/_utils/dsarStore.ts b/src/actions/gdpr/domain/dsarStore.ts similarity index 100% rename from src/actions/gdpr/_utils/dsarStore.ts rename to src/actions/gdpr/domain/dsarStore.ts diff --git a/src/actions/gdpr.ts b/src/actions/gdpr/responder.ts similarity index 99% rename from src/actions/gdpr.ts rename to src/actions/gdpr/responder.ts index 8ac9737a4..8b1bcd0e7 100644 --- a/src/actions/gdpr.ts +++ b/src/actions/gdpr/responder.ts @@ -13,15 +13,15 @@ import { findConsentRecords, findConsentRecordsByEmail, type ConsentEventRecord, -} from '@actions/gdpr/_utils/consentStore' +} from '@actions/gdpr/domain/consentStore' import { createDsarRequest, findActiveRequestByEmail, findDsarRequestByToken, markDsarRequestFulfilled, -} from '@actions/gdpr/_utils/dsarStore' +} from '@actions/gdpr/domain/dsarStore' import { sendDsarVerificationEmail } from '@actions/gdpr/_dsarVerificationEmails' -import { deleteNewsletterConfirmationsByEmail } from '@actions/newsletter/_token' +import { deleteNewsletterConfirmationsByEmail } from '@actions/newsletter/action' const CONSENT_PURPOSES = ['contact', 'marketing', 'analytics', 'downloads'] as const type ConsentPurpose = (typeof CONSENT_PURPOSES)[number] diff --git a/src/actions/index.ts b/src/actions/index.ts index 22b83e513..66b0755e7 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,7 +1,7 @@ -import { contact } from './contact' -import { downloads } from './downloads' -import { gdpr } from './gdpr' -import { newsletter } from './newsletter' +import { contact } from './contact/responder' +import { downloads } from './downloads/responder' +import { gdpr } from './gdpr/responder' +import { newsletter } from './newsletter/responder' export const server = { contact, diff --git a/src/actions/newsletter/_token.ts b/src/actions/newsletter/action.ts similarity index 100% rename from src/actions/newsletter/_token.ts rename to src/actions/newsletter/action.ts diff --git a/src/actions/newsletter/_email.ts b/src/actions/newsletter/entities.ts similarity index 100% rename from src/actions/newsletter/_email.ts rename to src/actions/newsletter/entities.ts diff --git a/src/actions/newsletter.ts b/src/actions/newsletter/responder.ts similarity index 98% rename from src/actions/newsletter.ts rename to src/actions/newsletter/responder.ts index b51bb4b41..4d941d5db 100644 --- a/src/actions/newsletter.ts +++ b/src/actions/newsletter/responder.ts @@ -5,9 +5,9 @@ import { z } from 'astro:schema' import { getConvertkitApiKey, getPrivacyPolicyVersion, isDev, isTest } from '@actions/_environment/environmentActions' import { checkRateLimit, rateLimiters } from '@actions/_utils/rateLimit' import { buildRequestFingerprint, createRateLimitIdentifier } from '@actions/_utils/requestContext' -import { createConsentRecord, markConsentRecordsVerified } from '@actions/gdpr/_utils/consentStore' -import { createPendingSubscription, confirmSubscription } from '@actions/newsletter/_token' -import { sendConfirmationEmail, sendWelcomeEmail } from '@actions/newsletter/_email' +import { createConsentRecord, markConsentRecordsVerified } from '@actions/gdpr/domain/consentStore' +import { createPendingSubscription, confirmSubscription } from './action' +import { sendConfirmationEmail, sendWelcomeEmail } from '@actions/newsletter/entities' type NewsletterFormData = { email: string diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts index 573f79e1d..d38e94d98 100644 --- a/src/pages/rss.xml.ts +++ b/src/pages/rss.xml.ts @@ -1,7 +1,7 @@ import rss from '@astrojs/rss' import { getCollection } from 'astro:content' import type { APIContext } from 'astro' -import { ApiFunctionError } from './api/_errors/ApiFunctionError' +import { ApiFunctionError } from './api/_utils/errors' export async function GET(context: APIContext) { if (!context.site) { From d44bfa6b93cf3ffd3d870e581781e35734816ce1 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 23 Dec 2025 05:08:14 +0300 Subject: [PATCH 06/10] Update endpoints and other tweaks to make 03-forms playwright tests play nice with new Astro action system from API endpoints --- .../scripts/api/__tests__/gdpr.client.spec.ts | 2 +- src/components/scripts/api/gdpr.client.ts | 2 +- src/lib/api/gdpr.client.ts | 2 +- test/e2e/helpers/fetchOverride.ts | 260 +++++++++--------- test/e2e/helpers/pageObjectModels/BasePage.ts | 51 +++- .../pageObjectModels/NewsletterPage.ts | 6 +- .../specs/03-forms/consent-checkbox.spec.ts | 30 +- test/e2e/specs/03-forms/contact-form.spec.ts | 19 +- .../03-forms/newsletter-double-optin.spec.ts | 40 +-- .../03-forms/newsletter-subscription.spec.ts | 51 ++-- .../04-components/consentPreferences.spec.ts | 2 +- test/e2e/specs/15-cron/cron.spec.ts | 2 +- 12 files changed, 220 insertions(+), 247 deletions(-) diff --git a/src/components/scripts/api/__tests__/gdpr.client.spec.ts b/src/components/scripts/api/__tests__/gdpr.client.spec.ts index ae5c5f673..272e36064 100644 --- a/src/components/scripts/api/__tests__/gdpr.client.spec.ts +++ b/src/components/scripts/api/__tests__/gdpr.client.spec.ts @@ -11,7 +11,7 @@ import type { DSARRequestInput, DSARResponse, ErrorResponse, -} from '@pages/api/_contracts/gdpr.contracts' +} from '@actions/_contracts/gdpr.contracts' const fetchSpy = vi.fn() diff --git a/src/components/scripts/api/gdpr.client.ts b/src/components/scripts/api/gdpr.client.ts index 887452534..0913f2632 100644 --- a/src/components/scripts/api/gdpr.client.ts +++ b/src/components/scripts/api/gdpr.client.ts @@ -12,7 +12,7 @@ import type { DSARRequestInput, DSARResponse, ErrorResponse -} from '@pages/api/_contracts/gdpr.contracts' +} from '@actions/_contracts/gdpr.contracts' /** * Base API response type that all GDPR endpoints return diff --git a/src/lib/api/gdpr.client.ts b/src/lib/api/gdpr.client.ts index 887452534..0913f2632 100644 --- a/src/lib/api/gdpr.client.ts +++ b/src/lib/api/gdpr.client.ts @@ -12,7 +12,7 @@ import type { DSARRequestInput, DSARResponse, ErrorResponse -} from '@pages/api/_contracts/gdpr.contracts' +} from '@actions/_contracts/gdpr.contracts' /** * Base API response type that all GDPR endpoints return diff --git a/test/e2e/helpers/fetchOverride.ts b/test/e2e/helpers/fetchOverride.ts index acf4a3903..bf9401b1d 100644 --- a/test/e2e/helpers/fetchOverride.ts +++ b/test/e2e/helpers/fetchOverride.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test' +import type { Page, Route, Request } from '@playwright/test' export interface FetchOverrideHandle { restore: () => Promise @@ -41,167 +41,153 @@ type OverrideOptions = const generateOverrideKey = (): string => `fetch-override-${Date.now()}-${Math.random().toString(16).slice(2)}` -const createFetchOverride = async (page: Page, options: OverrideOptions): Promise => { - const key = options.key ?? generateOverrideKey() +const delay = async (delayMs: number): Promise => { + await new Promise(resolve => setTimeout(resolve, delayMs)) +} - await page.evaluate(({ config }) => { - const globalWindow = window as typeof window & { - __fetchOverrideStack?: Array - __fetchOverrideCallCounts?: Record +const withTimeout = async (promise: Promise, timeoutMs: number, timeoutMessage: string): Promise => { + let timeoutId: ReturnType | undefined + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs) + }) + + try { + await Promise.race([promise, timeoutPromise]) + } finally { + if (timeoutId) { + clearTimeout(timeoutId) } + } +} - const getRequestUrl = (input: RequestInfo | URL): string => { - if (typeof input === 'string') { - return input - } - if (input instanceof Request) { - return input.url - } - if (input instanceof URL) { - return input.href - } - return String(input) - } +const getRoutePattern = (endpoint: string): string => { + if (endpoint.includes('*')) { + return endpoint + } - const ensureStateInitialized = () => { - globalWindow.__fetchOverrideStack = globalWindow.__fetchOverrideStack ?? [] - globalWindow.__fetchOverrideCallCounts = globalWindow.__fetchOverrideCallCounts ?? {} - } + // Match both absolute and relative URLs. + return `**${endpoint}**` +} - ensureStateInitialized() +const safePostDataJson = (request: Request): unknown => { + try { + return request.postDataJSON() + } catch { + return null + } +} - const previousFetch = window.fetch - globalWindow.__fetchOverrideStack!.push(previousFetch) +const buildMockResponseBody = async ( + request: Request, + config: MockResponseOverrideOptions, +): Promise => { + if (config.responseBuilder === 'echoRequestJson') { + const json = safePostDataJson(request) + return json ?? config.body ?? {} + } - window.fetch = async (...args) => { - const [input, init] = args - const url = getRequestUrl(input) + if (config.responseBuilder === 'consentRecord') { + const payloadSource = safePostDataJson(request) + const payload = (payloadSource && typeof payloadSource === 'object') ? (payloadSource as Record) : {} + + const purposesSource = payload['purposes'] + const purposes = Array.isArray(purposesSource) ? purposesSource : [] + + return { + success: true, + record: { + id: 'test-consent-record', + DataSubjectId: typeof payload['DataSubjectId'] === 'string' ? payload['DataSubjectId'] : 'test-subject-id', + purposes, + timestamp: new Date().toISOString(), + source: typeof payload['source'] === 'string' ? payload['source'] : 'cookies_modal', + userAgent: typeof payload['userAgent'] === 'string' ? payload['userAgent'] : 'playwright-test', + ipAddress: '127.0.0.1', + privacyPolicyVersion: 'test-policy-v1', + verified: Boolean(payload['verified']), + }, + } + } - if (!url.includes(config.endpoint)) { - return previousFetch(...args) - } + return config.body ?? {} +} - globalWindow.__fetchOverrideCallCounts![config.key] = - (globalWindow.__fetchOverrideCallCounts![config.key] ?? 0) + 1 +const createFetchOverride = async (page: Page, options: OverrideOptions): Promise => { + const key = options.key ?? generateOverrideKey() + const urlPattern = getRoutePattern(options.endpoint) + + let callCount = 0 + let resolveFirstCall: (() => void) | null = null + const firstCallPromise = new Promise(resolve => { + resolveFirstCall = resolve + }) + + const handler = async (route: Route, request: Request) => { + callCount += 1 + if (resolveFirstCall) { + resolveFirstCall() + resolveFirstCall = null + } - if (config.mode === 'spy') { - return previousFetch(...args) - } + if (options.mode === 'spy') { + await route.continue() + return + } + + if (options.mode === 'delay') { + await delay(options.delayMs) + await route.continue() + return + } - if (config.mode === 'delay') { - await new Promise(resolve => setTimeout(resolve, config.delayMs)) - return previousFetch(...args) + if (options.mode === 'injectHeaders') { + const mergedHeaders = { + ...request.headers(), + ...options.headers, } + await route.continue({ headers: mergedHeaders }) + return + } - const request = input instanceof Request ? input : new Request(input, init) + if (options.mode === 'mockResponse') { + const resolvedBody = await buildMockResponseBody(request, options) + const headers: Record = { ...(options.headers ?? {}) } - if (config.mode === 'injectHeaders') { - const mergedHeaders = new Headers(request.headers) - Object.entries(config.headers).forEach(([headerName, headerValue]) => { - mergedHeaders.set(headerName, headerValue) - }) - const overriddenRequest = new Request(request, { headers: mergedHeaders }) - return previousFetch(overriddenRequest) + if (!Object.keys(headers).some((headerName) => headerName.toLowerCase() === 'content-type')) { + headers['Content-Type'] = typeof resolvedBody === 'string' ? 'text/plain' : 'application/json' } - if (config.mode === 'mockResponse') { - const headers = new Headers(config.headers ?? {}) - const buildResponseBody = async (): Promise => { - if (config.responseBuilder === 'echoRequestJson') { - try { - return await request.clone().json() - } catch { - return config.body ?? {} - } - } - - if (config.responseBuilder === 'consentRecord') { - let payload: Record = {} - try { - payload = await request.clone().json() - } catch { - payload = {} - } - - const purposesSource = (payload as Record)['purposes'] - const purposes = Array.isArray(purposesSource) ? purposesSource : [] - - return { - success: true, - record: { - id: 'test-consent-record', - DataSubjectId: typeof payload['DataSubjectId'] === 'string' ? payload['DataSubjectId'] : 'test-subject-id', - purposes, - timestamp: new Date().toISOString(), - source: typeof payload['source'] === 'string' ? payload['source'] : 'cookies_modal', - userAgent: typeof payload['userAgent'] === 'string' ? payload['userAgent'] : 'playwright-test', - ipAddress: '127.0.0.1', - privacyPolicyVersion: 'test-policy-v1', - verified: Boolean(payload['verified']), - }, - } - } - - return config.body ?? {} - } - - const resolvedBody = await buildResponseBody() - if (!headers.has('Content-Type')) { - headers.set('Content-Type', typeof resolvedBody === 'string' ? 'text/plain' : 'application/json') - } - - const serializedBody = typeof resolvedBody === 'string' ? resolvedBody : JSON.stringify(resolvedBody) - - return new Response(serializedBody, { - status: config.status ?? 200, - headers, - }) - } + const body = typeof resolvedBody === 'string' ? resolvedBody : JSON.stringify(resolvedBody) - return previousFetch(...args) + await route.fulfill({ + status: options.status ?? 200, + headers, + body, + }) + return } - }, { config: { ...options, key } }) - const restore = async () => { - await page.evaluate(({ overrideKey }) => { - const globalWindow = window as typeof window & { - __fetchOverrideStack?: Array - __fetchOverrideCallCounts?: Record - } + await route.continue() + } - if (globalWindow.__fetchOverrideStack && globalWindow.__fetchOverrideStack.length > 0) { - const previousFetch = globalWindow.__fetchOverrideStack.pop() - if (previousFetch) { - window.fetch = previousFetch - } - } + await page.route(urlPattern, handler) - if (globalWindow.__fetchOverrideCallCounts) { - delete globalWindow.__fetchOverrideCallCounts[overrideKey] - } - }, { overrideKey: key }) + const restore = async () => { + try { + await page.unroute(urlPattern, handler) + } catch { + // Ignore - page might already be closed. + } } - const getCallCount = async () => { - return await page.evaluate(({ overrideKey }) => { - const globalWindow = window as typeof window & { - __fetchOverrideCallCounts?: Record - } - return globalWindow.__fetchOverrideCallCounts?.[overrideKey] ?? 0 - }, { overrideKey: key }) - } + const getCallCount = async () => callCount const waitForCall = async (timeout = 5000) => { - await page.waitForFunction( - (overrideKey) => { - const globalWindow = window as typeof window & { - __fetchOverrideCallCounts?: Record - } - return (globalWindow.__fetchOverrideCallCounts?.[overrideKey] ?? 0) > 0 - }, - key, - { timeout }, - ) + if (callCount > 0) { + return + } + + await withTimeout(firstCallPromise, timeout, `Timed out waiting for request match: ${urlPattern} (${key})`) } return { restore, getCallCount, waitForCall } diff --git a/test/e2e/helpers/pageObjectModels/BasePage.ts b/test/e2e/helpers/pageObjectModels/BasePage.ts index 0a3397c72..1327a2b8e 100644 --- a/test/e2e/helpers/pageObjectModels/BasePage.ts +++ b/test/e2e/helpers/pageObjectModels/BasePage.ts @@ -183,26 +183,57 @@ export class BasePage { } }) - // Force hide the modal + const cookieDialog = this._page.getByRole('dialog', { name: /cookie consent/i }) + const allowAllButton = this._page.getByRole('button', { name: /allow all/i }) + + // Wait briefly for the dialog to appear on client-side hydrated pages. + await cookieDialog.waitFor({ state: 'visible', timeout: 1000 }).catch(() => undefined) + + if (await cookieDialog.isVisible().catch(() => false)) { + if (await allowAllButton.isVisible().catch(() => false)) { + await allowAllButton.click({ timeout: 1000 }).catch(() => undefined) + } + } + + // Force hide the modal (covers and inert states) await this._page.evaluate(() => { const modal = document.getElementById('consent-modal-id') if (modal) { - modal.style.display = 'none' + modal.removeAttribute('open') + modal.setAttribute('aria-hidden', 'true') + ;(modal as HTMLElement).style.display = 'none' } + + const dialogs = Array.from(document.querySelectorAll('dialog')) + dialogs.forEach(dialog => { + dialog.removeAttribute('open') + dialog.setAttribute('aria-hidden', 'true') + ;(dialog as HTMLElement).style.display = 'none' + }) + + const roleDialogs = Array.from(document.querySelectorAll('[role="dialog"]')) + roleDialogs.forEach(dialog => { + dialog.setAttribute('aria-hidden', 'true') + dialog.style.display = 'none' + }) + const main = document.getElementById('main-content') if (main && main.hasAttribute('inert')) { main.removeAttribute('inert') } }) - // Wait until modal is hidden and page is interactive again - await this.waitForFunction(() => { - const modal = document.getElementById('consent-modal-id') - const main = document.getElementById('main-content') - const modalHidden = !modal || modal.style.display === 'none' || modal.hasAttribute('hidden') - const mainInteractive = !main || !main.hasAttribute('inert') - return modalHidden && mainInteractive - }, undefined, { timeout: 1000 }) + // Wait until modal is hidden and page is interactive again + await this.waitForFunction(() => { + const modal = document.getElementById('consent-modal-id') + const main = document.getElementById('main-content') + const roleDialogs = Array.from(document.querySelectorAll('[role="dialog"]')) + const anyRoleDialogVisible = roleDialogs.some(dialog => dialog.style.display !== 'none' && dialog.getAttribute('aria-hidden') !== 'true') + const modalHidden = (!modal || (modal as HTMLElement).style.display === 'none' || modal.hasAttribute('hidden') || modal.getAttribute('aria-hidden') === 'true') + && !anyRoleDialogVisible + const mainInteractive = !main || !main.hasAttribute('inert') + return modalHidden && mainInteractive + }, undefined, { timeout: 1000 }) } catch { // Ignore errors - modal might not exist on all pages } diff --git a/test/e2e/helpers/pageObjectModels/NewsletterPage.ts b/test/e2e/helpers/pageObjectModels/NewsletterPage.ts index 3f1bd0bd8..db5965cfc 100644 --- a/test/e2e/helpers/pageObjectModels/NewsletterPage.ts +++ b/test/e2e/helpers/pageObjectModels/NewsletterPage.ts @@ -6,6 +6,8 @@ import { type Page, expect } from '@playwright/test' import { BasePage } from '@test/e2e/helpers' export class NewsletterPage extends BasePage { + private readonly subscribeActionEndpoint = '/_actions/newsletter/subscribe' + // Selectors private readonly formSelector = '#newsletter-form' private readonly emailInputSelector = '#newsletter-email' @@ -207,7 +209,7 @@ export class NewsletterPage extends BasePage { * Wait for API response and verify status */ async expectApiResponse(expectedStatus: number): Promise { - const responsePromise = this.page.waitForResponse('/api/newsletter') + const responsePromise = this.page.waitForResponse(response => response.url().includes(this.subscribeActionEndpoint)) await this.submitForm() const response = await responsePromise expect(response.status()).toBe(expectedStatus) @@ -217,7 +219,7 @@ export class NewsletterPage extends BasePage { * Wait for API response and get JSON data */ async getApiResponse(): Promise { - const responsePromise = this.page.waitForResponse('/api/newsletter') + const responsePromise = this.page.waitForResponse(response => response.url().includes(this.subscribeActionEndpoint)) await this.submitForm() const response = await responsePromise return await response.json() diff --git a/test/e2e/specs/03-forms/consent-checkbox.spec.ts b/test/e2e/specs/03-forms/consent-checkbox.spec.ts index 6de32c306..342eeb6f1 100644 --- a/test/e2e/specs/03-forms/consent-checkbox.spec.ts +++ b/test/e2e/specs/03-forms/consent-checkbox.spec.ts @@ -9,7 +9,6 @@ import { test, expect, spyOnFetchEndpoint, - mockFetchEndpointResponse, } from '@test/e2e/helpers' import { TEST_EMAILS } from '@test/e2e/fixtures/test-data' @@ -25,6 +24,8 @@ const NEWSLETTER_CONSENT_ERROR_SELECTOR = '#newsletter-gdpr-consent-error' const NEWSLETTER_MESSAGE_SELECTOR = '#newsletter-message' const CONTACT_CONSENT_SELECTOR = '#contact-gdpr-consent' +const newsletterSubscribeActionEndpoint = '/_actions/newsletter/subscribe' + const waitForNewsletterSection = async (page: BasePage): Promise => { await page.waitForLoadState('networkidle') await page.locator(NEWSLETTER_FORM_SELECTOR).waitFor({ state: 'visible' }) @@ -67,6 +68,9 @@ test.describe('Newsletter GDPR Consent', () => { pageUnderTest = await BasePage.init(page) await pageUnderTest.goto(HOME_PATH) await waitForNewsletterSection(pageUnderTest) + + // Ensure a consistent starting state for tests (checkbox may be pre-checked). + await pageUnderTest.locator(NEWSLETTER_CONSENT_SELECTOR).uncheck({ force: true }).catch(() => undefined) }) test('@ready GDPR consent checkbox is visible', async () => { @@ -90,7 +94,7 @@ test.describe('Newsletter GDPR Consent', () => { }) test('@ready form cannot submit without GDPR consent', async () => { - const fetchSpy = await spyOnFetchEndpoint(playwrightPage, '/api/newsletter') + const fetchSpy = await spyOnFetchEndpoint(playwrightPage, newsletterSubscribeActionEndpoint) try { await fillNewsletterEmail(pageUnderTest) @@ -108,25 +112,11 @@ test.describe('Newsletter GDPR Consent', () => { test('@ready form can submit with GDPR consent', async () => { const subscriptionEmail = `consent-e2e-${Date.now()}@example.com` - const successOverride = await mockFetchEndpointResponse(playwrightPage, { - endpoint: '/api/newsletter', - status: 200, - body: { - success: true, - message: 'Please check your email to confirm your subscription', - }, - }) - - try { - await fillNewsletterEmail(pageUnderTest, subscriptionEmail) - await pageUnderTest.check(NEWSLETTER_CONSENT_SELECTOR) - await submitNewsletterForm(pageUnderTest) - await successOverride.waitForCall() + await fillNewsletterEmail(pageUnderTest, subscriptionEmail) + await pageUnderTest.check(NEWSLETTER_CONSENT_SELECTOR) + await submitNewsletterForm(pageUnderTest) - await expect(pageUnderTest.locator(NEWSLETTER_MESSAGE_SELECTOR)).toContainText('confirm your subscription') - } finally { - await successOverride.restore() - } + await expect(pageUnderTest.locator(NEWSLETTER_MESSAGE_SELECTOR)).toContainText('confirm your subscription') }) test('@ready GDPR checkbox is accessible via keyboard', async () => { diff --git a/test/e2e/specs/03-forms/contact-form.spec.ts b/test/e2e/specs/03-forms/contact-form.spec.ts index c6c45f2c1..2857570e0 100644 --- a/test/e2e/specs/03-forms/contact-form.spec.ts +++ b/test/e2e/specs/03-forms/contact-form.spec.ts @@ -4,7 +4,7 @@ * Focuses on client-side validation and UI behaviors of the contact form. * Also see test/e2e/specs/02-pages/contact.spec.ts for navigation and basic load tests. */ -import type { Page, Response } from '@playwright/test' +import type { Page } from '@playwright/test' import { BasePage, expect, @@ -17,7 +17,7 @@ import { TEST_CONTACT_DATA, TEST_EMAILS } from '@test/e2e/fixtures/test-data' const CONTACT_PATH = '/contact' -const isContactApiResponse = (response: Response) => response.url().includes('/api/contact') +const contactSubmitActionEndpoint = '/_actions/contact/submit' const waitForContactFormHydration = async (page: BasePage) => { await page.waitForFunction(() => { @@ -91,7 +91,7 @@ test.describe('Contact Form', () => { await page.locator('#project_type').selectOption('website') await page.locator('#timeline').selectOption('asap') - const fetchSpy = await spyOnFetchEndpoint(playwrightPage, '/api/contact') + const fetchSpy = await spyOnFetchEndpoint(playwrightPage, contactSubmitActionEndpoint) try { await page.click('#submitBtn') @@ -119,13 +119,9 @@ test.describe('Contact Form', () => { test('@mocks contact form submits successfully when API is available', async ({ page: playwrightPage }) => { const page = await setupContactPage(playwrightPage) await fillContactFormWithValidData(page) - const responsePromise = page.waitForResponse(isContactApiResponse) await page.click('#submitBtn') - const response = await responsePromise - expect(response.status()).toBe(200) - await expect(page.locator('#formMessages .message-success')).toBeVisible({ timeout: 5000 }) await expect(page.locator('#formMessages .message-error')).toBeHidden() }) @@ -135,12 +131,15 @@ test.describe('Contact Form', () => { await fillContactFormWithValidData(page) const apiErrorOverride = await mockFetchEndpointResponse(playwrightPage, { - endpoint: '/api/contact', + endpoint: '/_actions/', body: { success: false, message: 'Unable to reach contact API. Please try again shortly.', }, status: 200, + headers: { + 'Content-Type': 'application/json+devalue', + }, }) try { @@ -148,7 +147,9 @@ test.describe('Contact Form', () => { await apiErrorOverride.waitForCall() await expect(page.locator('#formMessages .message-error')).toBeVisible({ timeout: 5000 }) - await expect(page.locator('#errorMessage')).toContainText('Unable to reach contact API') + await expect(page.locator('#errorMessage')).toContainText( + /Unable to reach contact API|Unable to send message\. Please try again later\./ + ) } finally { await apiErrorOverride.restore() } diff --git a/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts b/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts index 717abcc97..c82185fda 100644 --- a/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts +++ b/test/e2e/specs/03-forms/newsletter-double-optin.spec.ts @@ -2,7 +2,6 @@ * Newsletter Double Opt-In Flow E2E Tests * Tests for complete newsletter subscription flow including email confirmation */ -import type { Page } from '@playwright/test' import { BasePage, test, @@ -11,7 +10,6 @@ import { import { markNewsletterTokenExpired, waitForLatestNewsletterConfirmationTokenByEmail } from '@test/e2e/db' const HOME_PATH = '/' -const NEWSLETTER_ENDPOINT = '/api/newsletter' const waitForNewsletterForm = async (page: BasePage) => { await page.waitForSelector('#newsletter-email', { timeout: 5000 }) @@ -33,47 +31,27 @@ type NewsletterSubscriptionCapture = { email: string localConfirmationPath: string token: string - siteOrigin: string } const submitNewsletterSubscription = async ( page: BasePage, - playwrightPage: Page, email = createUniqueEmail(), ): Promise => { await fillNewsletterForm(page, email) - const responsePromise = playwrightPage.waitForResponse((response) => { - return response.url().includes(NEWSLETTER_ENDPOINT) && response.request().method() === 'POST' - }) - await page.click('#newsletter-submit') - const response = await responsePromise - expect(response.status()).toBe(200) await expect(page.locator('#newsletter-message')).toContainText('confirm your subscription', { timeout: 5000 }) const token = await waitForLatestNewsletterConfirmationTokenByEmail(email) - const siteOrigin = new URL(playwrightPage.url()).origin return { email, localConfirmationPath: `/newsletter/confirm/${token}`, token, - siteOrigin, } } -const confirmTokenViaApi = async ( - playwrightPage: Page, - token: string, - siteOrigin: string, -) => { - return await playwrightPage.request.get( - `${siteOrigin}/api/newsletter/confirm?token=${encodeURIComponent(token)}`, - ) -} - const markConfirmationTokenExpired = async (token: string): Promise => { await markNewsletterTokenExpired(token) } @@ -85,17 +63,10 @@ test.describe('Newsletter Double Opt-In Flow', () => { const page = await BasePage.init(playwrightPage) await page.goto(HOME_PATH) - const subscription = await submitNewsletterSubscription(page, playwrightPage) - - const confirmResponsePromise = playwrightPage.waitForResponse((apiResponse) => { - return apiResponse.url().includes('/api/newsletter/confirm') && apiResponse.request().method() === 'GET' - }) + const subscription = await submitNewsletterSubscription(page) await page.goto(subscription.localConfirmationPath) - const confirmResponse = await confirmResponsePromise - expect(confirmResponse.status()).toBe(200) - await expect(page.locator('#loading-state')).toHaveClass(/hidden/, { timeout: 5000 }) await expect(page.locator('#success-state')).toBeVisible({ timeout: 5000 }) await expect(page.locator('#user-email')).toHaveText(subscription.email) @@ -105,7 +76,7 @@ test.describe('Newsletter Double Opt-In Flow', () => { const page = await BasePage.init(playwrightPage) await page.goto(HOME_PATH) - const subscription = await submitNewsletterSubscription(page, playwrightPage) + const subscription = await submitNewsletterSubscription(page) await markConfirmationTokenExpired(subscription.token) await page.goto(subscription.localConfirmationPath) @@ -119,9 +90,10 @@ test.describe('Newsletter Double Opt-In Flow', () => { const page = await BasePage.init(playwrightPage) await page.goto(HOME_PATH) - const subscription = await submitNewsletterSubscription(page, playwrightPage) - const confirmResponse = await confirmTokenViaApi(playwrightPage, subscription.token, subscription.siteOrigin) - expect(confirmResponse.status()).toBe(200) + const subscription = await submitNewsletterSubscription(page) + + await page.goto(subscription.localConfirmationPath) + await expect(page.locator('#success-state')).toBeVisible({ timeout: 5000 }) await page.goto(subscription.localConfirmationPath) diff --git a/test/e2e/specs/03-forms/newsletter-subscription.spec.ts b/test/e2e/specs/03-forms/newsletter-subscription.spec.ts index 9b7b00ca5..a94fa46f6 100644 --- a/test/e2e/specs/03-forms/newsletter-subscription.spec.ts +++ b/test/e2e/specs/03-forms/newsletter-subscription.spec.ts @@ -7,6 +7,9 @@ import { EvaluationError } from '@test/errors' import { TEST_EMAILS, ERROR_MESSAGES } from '@test/e2e/fixtures/test-data' import { NewsletterPage } from '@test/e2e/helpers/pageObjectModels/NewsletterPage' +const newsletterSubscribeActionEndpoint = '/_actions/newsletter/subscribe' +const actionsEndpointPrefix = '/_actions/' + test.describe('Newsletter Subscription Form', () => { test('@ready form accepts valid email and shows success message', async ({ page: playwrightPage }) => { const newsletterPage = await NewsletterPage.init(playwrightPage) @@ -37,26 +40,14 @@ test.describe('Newsletter Subscription Form', () => { test('@ready form requires GDPR consent', async ({ page: playwrightPage }) => { const newsletterPage = await NewsletterPage.init(playwrightPage) await newsletterPage.navigateToNewsletterForm() - const fetchSpy = await spyOnFetchEndpoint(newsletterPage.page, '/api/newsletter') + const fetchSpy = await spyOnFetchEndpoint(newsletterPage.page, newsletterSubscribeActionEndpoint) try { - // Wait for page to be fully loaded with scripts - await newsletterPage.waitForLoadState('networkidle') - await newsletterPage.waitForFunction(() => { - const button = document.querySelector('#newsletter-submit') - return button instanceof HTMLButtonElement && !button.disabled - }, undefined, { timeout: 3000 }) - await newsletterPage.fillEmail(TEST_EMAILS.valid) - // Don't check GDPR consent - leave it unchecked + // Ensure consent is explicitly unchecked (it can be pre-checked in some states) + await newsletterPage.uncheckGdprConsent() await newsletterPage.submitForm() - // Wait for client-side validation to show error message - await newsletterPage.waitForFunction(() => { - const message = document.getElementById('newsletter-message') - return message && message.textContent && message.textContent.includes('consent') - }, { timeout: 3000 }) - // Verify that no API call was made (client-side validation prevented it) const apiCallCount = await fetchSpy.getCallCount() if (apiCallCount > 0) { @@ -99,7 +90,7 @@ test.describe('Newsletter Subscription Form', () => { // Set up intercept for API call to slow it down const browserName = newsletterPage.context().browser()?.browserType().name() const delayMs = browserName === 'webkit' ? 300 : 100 - const delayOverride = await delayFetchForEndpoint(newsletterPage.page, { endpoint: '/api/newsletter', delayMs }) + const delayOverride = await delayFetchForEndpoint(newsletterPage.page, { endpoint: newsletterSubscribeActionEndpoint, delayMs }) // Click submit and immediately check for spinner const submitButton = newsletterPage.locator('#newsletter-submit') @@ -171,19 +162,11 @@ test.describe('Newsletter Subscription Form', () => { const newsletterPage = await NewsletterPage.init(playwrightPage) await newsletterPage.navigateToNewsletterForm() - // Set up response promise before submitting - const apiResponsePromise = newsletterPage.waitForResponse('/api/newsletter') - await newsletterPage.fillEmail(TEST_EMAILS.valid) await newsletterPage.checkGdprConsent() await newsletterPage.submitForm() - // Verify API response - const apiResponse = await apiResponsePromise - expect(apiResponse.status()).toBe(200) - const responseData = await apiResponse.json() - expect(responseData.success).toBe(true) - expect(responseData.message).toContain('check your email') + await newsletterPage.expectMessageContains('check your email') }) test('@ready API error preserves form state and surfaces message', async ({ page: playwrightPage }) => { @@ -191,9 +174,17 @@ test.describe('Newsletter Subscription Form', () => { await newsletterPage.navigateToNewsletterForm() const mockResponse = await mockFetchEndpointResponse(newsletterPage.page, { - endpoint: '/api/newsletter', + endpoint: actionsEndpointPrefix, status: 429, - body: { success: false, error: 'Try again in 30 seconds.' }, + headers: { + 'Content-Type': 'application/json', + }, + body: { + type: 'AstroActionError', + code: 'TOO_MANY_REQUESTS', + status: 429, + message: 'Try again in 30 seconds.', + }, }) try { @@ -201,6 +192,8 @@ test.describe('Newsletter Subscription Form', () => { await newsletterPage.checkGdprConsent() await newsletterPage.submitForm() + await mockResponse.waitForCall() + await newsletterPage.expectMessageContains('Try again in 30 seconds.') await newsletterPage.expectEmailValue(TEST_EMAILS.valid) await newsletterPage.expectGdprChecked() @@ -223,11 +216,10 @@ test.describe('Newsletter Subscription Form', () => { const browserName = newsletterPage.context().browser()?.browserType().name() const delayMs = browserName === 'webkit' ? 400 : browserName === 'firefox' ? 600 : 200 - const delayOverride = await delayFetchForEndpoint(newsletterPage.page, { endpoint: '/api/newsletter', delayMs }) + const delayOverride = await delayFetchForEndpoint(newsletterPage.page, { endpoint: actionsEndpointPrefix, delayMs }) const submitButton = newsletterPage.locator('#newsletter-submit') const stateTimeoutMs = 4000 const stateTimeout = { timeout: stateTimeoutMs } - const apiResponsePromise = newsletterPage.page.waitForResponse('/api/newsletter') const submitPromise = submitButton.click() const fetchStarted = delayOverride.waitForCall(stateTimeoutMs) @@ -236,7 +228,6 @@ test.describe('Newsletter Subscription Form', () => { await expect(submitButton).toHaveAttribute('data-e2e-state', 'loading', stateTimeout) await expect(submitButton).toBeDisabled({ timeout: 2000 }) await submitPromise - await apiResponsePromise await expect(submitButton).toHaveAttribute('data-e2e-state', 'idle', stateTimeout) await expect(submitButton).toBeEnabled({ timeout: 2000 }) } finally { diff --git a/test/e2e/specs/04-components/consentPreferences.spec.ts b/test/e2e/specs/04-components/consentPreferences.spec.ts index d120ddf7e..f6a509654 100644 --- a/test/e2e/specs/04-components/consentPreferences.spec.ts +++ b/test/e2e/specs/04-components/consentPreferences.spec.ts @@ -5,7 +5,7 @@ import type { Page } from '@playwright/test' import { BasePage, expect, test, mockFetchEndpointResponse, type FetchOverrideHandle } from '@test/e2e/helpers' -import type { ConsentResponse } from '@pages/api/_contracts/gdpr.contracts' +import type { ConsentResponse } from '@actions/_contracts/gdpr.contracts' import { deleteConsentRecordsBySubjectId, waitForConsentRecord } from '@test/e2e/db' const ALLOW_ALL_BUTTON = '#consent-allow-all' diff --git a/test/e2e/specs/15-cron/cron.spec.ts b/test/e2e/specs/15-cron/cron.spec.ts index 91b60f496..f79cfee96 100644 --- a/test/e2e/specs/15-cron/cron.spec.ts +++ b/test/e2e/specs/15-cron/cron.spec.ts @@ -4,7 +4,7 @@ import { ensureCronDependenciesHealthy } from '@test/e2e/helpers/cronHealth' * These env helpers are safe to use in E2E test as they call process.env * directly. Must use "npm run dev:env" for this test case to pass. */ -import { getCronSecret } from '@pages/api/_environment/environmentApi' +import { getCronSecret } from '@pages/api/_utils/environment/environmentApi' import { deleteDsarRequestById, deleteNewsletterConfirmationById, From 73508fe2359f0893def1c603a9fe87079c169539 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 23 Dec 2025 05:18:34 +0300 Subject: [PATCH 07/10] Remove unused environment exports in src/pages/api --- src/pages/testing/environment-api.astro | 8 +------- src/pages/testing/site-url-api.astro | 2 +- .../e2e/specs/14-system/environmentApi.spec.ts | 18 ------------------ 3 files changed, 2 insertions(+), 26 deletions(-) diff --git a/src/pages/testing/environment-api.astro b/src/pages/testing/environment-api.astro index 7c671d06f..aa5d2ab83 100644 --- a/src/pages/testing/environment-api.astro +++ b/src/pages/testing/environment-api.astro @@ -2,13 +2,10 @@ import BaseLayout from '@layouts/BaseLayout.astro' import { getPackageRelease, - getPrivacyPolicyVersion, isDev, - isE2eTest, isProd, isTest, - isUnitTest, -} from '@pages/api/_environment/environmentApi' +} from '@pages/api/_utils/environment' /** * This testing page intentionally renders as part of the client bundle so @@ -21,13 +18,10 @@ const pageDescription = 'Server environment helper snapshot for automated testin const pagePath = '/testing/environment-api' const snapshot = { - isUnitTest: isUnitTest(), isTest: isTest(), - isE2eTest: isE2eTest(), isDev: isDev(), isProd: isProd(), packageRelease: getPackageRelease(), - privacyPolicyVersion: getPrivacyPolicyVersion(), } const formattedSnapshot = JSON.stringify(snapshot, null, 2) diff --git a/src/pages/testing/site-url-api.astro b/src/pages/testing/site-url-api.astro index 7895f4cd4..8452117b4 100644 --- a/src/pages/testing/site-url-api.astro +++ b/src/pages/testing/site-url-api.astro @@ -1,6 +1,6 @@ --- import BaseLayout from '@layouts/BaseLayout.astro' -import { getSiteUrl } from '@pages/api/_environment/siteUrlApi' +import { getSiteUrl } from '@pages/api/_utils/environment' const pageTitle = 'Site URL API Diagnostics' const pageDescription = 'Server-side site URL snapshot for automated testing only.' diff --git a/test/e2e/specs/14-system/environmentApi.spec.ts b/test/e2e/specs/14-system/environmentApi.spec.ts index 20c9a5c6d..632c8c08e 100644 --- a/test/e2e/specs/14-system/environmentApi.spec.ts +++ b/test/e2e/specs/14-system/environmentApi.spec.ts @@ -35,24 +35,12 @@ const getEnvironmentSnapshot = async (page: BasePage): Promise { - test('isUnitTest should return false for server snapshot generated in dev', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - const snapshot = await getEnvironmentSnapshot(page) - expect(snapshot.isUnitTest).toBe(false) - }) - test('isTest should reflect server-side detection state', async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) const snapshot = await getEnvironmentSnapshot(page) expect(snapshot.isTest).toBe(false) }) - test('isE2eTest should be false on server snapshot during dev-server rendering', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - const snapshot = await getEnvironmentSnapshot(page) - expect(snapshot.isE2eTest).toBe(false) - }) - test('isDev should be true for dev-server rendering', async ({ page: playwrightPage }) => { const page = await BasePage.init(playwrightPage) const snapshot = await getEnvironmentSnapshot(page) @@ -70,10 +58,4 @@ test.describe('Server Environment Diagnostics', () => { const snapshot = await getEnvironmentSnapshot(page) expect(snapshot.packageRelease.length).toBeGreaterThan(0) }) - - test('privacy policy version value should be exposed from astro:env/server', async ({ page: playwrightPage }) => { - const page = await BasePage.init(playwrightPage) - const snapshot = await getEnvironmentSnapshot(page) - expect(snapshot.privacyPolicyVersion.length).toBeGreaterThan(0) - }) }) From 807395b9f3ceecaf946a8152f28fbe41a8eb6dab Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 23 Dec 2025 05:54:22 +0300 Subject: [PATCH 08/10] Fixes to cron workflow, remove hyphens from input vars --- .github/actions/keep-alive/action.yml | 8 +-- .github/actions/keep-alive/src/main.py | 8 +-- .github/workflows/cron.yml | 8 +-- _TODO.md | 84 +------------------------- 4 files changed, 13 insertions(+), 95 deletions(-) diff --git a/.github/actions/keep-alive/action.yml b/.github/actions/keep-alive/action.yml index 68d80bca2..31e750d8f 100644 --- a/.github/actions/keep-alive/action.yml +++ b/.github/actions/keep-alive/action.yml @@ -2,10 +2,10 @@ name: Keep Alive description: Executes a keep-alive query (SELECT 1) against the Turso DB. inputs: - astro-db-remote-url: + url: description: Turso DB URL. required: true - astro-db-app-token: + token: description: Turso auth token. required: true @@ -17,5 +17,5 @@ runs: run: python3 src/main.py shell: bash env: - INPUT_ASTRO_DB_REMOTE_URL: ${{ inputs.astro-db-remote-url }} - INPUT_ASTRO_DB_APP_TOKEN: ${{ inputs.astro-db-app-token }} + INPUT_URL: ${{ inputs.url }} + INPUT_ASTRO_DB_APP_TOKEN: ${{ inputs.token }} diff --git a/.github/actions/keep-alive/src/main.py b/.github/actions/keep-alive/src/main.py index bba6cb9a9..83c6f53c1 100644 --- a/.github/actions/keep-alive/src/main.py +++ b/.github/actions/keep-alive/src/main.py @@ -27,11 +27,11 @@ def normalize_libsql_url(url: str) -> str: def run() -> None: try: - url = core.get_input("astro-db-remote-url", required=True) - auth_token = core.get_input("astro-db-app-token", required=True) + url = core.get_input("url", required=True) + auth_token = core.get_input("token", required=True) - required_url = normalize_libsql_url(get_required_value(url, "astro-db-remote-url")) - required_auth_token = get_required_value(auth_token, "astro-db-app-token") + required_url = normalize_libsql_url(get_required_value(url, "url")) + required_auth_token = get_required_value(auth_token, "token") client = create_client_sync(url=required_url, auth_token=required_auth_token) try: diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index d4e54da3c..346882ca7 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -34,8 +34,8 @@ jobs: - name: Execute keep-alive query uses: './.github/actions/keep-alive' with: - astro-db-remote-url: ${{ vars.ASTRO_DB_REMOTE_URL }} - astro-db-app-token: ${{ secrets.ASTRO_DB_APP_TOKEN }} + url: ${{ vars.ASTRO_DB_REMOTE_URL }} + token: ${{ secrets.ASTRO_DB_APP_TOKEN }} ping-preview: name: Ping Turso Preview DB @@ -58,5 +58,5 @@ jobs: - name: Execute keep-alive query uses: './.github/actions/keep-alive' with: - astro-db-remote-url: ${{ vars.ASTRO_DB_REMOTE_URL }} - astro-db-app-token: ${{ secrets.ASTRO_DB_APP_TOKEN }} + url: ${{ vars.ASTRO_DB_REMOTE_URL }} + token: ${{ secrets.ASTRO_DB_APP_TOKEN }} diff --git a/_TODO.md b/_TODO.md index 39129fe57..992e8597a 100644 --- a/_TODO.md +++ b/_TODO.md @@ -1,9 +1,7 @@ # TODO -## Refactor API Endpoints to Astro Actions - -### Action / Domain / Responder Pattern +## Astro Actions - Action / Domain / Responder Pattern - The action takes HTTP requests (URLs and their methods) and uses that input to interact with the domain, after which it passes the domain's output to one and only one responder. @@ -27,43 +25,6 @@ - The responder builds the entire HTTP response from the domain's output which is given to it by the action. The Responder is responsible solely for formatting the final response (e.g., JSON, HTML) to be sent back to the client. -### Endpoints: - -- social-card/ → GET // not refactoring to an action - this stays as an api endpoint - -- contact/ → POST (contact form submission) and OPTIONS (CORS pre-flight) -- downloads/submit → POST -- gdpr/consent → POST, GET, DELETE -- gdpr/request-data → POST -- gdpr/export → GET -- gdpr/verify → GET -- health/ → GET -- newsletter/ → POST, OPTIONS -- newsletter/confirm → GET - -### Files importing from `astro:db` - -- _utils/rateLimit.ts -- _utils/rateLimitStore.ts -- gdpr/_utils/consentStore.ts -- gdpr/_utils/dsarStore.ts -- newsletter/_token.ts - -### Cross-endpoint dependencies: - -gdpr: Mostly self-contained, but `verify.ts` does import `deleteNewsletterConfirmationsByEmail` from `@pages/api/newsletter/_token` (line 15). That's a direct dependency on the newsletter code. - -newsletter: `confirm.ts` pulls `markConsentRecordsVerified` from `@pages/api/gdpr/_utils/consentStore` (line 10) to mark double opt-in consent. That's the reciprocal dependency. - -Newsletter hits the gdpr consent endpoint using `recordConsent` in `src/pages/api/_logger/index.ts`. - -If we want to make it feel less inconsistent, we could either (a) rename `_logger` to something like `_consentClient` so its purpose is clearer, or (b) move to a microservices architecture and expose a protected `/api/gdpr/verify` endpoint and have newsletter call it over HTTP as well - but that would need additional auth to prevent abuse. - -**Affected components:** - -- CallToAction/Newsletter -- ContactForm - ## Refactor Theme Colors ### Color vars @@ -106,43 +67,6 @@ cat.text-alternatives: Rules for ensuring that text alternatives are provided fo Implement mitigations in test/e2e/specs/07-performance/PERFORMANCE.md -## Prefetch Links - -The default prefetch strategy when adding the data-astro-prefetch attribute is hover. To change it, you can configure prefetch.defaultStrategy in your astro.config.mjs file. - -hover (default): Prefetch when you hover over or focus on the link. -tap: Prefetch just before you click on the link. -viewport: Prefetch as the links enter the viewport. -load: Prefetch all links on the page after the page is loaded. - -```html - -About -``` - -If you want to prefetch all links, including those without the data-astro-prefetch attribute, you can set prefetch.prefetchAll to true: - -```typescript -// astro.config.mjs -import { defineConfig } from 'astro/config' - -export default defineConfig({ - prefetch: { - c: true - } -}) -``` - -You can then opt-out of prefetching for individual links by setting data-astro-prefetch="false": - -```html -About -``` - -## Service Worker - -Evaluate the service worker configuration for whether it's sensible. - ## Email Templates Right now we're using string literals to define HTML email templates for site mails. We should use Nunjucks with the rule-checking for valid CSS in HTML emails like we have in the corporate email footer repo. @@ -174,12 +98,6 @@ Needs to add real API key and test See the example image in Social Shares. The social shares UI on mobile should be a modal that slides in from the bottom. -## Themepicker tooltips, extra themes - -- Add additional themes (high contrast) -- Add Carousel -- Add tooltip that makes use of the description field for the theme, explaining what the intent of the theme is - ## Sentry feedback, chat bot tying into my phone and email See note in src/components/scripts/sentry/client.ts - "User Feedback - allow users to report issues" From 151c35ba247f630546f18fa3502d7644a653b36b Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 23 Dec 2025 06:00:11 +0300 Subject: [PATCH 09/10] Update unit tests in src/pages/api after move to Astro Actions --- .github/actions/keep-alive/__tests__/test_main.py | 12 ++++++------ src/pages/api/_utils/environment/environmentApi.ts | 2 +- src/pages/api/_utils/sentry/__tests__/index.spec.ts | 2 +- src/pages/api/cron/__tests__/cleanup.spec.ts | 6 +++--- src/pages/api/cron/__tests__/runner.spec.ts | 6 +++--- test/e2e/helpers/pageObjectModels/BasePage.ts | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/actions/keep-alive/__tests__/test_main.py b/.github/actions/keep-alive/__tests__/test_main.py index 4c23abc2d..100f58d6a 100644 --- a/.github/actions/keep-alive/__tests__/test_main.py +++ b/.github/actions/keep-alive/__tests__/test_main.py @@ -60,9 +60,9 @@ def test_executes_select_1_and_closes_client(monkeypatch: pytest.MonkeyPatch) -> module = load_action_module() def fake_get_input(name: str, required: bool = False) -> str: - if name == "astro-db-remote-url": + if name == "url": return "libsql://example.turso.io" - if name == "astro-db-app-token": + if name == "token": return "token" return "" @@ -103,9 +103,9 @@ def test_prefers_inputs_over_env_vars(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("ASTRO_DB_APP_TOKEN", "env_token") def fake_get_input(name: str, required: bool = False) -> str: - if name == "astro-db-remote-url": + if name == "url": return "libsql://input.turso.io" - if name == "astro-db-app-token": + if name == "token": return "input_token" return "" @@ -136,9 +136,9 @@ def test_normalizes_libsql_url_to_include_trailing_slash(monkeypatch: pytest.Mon module = load_action_module() def fake_get_input(name: str, required: bool = False) -> str: - if name == "astro-db-remote-url": + if name == "url": return "libsql://example.turso.io" - if name == "astro-db-app-token": + if name == "token": return "token" return "" diff --git a/src/pages/api/_utils/environment/environmentApi.ts b/src/pages/api/_utils/environment/environmentApi.ts index 6f2f2b592..d12e58530 100644 --- a/src/pages/api/_utils/environment/environmentApi.ts +++ b/src/pages/api/_utils/environment/environmentApi.ts @@ -5,7 +5,7 @@ * routes to import it. Vercel exposes environment variables in Vercel serverless * functions with process.env. */ -import { ApiFunctionError } from '@pages/api/_utils/errors' +import { ApiFunctionError } from '../errors/ApiFunctionError' import { isUnitTest } from '@lib/config/environmentServer' export { isCI, diff --git a/src/pages/api/_utils/sentry/__tests__/index.spec.ts b/src/pages/api/_utils/sentry/__tests__/index.spec.ts index efe41ddf8..1728a1e3a 100644 --- a/src/pages/api/_utils/sentry/__tests__/index.spec.ts +++ b/src/pages/api/_utils/sentry/__tests__/index.spec.ts @@ -15,7 +15,7 @@ vi.mock('@sentry/astro', () => ({ init: sentryInitMock, })) -vi.mock('@pages/api/_environment/environmentApi', () => envMocks) +vi.mock('@pages/api/_utils/environment', () => envMocks) describe('ensureApiSentry', () => { beforeEach(() => { diff --git a/src/pages/api/cron/__tests__/cleanup.spec.ts b/src/pages/api/cron/__tests__/cleanup.spec.ts index 265df74da..901fc10d0 100644 --- a/src/pages/api/cron/__tests__/cleanup.spec.ts +++ b/src/pages/api/cron/__tests__/cleanup.spec.ts @@ -21,9 +21,9 @@ vi.mock('astro:db', () => ({ and: vi.fn((...args) => ({ op: 'and', args })), })) -vi.mock('@pages/api/_environment/environmentApi', async () => { - const actual = await vi.importActual( - '@pages/api/_environment/environmentApi', +vi.mock('@pages/api/_utils/environment', async () => { + const actual = await vi.importActual( + '@pages/api/_utils/environment', ) return { ...actual, diff --git a/src/pages/api/cron/__tests__/runner.spec.ts b/src/pages/api/cron/__tests__/runner.spec.ts index 9fca3fc74..7fb216d97 100644 --- a/src/pages/api/cron/__tests__/runner.spec.ts +++ b/src/pages/api/cron/__tests__/runner.spec.ts @@ -5,9 +5,9 @@ import { GET as runAll } from '@pages/api/cron/run-all' const getCronSecretMock = vi.hoisted(() => vi.fn(() => 'cron-secret')) const getSiteUrlMock = vi.hoisted(() => vi.fn(() => 'https://example.com')) -vi.mock('@pages/api/_environment/environmentApi', async () => { - const actual = await vi.importActual( - '@pages/api/_environment/environmentApi', +vi.mock('@pages/api/_utils/environment', async () => { + const actual = await vi.importActual( + '@pages/api/_utils/environment', ) return { ...actual, diff --git a/test/e2e/helpers/pageObjectModels/BasePage.ts b/test/e2e/helpers/pageObjectModels/BasePage.ts index 1327a2b8e..d84155a77 100644 --- a/test/e2e/helpers/pageObjectModels/BasePage.ts +++ b/test/e2e/helpers/pageObjectModels/BasePage.ts @@ -201,14 +201,14 @@ export class BasePage { if (modal) { modal.removeAttribute('open') modal.setAttribute('aria-hidden', 'true') - ;(modal as HTMLElement).style.display = 'none' + modal.style.display = 'none' } const dialogs = Array.from(document.querySelectorAll('dialog')) dialogs.forEach(dialog => { dialog.removeAttribute('open') dialog.setAttribute('aria-hidden', 'true') - ;(dialog as HTMLElement).style.display = 'none' + dialog.style.display = 'none' }) const roleDialogs = Array.from(document.querySelectorAll('[role="dialog"]')) @@ -229,7 +229,7 @@ export class BasePage { const main = document.getElementById('main-content') const roleDialogs = Array.from(document.querySelectorAll('[role="dialog"]')) const anyRoleDialogVisible = roleDialogs.some(dialog => dialog.style.display !== 'none' && dialog.getAttribute('aria-hidden') !== 'true') - const modalHidden = (!modal || (modal as HTMLElement).style.display === 'none' || modal.hasAttribute('hidden') || modal.getAttribute('aria-hidden') === 'true') + const modalHidden = (!modal || modal.style.display === 'none' || modal.hasAttribute('hidden') || modal.getAttribute('aria-hidden') === 'true') && !anyRoleDialogVisible const mainInteractive = !main || !main.hasAttribute('inert') return modalHidden && mainInteractive From f25b9830f09046f29ebc644f6c3fe81fd6ef86aa Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Tue, 23 Dec 2025 06:20:21 +0300 Subject: [PATCH 10/10] Adjust build artifact in build workflow --- .../__tests__/test_main.py | 35 +++++++++++++++++++ .../download-build-artifact/src/main.py | 32 +++++++++++++++-- .github/workflows/build-preview.yml | 2 +- .github/workflows/build-production.yml | 2 +- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/.github/actions/download-build-artifact/__tests__/test_main.py b/.github/actions/download-build-artifact/__tests__/test_main.py index e1728fd73..a846b539e 100644 --- a/.github/actions/download-build-artifact/__tests__/test_main.py +++ b/.github/actions/download-build-artifact/__tests__/test_main.py @@ -38,6 +38,13 @@ def make_zip_bytes() -> bytes: return mem.getvalue() +def make_zip_bytes_with_top_level_dir() -> bytes: + mem = io.BytesIO() + with zipfile.ZipFile(mem, "w") as z: + z.writestr("vercel-build-preview/.vercel/output/config.json", "{}") + return mem.getvalue() + + def test_extracts_vercel_output(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: module = load_action_module() @@ -64,3 +71,31 @@ def fake_get(url: str, **kwargs: Any) -> MockResponse: assert failures == [] assert (tmp_path / ".vercel" / "output" / "config.json").exists() monkeypatch.chdir(cwd) + + +def test_extracts_vercel_output_when_nested_in_top_level_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + module = load_action_module() + + inputs = {"token": "ghs_test", "artifact-download-url": "https://api.github.com/art.zip"} + monkeypatch.setattr(module.core, "get_input", lambda name, required=False: inputs.get(name, "")) + + monkeypatch.setenv("GITHUB_API_URL", "https://api.github.com") + + zip_bytes = make_zip_bytes_with_top_level_dir() + + def fake_get(url: str, **kwargs: Any) -> MockResponse: + return MockResponse(ok=True, status_code=200, content=zip_bytes) + + monkeypatch.setattr(module.requests, "get", fake_get) + + cwd = Path.cwd() + monkeypatch.chdir(tmp_path) + + failures: list[str] = [] + monkeypatch.setattr(module.core, "set_failed", lambda m: failures.append(m)) + + module.run() + + assert failures == [] + assert (tmp_path / ".vercel" / "output" / "config.json").exists() + monkeypatch.chdir(cwd) diff --git a/.github/actions/download-build-artifact/src/main.py b/.github/actions/download-build-artifact/src/main.py index 47c7c514c..49ea23a64 100644 --- a/.github/actions/download-build-artifact/src/main.py +++ b/.github/actions/download-build-artifact/src/main.py @@ -42,6 +42,27 @@ def is_allowed_fetch_url(url: str, allowed_hosts: set[str]) -> bool: return parsed.scheme == "https" and parsed.hostname in allowed_hosts +def find_vercel_output_dir(extract_dir: Path) -> Path | None: + direct_candidates = [extract_dir / ".vercel" / "output", extract_dir / "output"] + for candidate in direct_candidates: + if candidate.is_dir(): + return candidate + + # Common case: artifact contains a top-level folder (e.g. "vercel-build-preview/") + # and the output is nested under it. + nested_candidates = list(extract_dir.rglob(".vercel/output")) + for candidate in nested_candidates: + if candidate.is_dir(): + return candidate + + # Fallback: look for directories named "output" that contain Vercel output. + for candidate in extract_dir.rglob("output"): + if candidate.is_dir() and (candidate / "config.json").is_file(): + return candidate + + return None + + def run() -> None: try: token = get_input_compat("token", required=True) @@ -73,10 +94,15 @@ def run() -> None: with zipfile.ZipFile(io.BytesIO(response.content)) as zip_ref: zip_ref.extractall(extract_dir) - candidates = [extract_dir / ".vercel" / "output", extract_dir / "output"] - source = next((p for p in candidates if p.is_dir()), None) + source = find_vercel_output_dir(extract_dir) if not source: - raise RuntimeError("Downloaded artifact did not contain expected output directory.") + top_level = sorted( + [p.name + ("/" if p.is_dir() else "") for p in extract_dir.iterdir()] + ) + raise RuntimeError( + "Downloaded artifact did not contain expected output directory. " + f"Top-level entries: {top_level}" + ) target = Path(".vercel") / "output" if target.exists(): diff --git a/.github/workflows/build-preview.yml b/.github/workflows/build-preview.yml index de48e5ebc..507a2bf03 100644 --- a/.github/workflows/build-preview.yml +++ b/.github/workflows/build-preview.yml @@ -53,5 +53,5 @@ jobs: uses: actions/upload-artifact@v6 with: name: vercel-build-preview - path: .vercel/output + path: .vercel retention-days: 30 diff --git a/.github/workflows/build-production.yml b/.github/workflows/build-production.yml index 16cb7d171..f76f23859 100644 --- a/.github/workflows/build-production.yml +++ b/.github/workflows/build-production.yml @@ -50,5 +50,5 @@ jobs: uses: actions/upload-artifact@v6 with: name: vercel-build-production - path: .vercel/output + path: .vercel retention-days: 30