Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions functions/_shared/form-data-parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Safely gets a string value from FormData
* @param formData - The FormData object to extract from
* @param fieldName - The name of the field to extract
* @returns The trimmed string value, or undefined if field doesn't exist or is empty
*/
export function getStringField(formData: FormData, fieldName: string): string | undefined {
const value = formData.get(fieldName)

if (value === null || value === undefined) {
return undefined
}

if (typeof value !== 'string') {
return undefined
}

const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : undefined
}

/**
* Safely parses an integer from FormData
* @param formData - The FormData object to extract from
* @param fieldName - The name of the field to parse
* @returns The parsed integer, or undefined if parsing fails or value is invalid
*/
export function getIntegerField(formData: FormData, fieldName: string): number | undefined {
const value = getStringField(formData, fieldName)

if (value === undefined) {
return undefined
}

const parsed = parseInt(value, 10)

if (isNaN(parsed)) {
return undefined
}

return parsed
}

/**
* Safely parses a JSON array from FormData
* @param formData - The FormData object to extract from
* @param fieldName - The name of the field to parse as JSON array
* @returns The parsed array, or undefined if parsing fails or result is not an array
* @template T - The expected type of array elements
*/
export function getJsonArrayField<T = unknown>(
formData: FormData,
fieldName: string
): T[] | undefined {
const value = getStringField(formData, fieldName)

if (value === undefined) {
return undefined
}

try {
const parsed = JSON.parse(value)

if (!Array.isArray(parsed)) {
console.warn(`Field "${fieldName}" is not an array:`, parsed)
return undefined
}

return parsed as T[]
} catch (error) {
if (error instanceof Error) {
console.warn(`Failed to parse JSON for field "${fieldName}":`, error.message)
}
return undefined
}
}

/**
* Gets a File from FormData
* @param formData - The FormData object to extract from
* @param fieldName - The name of the file field
* @returns The File object, or undefined if field doesn't exist, is not a File, or is empty
*/
export function getFileField(formData: FormData, fieldName: string): File | undefined {
const value = formData.get(fieldName)

if (value === null || value === undefined) {
return undefined
}

if (!(value instanceof File)) {
return undefined
}

if (value.size === 0) {
return undefined
}

return value
}
107 changes: 107 additions & 0 deletions functions/_shared/schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { z } from 'zod'

const basePetitionFields = {
title: z
.string({
error: iss => (iss.input === undefined ? 'Title is required' : 'Title must be a string'),
})
.min(10, 'Title must be at least 10 characters')
.max(150, 'Title must not exceed 150 characters')
.trim(),

description: z
.string({
error: iss =>
iss.input === undefined ? 'Description is required' : 'Description must be a string',
})
.min(100, 'Description must be at least 100 characters')
.trim(),

type: z.enum(['local', 'national'], {
error: iss =>
iss.input === undefined
? 'Petition type is required'
: 'Petition type must be either "local" or "national"',
}),

location: z
.string({
error: 'Location must be a string',
})
.trim()
.optional(),

target_count: z
.number({
error: 'Target count must be a number',
})
.int('Target count must be an integer')
.min(1, 'Target count must be at least 1')
.max(1000000, 'Target count must not exceed 1,000,000'),

category_ids: z
.array(
z.number({
error: 'Category ID must be a number',
})
)
.optional(),

image_url: z.string().optional(),
}

export const createPetitionSchema = z
.object({
...basePetitionFields,
target_count: basePetitionFields.target_count.default(1000),
created_by: z.string(),
})
// validate location is required when type is 'local'
.refine(
data => {
if (data.type === 'local') {
return data.location !== undefined && data.location.length > 0
}
return true
},
{
message: 'Location is required for local petitions',
path: ['location'],
}
)

export type CreatePetitionRequest = z.input<typeof createPetitionSchema>
export type ValidatedPetitionData = z.output<typeof createPetitionSchema>

export const updatePetitionSchema = z
.object({
...basePetitionFields,
status: z
.enum(['active', 'completed', 'closed'], {
error: 'Status must be one of: active, completed, closed',
})
.optional(),
})
.partial()
.refine(
data => {
if (data.type === 'local') {
return data.location !== undefined && data.location.length > 0
}
return true
},
{
message: 'Location is required when changing petition type to local',
path: ['location'],
}
)

export type UpdatePetitionRequest = z.input<typeof updatePetitionSchema>
export type ValidatedUpdateData = z.output<typeof updatePetitionSchema>

export function formatZodError(error: z.ZodError): { field: string; message: string }[] {
return error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
}))
}
23 changes: 23 additions & 0 deletions functions/_shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,29 @@ export function createCachedErrorResponse(
})
}

export function createValidationErrorResponse(
errors: Array<{ field: string; message: string }>,
request: Request,
env: Env
): Response {
const corsHeaders = getCorsHeaders(request, env)

return new Response(
JSON.stringify({
message: 'Validation failed',
errors,
}),
{
status: 422,
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
}
)
}

// KV Cache utilities
export function generateCacheKey(request: Request, prefix: string = 'api'): string {
const url = new URL(request.url)
Expand Down
53 changes: 40 additions & 13 deletions functions/api/petitions.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import type { CreatePetitionInput } from '../../src/db/schemas/types'
import type { Env, EventContext } from '../_shared/types'
import {
createCachedErrorResponse,
createCachedResponse,
createSuccessResponse,
createValidationErrorResponse,
generateCacheKey,
getDbService,
getOrSetCache,
handleCORS,
invalidateCachePattern,
type AuthenticatedUser,
} from '../_shared/utils'
import {
getStringField,
getIntegerField,
getJsonArrayField,
getFileField,
} from '../_shared/form-data-parser'
import {
createPetitionSchema,
formatZodError,
type CreatePetitionRequest,
type ValidatedPetitionData,
} from '../_shared/schemas'

export const onRequest = async (context: EventContext<Env>): Promise<Response> => {
const corsResponse = handleCORS(context.request, context.env)
Expand All @@ -35,37 +47,52 @@ export const onRequest = async (context: EventContext<Env>): Promise<Response> =
// Check if this is a multipart form (with image) or JSON
const contentType = context.request.headers.get('content-type') || ''

let petitionData: CreatePetitionInput
let imageFile: File | null = null
let petitionData: CreatePetitionRequest
let imageFile: File | undefined = undefined

if (contentType.includes('multipart/form-data')) {
// Handle form data with potential image
const formData = await context.request.formData()

// Extract petition data from form
const title = getStringField(formData, 'title')
const description = getStringField(formData, 'description')
const type = getStringField(formData, 'type')
const location = getStringField(formData, 'location')
const targetCount = getIntegerField(formData, 'target_count')
const categoryIds = getJsonArrayField<number>(formData, 'category_ids')

petitionData = {
title: formData.get('title') as string,
description: formData.get('description') as string,
type: formData.get('type') as 'local' | 'national',
location: (formData.get('location') as string) || undefined,
target_count: parseInt(formData.get('target_count') as string),
created_by: user.id, // Use authenticated user's ID
category_ids: JSON.parse((formData.get('category_ids') as string) || '[]'),
image_url: '', // Will be set after upload
title: title ?? '',
description: description ?? '',
type: (type as 'local' | 'national') ?? 'national',
location: location,
target_count: targetCount,
created_by: user.id,
category_ids: categoryIds,
image_url: '',
}

// Get image file if provided
imageFile = formData.get('image') as File | null
imageFile = getFileField(formData, 'image')
} else {
// Handle JSON data (no image)
petitionData = await context.request.json()
// Ensure the petition is created by the authenticated user
petitionData.created_by = user.id
}

const validationResult = createPetitionSchema.safeParse(petitionData)
if (!validationResult.success) {
const formattedErrors = formatZodError(validationResult.error)
return createValidationErrorResponse(formattedErrors, context.request, context.env)
}

const validatedData: ValidatedPetitionData = validationResult.data

// Step 1: Create petition record first (without image_url)
const petition = await db.createPetition({
...petitionData,
...validatedData,
image_url: '', // Start with empty image_url
})

Expand Down
Loading
Loading