= {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ }
+ 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/responder.ts b/src/actions/downloads/responder.ts
new file mode 100644
index 000000000..8c5d1c3b0
--- /dev/null
+++ b/src/actions/downloads/responder.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/pages/api/gdpr/_dsarVerificationEmails.ts b/src/actions/gdpr/_dsarVerificationEmails.ts
similarity index 55%
rename from src/pages/api/gdpr/_dsarVerificationEmails.ts
rename to src/actions/gdpr/_dsarVerificationEmails.ts
index d9afafd05..266b6609c 100644
--- a/src/pages/api/gdpr/_dsarVerificationEmails.ts
+++ b/src/actions/gdpr/_dsarVerificationEmails.ts
@@ -1,28 +1,15 @@
-/**
- * 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'
+import { getResendApiKey, isDev, isTest } from '@actions/_environment/environmentActions'
+import { getSiteUrl } from '@actions/_environment/siteUrlActions'
+import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError'
-/**
- * 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(
+export async function sendDsarVerificationEmail(
email: string,
token: string,
- requestType: 'ACCESS' | 'DELETE'
+ 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
@@ -32,24 +19,22 @@ export async function sendDSARVerificationEmail(
try {
resend = new Resend(getResendApiKey())
} catch (error) {
- const message = `[DSAR Email] Failed to initialize Resend client`
+ const message = '[DSAR Email] Failed to initialize Resend client'
console.error(message, error)
- throw new ApiFunctionError({
+ throw new ActionsFunctionError({
message,
cause: error,
code: 'DSAR_EMAIL_INIT_FAILED',
status: 500,
- route: '/api/gdpr',
- operation: 'sendDSARVerificationEmail'
+ route: 'actions:gdpr',
+ operation: 'sendDsarVerificationEmail',
})
}
- const verifyUrl = `${getSiteUrl()}/api/gdpr/verify?token=${token}`
+ 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 subject = requestType === 'ACCESS' ? 'Verify Your Data Access Request' : 'Verify Your Data Deletion Request'
const html = dsarVerificationEmailHtml({
subject,
@@ -80,15 +65,15 @@ export async function sendDSARVerificationEmail(
})
if (result.error) {
- const message = `[DSAR Email] Failed to send verification`
+ const message = '[DSAR Email] Failed to send verification'
console.error(message, result.error)
- throw new ApiFunctionError({
+ throw new ActionsFunctionError({
message,
cause: result.error,
code: 'DSAR_EMAIL_SEND_FAILED',
status: 502,
- route: '/api/gdpr',
- operation: 'sendDSARVerificationEmail'
+ route: 'actions:gdpr',
+ operation: 'sendDsarVerificationEmail',
})
}
@@ -98,15 +83,15 @@ export async function sendDSARVerificationEmail(
messageId: result.data?.id,
})
} catch (error) {
- const message = `[DSAR Email] Error sending verification`
+ const message = '[DSAR Email] Error sending verification'
console.error(message, error)
- throw new ApiFunctionError({
+ throw new ActionsFunctionError({
message,
cause: error,
code: 'DSAR_EMAIL_SEND_FAILED',
status: 502,
- route: '/api/gdpr',
- operation: 'sendDSARVerificationEmail'
+ route: 'actions:gdpr',
+ operation: 'sendDsarVerificationEmail',
})
}
-}
\ No newline at end of file
+}
diff --git a/src/pages/api/gdpr/_utils/consentStore.ts b/src/actions/gdpr/domain/consentStore.ts
similarity index 96%
rename from src/pages/api/gdpr/_utils/consentStore.ts
rename to src/actions/gdpr/domain/consentStore.ts
index 7c8362ab7..2cf825adf 100644
--- a/src/pages/api/gdpr/_utils/consentStore.ts
+++ b/src/actions/gdpr/domain/consentStore.ts
@@ -83,10 +83,7 @@ export async function deleteConsentRecordsByEmail(email: string): Promise {
+export async function markConsentRecordsVerified(email: string, dataSubjectId: string): Promise {
const normalizedEmail = normalizeEmail(email)
const updated = await db
.update(consentEvents)
diff --git a/src/pages/api/gdpr/_utils/dsarStore.ts b/src/actions/gdpr/domain/dsarStore.ts
similarity index 76%
rename from src/pages/api/gdpr/_utils/dsarStore.ts
rename to src/actions/gdpr/domain/dsarStore.ts
index 81df67872..4c66f2207 100644
--- a/src/pages/api/gdpr/_utils/dsarStore.ts
+++ b/src/actions/gdpr/domain/dsarStore.ts
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto'
import { and, db, dsarRequests, eq, gt, isNull } from 'astro:db'
-import type { DSARRequestInput } from '@pages/api/_contracts/gdpr.contracts'
+import type { DSARRequestInput } from '@actions/_contracts/gdpr.contracts'
export type DsarRequestRecord = typeof dsarRequests.$inferSelect
@@ -33,9 +33,7 @@ export async function findActiveRequestByEmail(
return record
}
-export async function createDsarRequest(
- input: CreateDsarRequestInput,
-): Promise {
+export async function createDsarRequest(input: CreateDsarRequestInput): Promise {
const [record] = await db
.insert(dsarRequests)
.values({
@@ -56,18 +54,10 @@ export async function createDsarRequest(
}
export async function findDsarRequestByToken(token: string): Promise {
- const [record] = await db
- .select()
- .from(dsarRequests)
- .where(eq(dsarRequests.token, token))
- .limit(1)
-
+ 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))
+ await db.update(dsarRequests).set({ fulfilledAt: new Date() }).where(eq(dsarRequests.token, token))
}
diff --git a/src/actions/gdpr/responder.ts b/src/actions/gdpr/responder.ts
new file mode 100644
index 000000000..8b1bcd0e7
--- /dev/null
+++ b/src/actions/gdpr/responder.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/domain/consentStore'
+import {
+ createDsarRequest,
+ findActiveRequestByEmail,
+ findDsarRequestByToken,
+ markDsarRequestFulfilled,
+} from '@actions/gdpr/domain/dsarStore'
+import { sendDsarVerificationEmail } from '@actions/gdpr/_dsarVerificationEmails'
+import { deleteNewsletterConfirmationsByEmail } from '@actions/newsletter/action'
+
+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/index.ts b/src/actions/index.ts
new file mode 100644
index 000000000..66b0755e7
--- /dev/null
+++ b/src/actions/index.ts
@@ -0,0 +1,11 @@
+import { contact } from './contact/responder'
+import { downloads } from './downloads/responder'
+import { gdpr } from './gdpr/responder'
+import { newsletter } from './newsletter/responder'
+
+export const server = {
+ contact,
+ downloads,
+ gdpr,
+ newsletter,
+}
diff --git a/src/pages/api/newsletter/_token.ts b/src/actions/newsletter/action.ts
similarity index 64%
rename from src/pages/api/newsletter/_token.ts
rename to src/actions/newsletter/action.ts
index 02978436a..4e2cbec06 100644
--- a/src/pages/api/newsletter/_token.ts
+++ b/src/actions/newsletter/action.ts
@@ -1,53 +1,33 @@
-/**
- * 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'
+import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError'
-/**
- * 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
+ createdAt: string
+ expiresAt: string
+ consentTimestamp: string
userAgent: string
- ipAddress?: string | undefined // Optional, for fraud prevention only
+ ipAddress?: string | undefined
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')
+ 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
@@ -58,7 +38,7 @@ export async function createPendingSubscription(data: {
}): Promise {
const token = generateConfirmationToken()
const now = new Date()
- const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000) // 24 hours
+ const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000)
const pending: PendingSubscription = {
email: data.email.toLowerCase().trim(),
@@ -90,47 +70,31 @@ export async function createPendingSubscription(data: {
createdAt: now,
})
} catch (error) {
- throw new ApiFunctionError({
+ throw new ActionsFunctionError({
message: 'Failed to create subscription confirmation',
cause: error,
code: 'NEWSLETTER_TOKEN_CREATE_FAILED',
status: 500,
- route: '/api/newsletter',
+ route: 'actions: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 {
+export async function validateToken(token: string): Promise {
const [dbRecord] = await db
.select()
.from(newsletterConfirmations)
- .where(
- and(
- eq(newsletterConfirmations.token, token),
- isNull(newsletterConfirmations.confirmedAt),
- ),
- )
+ .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
}
@@ -150,24 +114,19 @@ export async function validateToken(
}
}
- // 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
}
@@ -175,40 +134,21 @@ export async function validateToken(
return pending
}
-/**
- * Mark subscription as verified and remove from pending
- * Returns the subscription data for processing
- */
-export async function confirmSubscription(
- token: string,
-): Promise {
+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))
+ 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) {
@@ -217,10 +157,6 @@ function cleanExpiredTokens(): void {
}
}
-/**
- * Get all pending subscriptions (for testing/debugging)
- * Should be removed or protected in production
- */
export function getPendingCount(): number {
return pendingSubscriptions.size
}
diff --git a/src/pages/api/newsletter/_email.ts b/src/actions/newsletter/entities.ts
similarity index 81%
rename from src/pages/api/newsletter/_email.ts
rename to src/actions/newsletter/entities.ts
index 19512b3e0..a29a2d640 100644
--- a/src/pages/api/newsletter/_email.ts
+++ b/src/actions/newsletter/entities.ts
@@ -1,27 +1,16 @@
-/**
- * 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'
+import { getResendApiKey, isDev, isTest } from '@actions/_environment/environmentActions'
+import { getSiteUrl } from '@actions/_environment/siteUrlActions'
+import { ActionsFunctionError } from '@actions/_errors/ActionsFunctionError'
-/**
- * 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'
+ expiresIn: string = '24 hours',
): string {
const greeting = firstName ? `Hi ${firstName}` : 'Hello'
@@ -141,13 +130,10 @@ function generateConfirmationEmailHtml(
`.trim()
}
-/**
- * Generate plain text version of the confirmation email
- */
function generateConfirmationEmailText(
firstName: string | undefined,
confirmUrl: string,
- expiresIn: string = '24 hours'
+ expiresIn: string = '24 hours',
): string {
const greeting = firstName ? `Hi ${firstName}` : 'Hello'
@@ -181,25 +167,11 @@ Unsubscribe: ${getSiteUrl()}/privacy#unsubscribe
`.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 {
+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
@@ -217,17 +189,6 @@ export async function sendConfirmationEmail(
],
}
- 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 {
@@ -235,35 +196,27 @@ export async function sendConfirmationEmail(
if (result.error) {
console.error('[Newsletter Email] Failed to send confirmation:', result.error)
- throw new ApiFunctionError({
+ throw new ActionsFunctionError({
message: `Failed to send confirmation email: ${result.error.message}`,
code: 'NEWSLETTER_CONFIRMATION_EMAIL_FAILED',
status: 502,
- route: '/api/newsletter',
- operation: 'sendConfirmationEmail'
+ route: 'actions:newsletter',
+ operation: 'sendConfirmationEmail',
})
}
-
- console.log('[Newsletter Email] Confirmation sent successfully:', {
- email,
- messageId: result.data?.id,
- })
} catch (error) {
- handleSendError(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',
+ })
}
}
-/**
- * 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 {
+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
@@ -388,26 +341,15 @@ Questions? Reply to this email or contact us at hello@webstackbuilders.com
`.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'
- })
+ from: 'Webstack Builders ',
+ to: email,
+ subject: '🎉 Welcome to Webstack Builders!',
+ html,
+ text,
+ tags: [
+ { name: 'type', value: 'newsletter-welcome' },
+ { name: 'flow', value: 'post-confirmation' },
+ ],
}
try {
@@ -415,20 +357,22 @@ Questions? Reply to this email or contact us at hello@webstackbuilders.com
if (result.error) {
console.error('[Newsletter Email] Failed to send welcome email:', result.error)
- throw new ApiFunctionError({
+ throw new ActionsFunctionError({
message: `Failed to send welcome email: ${result.error.message}`,
code: 'NEWSLETTER_WELCOME_EMAIL_FAILED',
status: 502,
- route: '/api/newsletter',
- operation: 'sendWelcomeEmail'
+ route: 'actions:newsletter',
+ operation: 'sendWelcomeEmail',
})
}
-
- console.log('[Newsletter Email] Welcome email sent successfully:', {
- email,
- messageId: result.data?.id,
- })
} catch (error) {
- handleSendError(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/responder.ts b/src/actions/newsletter/responder.ts
new file mode 100644
index 000000000..4d941d5db
--- /dev/null
+++ b/src/actions/newsletter/responder.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/domain/consentStore'
+import { createPendingSubscription, confirmSubscription } from './action'
+import { sendConfirmationEmail, sendWelcomeEmail } from '@actions/newsletter/entities'
+
+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/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/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/env.d.ts b/src/env.d.ts
index f45de6922..f2efe35d1 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -1,5 +1,25 @@
///
+type RequestIdleCallbackHandle = number
+
+interface IdleDeadline {
+ didTimeout: boolean
+ timeRemaining(): number
+}
+
+type IdleRequestCallback = (_deadline: IdleDeadline) => void
+
+interface IdleRequestOptions {
+ timeout?: number
+}
+
+declare function requestIdleCallback(
+ _callback: IdleRequestCallback,
+ _options?: IdleRequestOptions,
+): RequestIdleCallbackHandle
+
+declare function cancelIdleCallback(_handle: RequestIdleCallbackHandle): void
+
interface ImportMetaEnv {
readonly NODE_ENV: string
}
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index 248f2667c..3cbb4abf0 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -107,6 +107,14 @@ const {
{/* Register the service worker via @vite-pwa/astro virtual module */}
+