Give each call its own page, and a form to answer it

A call now has its address — description, what the volunteer gets, the
commitment added up, level, closing date, project, technologies — and
the application form right there. The landing's call card leads to it
instead of scrolling to the general form, and shows the level in the
visitor's language rather than the API's `mid` or `any`.

The form is the landing's, with a `callSlug` that hides the kind
selector and posts to the new /api/calls/{slug}/apply handler: the API
refuses a `kind` on an application, which is always an offer of time.
Both handlers share one check, core/submission.ts, now tested on its
own.

A call that is filled or closed keeps its page — a link to it may
still be out there — but shows why there is no form, and where to
write instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-09-29 15:02:06 +02:00
parent 439f951f2e
commit b5681a12ac
15 changed files with 625 additions and 92 deletions

View File

@@ -0,0 +1,49 @@
/**
* An application to one call, forwarded to the API for the reason the general
* form is (see `contact.post.ts`).
*
* The slug travels as it came: the API resolves it in the request's language
* and answers 404 for a call that is not open, which is passed on as it is.
*/
import {
defineEventHandler,
getQuery,
getRouterParam,
readBody,
setResponseStatus,
} from 'h3';
import { postSubmission, resolveLocale } from '../../../../../app/core/api';
import {
checkSubmission,
type SubmissionBody,
} from '../../../../../app/core/submission';
export default defineEventHandler(async (event) => {
const slug = getRouterParam(event, 'slug') ?? '';
const body = (await readBody<SubmissionBody>(event)) ?? {};
const locale = resolveLocale(getQuery(event)['lang']);
const { errors, payload } = checkSubmission(body, { forCall: true });
if (errors) {
setResponseStatus(event, 422);
return { errors, message: 'Validation failed' };
}
try {
const outcome = await postSubmission(
`/calls/${encodeURIComponent(slug)}/apply`,
payload,
locale,
);
setResponseStatus(event, outcome.status);
return outcome.body;
} catch {
setResponseStatus(event, 502);
return { message: 'upstream_unreachable' };
}
});

View File

@@ -1,90 +1,30 @@
/**
* The browser's only write path.
* The general form's write path.
*
* The public API throttles submissions per e-mail address rather than per IP
* precisely because an SSR frontend sends everything from one address, so the
* form posts here and this handler forwards it. Keeping the API host out of
* the browser also keeps CORS out of the picture.
*/
import { defineEventHandler, readBody, setResponseStatus, getQuery } from 'h3';
import { defineEventHandler, getQuery, readBody, setResponseStatus } from 'h3';
import { postSubmission, resolveLocale } from '../../../app/core/api';
import {
postSubmission,
resolveLocale,
type SubmissionPayload,
} from '../../../app/core/api';
interface ContactBody {
name?: string;
email?: string;
message?: string;
organisation?: string | null;
hours_per_week?: number | string | null;
kind?: 'contribute' | 'request';
consent?: boolean;
links?: Record<string, string>;
/** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */
website?: string | null;
}
checkSubmission,
type SubmissionBody,
} from '../../../app/core/submission';
export default defineEventHandler(async (event) => {
const body = (await readBody<ContactBody>(event)) ?? {};
const body = (await readBody<SubmissionBody>(event)) ?? {};
const locale = resolveLocale(getQuery(event)['lang']);
const kind = body.kind === 'request' ? 'request' : 'contribute';
const { errors, payload } = checkSubmission(body);
const name = body.name?.trim() ?? '';
const email = body.email?.trim() ?? '';
const message = body.message?.trim() ?? '';
const organisation = body.organisation?.trim() ?? '';
const errors: Record<string, string[]> = {};
if (name.length < 2) {
errors['name'] = ['required'];
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
errors['email'] = ['required'];
}
// The API rejects anything shorter, and rightly: a one-word submission is
// never anything but noise.
if (message.length < 20) {
errors['message'] = ['min'];
}
if (kind === 'request' && organisation.length === 0) {
errors['organisation'] = ['required'];
}
if (body.consent !== true) {
errors['consent'] = ['required'];
}
// Caught here rather than spent on a round trip: the API would answer 422
// with the same verdict, and its throttle counts the attempt either way.
if (Object.keys(errors).length > 0) {
if (errors) {
setResponseStatus(event, 422);
return { errors, message: 'Validation failed' };
}
const hours = Number(body.hours_per_week);
const payload: SubmissionPayload = {
name,
email,
message,
consent: true,
kind,
organisation: organisation || null,
hours_per_week: Number.isFinite(hours) && hours > 0 ? hours : null,
...(body.links && Object.keys(body.links).length > 0
? { links: body.links }
: {}),
...(body.website ? { website: body.website } : {}),
};
try {
const outcome = await postSubmission('/contact', payload, locale);