Admin can now upload a png/jpg event photo (SPEC §8 exception added): - new image-upload admin field kind with live preview, uploading via POST /api/admin/upload (fenja-only, type + 5MB validation); - files stored under data/uploads (gitignored, BIFROST_UPLOAD_DIR overridable) and served by GET /uploads/[file] with a traversal guard. Reworks the /pulse event card: the greeting moved inside a taller box, the "next gathering" label sits above the date + title, and the photo renders as a top-right background that blends into the indigo via gradient masks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
4 KiB
TypeScript
124 lines
4 KiB
TypeScript
/* ---------------------------------------------------------------------------
|
|
* Form validation derived from a Resource's field definitions.
|
|
*
|
|
* Returns a map of field.key → error message. Empty object = valid.
|
|
* Server-side authoritative; the client may show hints, but every POST
|
|
* must call this before writing to the DB.
|
|
* ------------------------------------------------------------------------- */
|
|
|
|
import type { Field, FieldContext, Resource } from './resource-types';
|
|
|
|
export type ValidationErrors = Record<string, string>;
|
|
|
|
export interface ValidateArgs {
|
|
resource: Resource;
|
|
data: Record<string, unknown>;
|
|
/** Existing item being edited, or null on create. */
|
|
item: Record<string, unknown> | null;
|
|
actingUserId: number;
|
|
}
|
|
|
|
export function validateForResource(args: ValidateArgs): ValidationErrors {
|
|
const { resource, data, item, actingUserId } = args;
|
|
const errors: ValidationErrors = {};
|
|
|
|
if (!resource.form) return errors;
|
|
|
|
const ctx: FieldContext = { formValues: data, item, actingUserId };
|
|
|
|
for (const field of resource.form.fields) {
|
|
if (field.readOnly) continue;
|
|
if (field.visibleWhen && !field.visibleWhen(ctx)) continue;
|
|
|
|
const value = data[field.key];
|
|
const error = validateField(field, value);
|
|
if (error) errors[field.key] = error;
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateField(field: Field, value: unknown): string | null {
|
|
const isEmpty =
|
|
value === undefined ||
|
|
value === null ||
|
|
value === '' ||
|
|
(Array.isArray(value) && value.length === 0);
|
|
|
|
if (field.required && isEmpty) {
|
|
return `${field.label} is required`;
|
|
}
|
|
if (isEmpty) return null;
|
|
|
|
switch (field.kind) {
|
|
case 'text':
|
|
case 'textarea':
|
|
case 'markdown': {
|
|
if (typeof value !== 'string') return `${field.label} must be text`;
|
|
if ('maxLength' in field && field.maxLength && value.length > field.maxLength) {
|
|
return `${field.label} must be ${field.maxLength} characters or fewer`;
|
|
}
|
|
if (field.kind === 'text' && field.pattern && !field.pattern.test(value)) {
|
|
return `${field.label} is not in the expected format`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
case 'select':
|
|
case 'select-async': {
|
|
// Empty already handled. Anything else is accepted at the validate layer;
|
|
// option membership is enforced by the route handler against a fresh option list.
|
|
return null;
|
|
}
|
|
|
|
case 'multi-select-async': {
|
|
if (!Array.isArray(value)) return `${field.label} must be a list`;
|
|
return null;
|
|
}
|
|
|
|
case 'multi-text': {
|
|
if (!Array.isArray(value)) return `${field.label} must be a list`;
|
|
const filled = value.filter(
|
|
(v) => typeof v === 'string' && v.trim() !== '',
|
|
);
|
|
if (field.minItems !== undefined && filled.length < field.minItems) {
|
|
return `${field.label} requires at least ${field.minItems} entries`;
|
|
}
|
|
if (field.maxItems !== undefined && filled.length > field.maxItems) {
|
|
return `${field.label} allows at most ${field.maxItems} entries`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
case 'number': {
|
|
const n = typeof value === 'number' ? value : Number(value);
|
|
if (!Number.isFinite(n)) return `${field.label} must be a number`;
|
|
if (field.min !== undefined && n < field.min) {
|
|
return `${field.label} must be at least ${field.min}`;
|
|
}
|
|
if (field.max !== undefined && n > field.max) {
|
|
return `${field.label} must be no more than ${field.max}`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
case 'date':
|
|
case 'datetime': {
|
|
if (typeof value !== 'string') return `${field.label} must be a date`;
|
|
const t = Date.parse(value);
|
|
if (Number.isNaN(t)) return `${field.label} is not a valid date`;
|
|
return null;
|
|
}
|
|
|
|
case 'image-upload': {
|
|
if (typeof value !== 'string') return `${field.label} must be a URL`;
|
|
if ('maxLength' in field && field.maxLength && value.length > field.maxLength) {
|
|
return `${field.label} must be ${field.maxLength} characters or fewer`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
case 'readonly':
|
|
return null;
|
|
}
|
|
}
|