Inscription en deux temps, emails de confirmation et export Excel des bénévoles
Formulaire public — paiement en ligne : - Le bénévole n'est plus compté en base tant qu'un paiement en ligne choisi n'est pas confirmé (PendingFormSubmission, promu en Volunteer à la confirmation, webhook ou vérification en direct). - Type de paiement « mécénat » : étape d'adresse postale avant le paiement, conservée sur le bénévole pour un reçu fiscal (cerfa) ultérieur. - Page de confirmation au retour de SumUp, avec relance possible après échec/expiration sans ressaisir le formulaire. - « Je paierai sur place » remplace « je ne participe pas » ; tous les champs activés par la coordination sont désormais obligatoires. - La fiche bénévole n'affiche plus l'état des cases à cocher, seulement le résultat réel du paiement (libellé, montant, statut). Emails : - Email de confirmation d'inscription à la création du bénévole (tient lieu de reçu de paiement quand une cotisation est payée en ligne). - Mise en forme HTML au thème de l'application (logo de l'édition en en-tête) pour tous les emails envoyés, texte brut conservé en repli. Export Excel des bénévoles (COORDINATION) : coordonnées, vœux résolus en noms lisibles, cotisation et mécénat. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
68d42ee303
commit
a2b748566b
@@ -32,6 +32,8 @@ import type {
|
||||
ImportReport,
|
||||
ImportRowReport,
|
||||
MatrixResponse,
|
||||
MecenatInfoSubmit,
|
||||
PendingSubmissionStatus,
|
||||
PlanningNotificationRequest,
|
||||
PositionSheetsRequest,
|
||||
PublicFormConfig,
|
||||
@@ -45,7 +47,7 @@ import type {
|
||||
} from '../schema';
|
||||
import { getAuthToken } from '../session';
|
||||
import { ApiError, type PlanningApi } from '../types';
|
||||
import { buildMockState, type MockState } from './dataset';
|
||||
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
|
||||
|
||||
// Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le
|
||||
// comportement (chargements, invalidations) proche du back-end réel.
|
||||
@@ -159,6 +161,14 @@ const MOCK_PDF_BLOB = () =>
|
||||
{ type: 'application/pdf' },
|
||||
);
|
||||
|
||||
// Contenu factice (pas un vrai classeur) — la génération réelle (openpyxl)
|
||||
// est réservée au back-end ; en mode démonstration, seul le déclenchement
|
||||
// du téléchargement est simulé.
|
||||
const MOCK_XLSX_BLOB = () =>
|
||||
new Blob(['Export bénévoles (démonstration)'], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
|
||||
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||
|
||||
const addDays = (day: string, n: number): string => {
|
||||
@@ -197,6 +207,74 @@ function resolveWishes<T extends { id: string }>(
|
||||
return wanted;
|
||||
}
|
||||
|
||||
/** Comme `resolveWishes` mais silencieux : filtre les ids toujours valides
|
||||
* sans jamais lever — utilisé à la promotion d'une inscription en attente
|
||||
* (le paiement a déjà eu lieu, voir `promotePending`). */
|
||||
function filterKnownIds<T extends { id: string }>(pool: T[], ids: string[]): string[] {
|
||||
const known = new Set(pool.map((x) => x.id));
|
||||
return [...new Set(ids)].filter((id) => known.has(id));
|
||||
}
|
||||
|
||||
/** Paiement confirmé : crée enfin le `Volunteer` (voir le docstring de
|
||||
* `PendingFormSubmission` côté back-end) puis vide les champs nominatifs de
|
||||
* l'inscription en attente. */
|
||||
function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||
const now = touch();
|
||||
const volunteer: VolunteerRead = {
|
||||
id: `v${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
first_name: pending.firstName ?? '',
|
||||
last_name: pending.lastName ?? '',
|
||||
email: pending.email,
|
||||
phone: pending.phone,
|
||||
comment: pending.comment,
|
||||
artist_ids: filterKnownIds(state.artists, pending.artistIds),
|
||||
wished_position_ids: filterKnownIds(state.positions, pending.positionIds),
|
||||
wished_companion_ids: [],
|
||||
has_photo: pending.photo !== null,
|
||||
companion_wish_note: pending.companionWishNote,
|
||||
self_declared_available: pending.available,
|
||||
willing_to_pay_fee: true,
|
||||
fee_payment_label: pending.paymentLabel,
|
||||
fee_amount: pending.paymentAmount,
|
||||
mecenat_address: pending.mecenatAddress,
|
||||
mecenat_city: pending.mecenatCity,
|
||||
mecenat_postal_code: pending.mecenatPostalCode,
|
||||
mecenat_country: pending.mecenatCountry,
|
||||
fee_payment_status: 'PAID',
|
||||
fee_paid_at: now,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
if (pending.photo) state.photos.set(volunteer.id, pending.photo);
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
|
||||
pending.promotedVolunteerId = volunteer.id;
|
||||
pending.status = 'PAID';
|
||||
pending.firstName = null;
|
||||
pending.lastName = null;
|
||||
pending.email = null;
|
||||
pending.phone = null;
|
||||
pending.comment = null;
|
||||
pending.companionWishNote = null;
|
||||
pending.photo = null;
|
||||
pending.mecenatAddress = null;
|
||||
pending.mecenatCity = null;
|
||||
pending.mecenatPostalCode = null;
|
||||
pending.mecenatCountry = null;
|
||||
return volunteer;
|
||||
}
|
||||
|
||||
/** Simule la création (ou relance) d'un checkout SumUp — pas de vrai appel
|
||||
* réseau en mode démonstration, voir `app/api/forms.py:_create_pending_checkout`. */
|
||||
function createPendingCheckout(pending: MockPendingSubmission): string {
|
||||
pending.checkoutId = `co-mock-${state.seq++}`;
|
||||
pending.status = 'PENDING';
|
||||
pending.pollCount = 0;
|
||||
return `https://checkout.sumup.com/pay/mock-${pending.checkoutId}`;
|
||||
}
|
||||
|
||||
/** Vœux de compagnon : comme `resolveWishes`, plus le rejet de soi-même. */
|
||||
function resolveCompanionWishes(ids: string[], selfId?: string): string[] {
|
||||
const resolved = resolveWishes(state.volunteers, ids, 'volunteer.unknown');
|
||||
@@ -872,6 +950,10 @@ export function createMockApi(): PlanningApi {
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
mecenat_address: null,
|
||||
mecenat_city: null,
|
||||
mecenat_postal_code: null,
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
@@ -935,6 +1017,7 @@ export function createMockApi(): PlanningApi {
|
||||
if (!dataUrl) return null;
|
||||
return (await fetch(dataUrl)).blob();
|
||||
},
|
||||
exportXlsx: () => delay(MOCK_XLSX_BLOB()),
|
||||
planning: (_edition, id) => {
|
||||
if (!state.volunteers.some((v) => v.id === id)) return notFound();
|
||||
const entries = state.assignments
|
||||
@@ -1211,6 +1294,10 @@ export function createMockApi(): PlanningApi {
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
mecenat_address: null,
|
||||
mecenat_city: null,
|
||||
mecenat_postal_code: null,
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
@@ -1545,6 +1632,7 @@ export function createMockApi(): PlanningApi {
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
amount: o.amount,
|
||||
requires_mecenat_info: o.requires_mecenat_info,
|
||||
})),
|
||||
});
|
||||
},
|
||||
@@ -1563,6 +1651,7 @@ export function createMockApi(): PlanningApi {
|
||||
label,
|
||||
amount: body.amount,
|
||||
order: body.order ?? 0,
|
||||
requires_mecenat_info: body.requires_mecenat_info ?? false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -1585,6 +1674,9 @@ export function createMockApi(): PlanningApi {
|
||||
label,
|
||||
...(body.amount !== undefined ? { amount: body.amount } : {}),
|
||||
...(body.order !== undefined ? { order: body.order } : {}),
|
||||
...(body.requires_mecenat_info !== undefined
|
||||
? { requires_mecenat_info: body.requires_mecenat_info }
|
||||
: {}),
|
||||
updated_at: touch(),
|
||||
});
|
||||
return delay<FormPaymentOptionRead>(option);
|
||||
@@ -1604,8 +1696,18 @@ export function createMockApi(): PlanningApi {
|
||||
}
|
||||
|
||||
const fields = new Set(state.formTemplate.fields);
|
||||
const email = body.email?.trim() || null;
|
||||
const phone = body.phone?.trim() || null;
|
||||
const available = Boolean(body.available);
|
||||
if (fields.has('EMAIL') && !email) throw new ApiError(422, 'import.row.missing_required_field');
|
||||
if (fields.has('PHONE') && !phone) throw new ApiError(422, 'import.row.missing_required_field');
|
||||
if (fields.has('AVAILABLE') && !available) {
|
||||
throw new ApiError(422, 'import.row.missing_required_field');
|
||||
}
|
||||
|
||||
const positionIds = fields.has('POSITION_WISHES') ? (body.position_wish_ids ?? []) : [];
|
||||
if (positionIds.length > 2) throw new ApiError(422, 'form.too_many_position_wishes');
|
||||
if (positionIds.length) resolveWishes(state.positions, positionIds, 'position.unknown');
|
||||
const artistIds = fields.has('ARTIST_WISHES') ? (body.artist_wish_ids ?? []) : [];
|
||||
const artistDates = artistIds.map(
|
||||
(id) => state.artists.find((a) => a.id === id)?.performance_date,
|
||||
@@ -1613,6 +1715,7 @@ export function createMockApi(): PlanningApi {
|
||||
if (new Set(artistDates).size !== artistDates.length) {
|
||||
throw new ApiError(422, 'form.too_many_artists_same_day');
|
||||
}
|
||||
if (artistIds.length) resolveWishes(state.artists, artistIds, 'artist.unknown');
|
||||
|
||||
let paymentOption: FormPaymentOptionRead | null = null;
|
||||
if (fields.has('WILLING_TO_PAY_FEE') && body.payment_option_id) {
|
||||
@@ -1620,53 +1723,157 @@ export function createMockApi(): PlanningApi {
|
||||
if (!paymentOption) throw new ApiError(422, 'form.unknown_payment_option');
|
||||
}
|
||||
|
||||
const now = touch();
|
||||
const volunteer: VolunteerRead = {
|
||||
id: `v${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
first_name: titleCase(body.first_name.trim()),
|
||||
last_name: titleCase(body.last_name.trim()),
|
||||
email: fields.has('EMAIL') ? body.email?.trim() || null : null,
|
||||
phone: fields.has('PHONE') ? body.phone?.trim() || null : null,
|
||||
comment: fields.has('COMMENT') ? body.comment?.trim() || null : null,
|
||||
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
|
||||
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
|
||||
wished_companion_ids: [],
|
||||
has_photo: false,
|
||||
companion_wish_note: fields.has('COMPANION_WISH')
|
||||
? body.companion_wish_note?.trim() || null
|
||||
: null,
|
||||
self_declared_available: fields.has('AVAILABLE') ? Boolean(body.available) : false,
|
||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE')
|
||||
? Boolean(body.willing_to_pay_fee) || paymentOption !== null
|
||||
: false,
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
const comment = fields.has('COMMENT') ? body.comment?.trim() || null : null;
|
||||
const companionWishNote = fields.has('COMPANION_WISH')
|
||||
? body.companion_wish_note?.trim() || null
|
||||
: null;
|
||||
const photo =
|
||||
fields.has('PHOTO') && body.photo ? await fileToDataUrl(body.photo) : null;
|
||||
|
||||
if (paymentOption === null) {
|
||||
// Pas de paiement en ligne (case désactivée, ou « je paierai sur
|
||||
// place ») : rien à attendre, le bénévole est créé tout de suite.
|
||||
const now = touch();
|
||||
const volunteer: VolunteerRead = {
|
||||
id: `v${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
first_name: titleCase(body.first_name.trim()),
|
||||
last_name: titleCase(body.last_name.trim()),
|
||||
email: fields.has('EMAIL') ? email : null,
|
||||
phone: fields.has('PHONE') ? phone : null,
|
||||
comment,
|
||||
artist_ids: resolveWishes(state.artists, artistIds, 'artist.unknown'),
|
||||
wished_position_ids: resolveWishes(state.positions, positionIds, 'position.unknown'),
|
||||
wished_companion_ids: [],
|
||||
has_photo: photo !== null,
|
||||
companion_wish_note: companionWishNote,
|
||||
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
||||
fee_payment_label: null,
|
||||
fee_amount: null,
|
||||
mecenat_address: null,
|
||||
mecenat_city: null,
|
||||
mecenat_postal_code: null,
|
||||
mecenat_country: null,
|
||||
fee_payment_status: null,
|
||||
fee_paid_at: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
if (photo) state.photos.set(volunteer.id, photo);
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: volunteer.id,
|
||||
pending_token: null,
|
||||
requires_mecenat_info: false,
|
||||
sumup_checkout_url: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Paiement en ligne choisi : rien en base tant que le paiement n'est
|
||||
// pas confirmé — voir `PendingFormSubmission` côté back-end.
|
||||
const pending: MockPendingSubmission = {
|
||||
id: `pend${state.seq++}`,
|
||||
editionId: state.edition.id,
|
||||
firstName: titleCase(body.first_name.trim()),
|
||||
lastName: titleCase(body.last_name.trim()),
|
||||
email: fields.has('EMAIL') ? email : null,
|
||||
phone: fields.has('PHONE') ? phone : null,
|
||||
comment,
|
||||
companionWishNote,
|
||||
available: fields.has('AVAILABLE') ? available : false,
|
||||
positionIds,
|
||||
artistIds,
|
||||
photo,
|
||||
paymentLabel: paymentOption.label,
|
||||
paymentAmount: paymentOption.amount,
|
||||
requiresMecenatInfo: paymentOption.requires_mecenat_info,
|
||||
mecenatAddress: null,
|
||||
mecenatCity: null,
|
||||
mecenatPostalCode: null,
|
||||
mecenatCountry: null,
|
||||
checkoutId: null,
|
||||
status: null,
|
||||
promotedVolunteerId: null,
|
||||
pollCount: 0,
|
||||
};
|
||||
state.pendingSubmissions.set(pending.id, pending);
|
||||
|
||||
if (fields.has('PHOTO') && body.photo) {
|
||||
volunteer.has_photo = true;
|
||||
state.photos.set(volunteer.id, await fileToDataUrl(body.photo));
|
||||
if (pending.requiresMecenatInfo) {
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: null,
|
||||
pending_token: pending.id,
|
||||
requires_mecenat_info: true,
|
||||
sumup_checkout_url: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Simule la création d'un checkout SumUp (pas de vrai appel réseau
|
||||
// en mode démonstration) — voir `app/api/forms.py:_create_fee_checkout`.
|
||||
let checkoutUrl: string | null = null;
|
||||
if (paymentOption && state.formTemplate.sumupMerchantCode && state.formTemplate.sumupApiKey) {
|
||||
volunteer.fee_payment_label = paymentOption.label;
|
||||
volunteer.fee_amount = paymentOption.amount;
|
||||
volunteer.fee_payment_status = 'PENDING';
|
||||
checkoutUrl = `https://checkout.sumup.com/pay/mock-${volunteer.id}`;
|
||||
}
|
||||
|
||||
state.volunteers.push(volunteer);
|
||||
state.availability.set(volunteer.id, []);
|
||||
const checkoutUrl = createPendingCheckout(pending);
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: volunteer.id,
|
||||
volunteer_id: null,
|
||||
pending_token: pending.id,
|
||||
requires_mecenat_info: false,
|
||||
sumup_checkout_url: checkoutUrl,
|
||||
});
|
||||
},
|
||||
submitMecenatInfo: (_edition, token, body: MecenatInfoSubmit) => {
|
||||
const pending = state.pendingSubmissions.get(token) ?? notFound();
|
||||
if (pending.promotedVolunteerId !== null) {
|
||||
throw new ApiError(422, 'form.pending_already_paid');
|
||||
}
|
||||
if (!pending.requiresMecenatInfo) {
|
||||
throw new ApiError(422, 'form.pending_not_awaiting_info');
|
||||
}
|
||||
const address = body.address.trim();
|
||||
const city = body.city.trim();
|
||||
const postalCode = body.postal_code.trim();
|
||||
const country = body.country.trim();
|
||||
if (!address || !city || !postalCode || !country) {
|
||||
throw new ApiError(422, 'import.row.missing_required_field');
|
||||
}
|
||||
pending.mecenatAddress = address;
|
||||
pending.mecenatCity = city;
|
||||
pending.mecenatPostalCode = postalCode;
|
||||
pending.mecenatCountry = country;
|
||||
const checkoutUrl = createPendingCheckout(pending);
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: null,
|
||||
pending_token: pending.id,
|
||||
requires_mecenat_info: false,
|
||||
sumup_checkout_url: checkoutUrl,
|
||||
});
|
||||
},
|
||||
getPendingStatus: (_edition, token) => {
|
||||
const pending = state.pendingSubmissions.get(token) ?? notFound();
|
||||
// Simule le délai de confirmation réel (webhook asynchrone) : la
|
||||
// première vérification reste PENDING, la suivante confirme.
|
||||
if (pending.promotedVolunteerId === null && pending.checkoutId !== null) {
|
||||
if (pending.pollCount === 0) {
|
||||
pending.pollCount += 1;
|
||||
} else {
|
||||
promotePending(pending);
|
||||
}
|
||||
}
|
||||
return delay<PendingSubmissionStatus>({
|
||||
status: pending.promotedVolunteerId !== null ? 'PAID' : (pending.status ?? 'PENDING'),
|
||||
label: pending.paymentLabel,
|
||||
amount: pending.paymentAmount,
|
||||
volunteer_id: pending.promotedVolunteerId,
|
||||
});
|
||||
},
|
||||
retryPendingPayment: (_edition, token) => {
|
||||
const pending = state.pendingSubmissions.get(token) ?? notFound();
|
||||
if (pending.promotedVolunteerId !== null) {
|
||||
throw new ApiError(422, 'form.pending_already_paid');
|
||||
}
|
||||
if (pending.requiresMecenatInfo && pending.mecenatAddress === null) {
|
||||
throw new ApiError(422, 'form.pending_not_awaiting_info');
|
||||
}
|
||||
const checkoutUrl = createPendingCheckout(pending);
|
||||
return delay<FormSubmissionResult>({
|
||||
volunteer_id: null,
|
||||
pending_token: pending.id,
|
||||
requires_mecenat_info: false,
|
||||
sumup_checkout_url: checkoutUrl,
|
||||
});
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user