Files
SBC/server/src/schemas.js
T

214 lines
8.3 KiB
JavaScript

import { z } from 'zod';
const trimmed = (max, min = 0) => z.string().trim().min(min).max(max);
const siret = z.string().trim().max(20).refine(
(value) => !value || /^\d{14}$/.test(value.replace(/\s/g, '')),
'doit contenir exactement 14 chiffres'
);
export const idParam = z.object({ id: z.coerce.number().int().positive() });
export const loginSchema = z.object({
email: trimmed(254, 3).toLowerCase(),
password: z.string().min(1).max(200),
});
export const changePasswordSchema = z.object({
currentPassword: z.string().min(1).max(200),
newPassword: z
.string()
.min(10, 'au moins 10 caractères')
.max(200)
.regex(/[a-zA-Z]/, 'doit contenir une lettre')
.regex(/[0-9]/, 'doit contenir un chiffre'),
});
export const demandeSchema = z.object({
nom: trimmed(120, 1),
fonction: trimmed(120).optional().default(''),
entreprise: trimmed(120, 1),
email: trimmed(254, 3).email(),
tel: trimmed(30, 6),
});
// Each participant carries their own image-rights decision (an adult can't
// consent for another — art. 9 Code civil), and the member attests they
// informed each accompanying person and obtained their agreement.
export const inscriptionPublicSchema = z.object({
participants: z
.array(z.object({ nom: trimmed(120, 1), imageConsent: z.boolean() }))
.min(1)
.max(100),
attestation: z.literal(true, { errorMap: () => ({ message: "l'attestation est requise" }) }),
});
export const memberProfileSchema = z.object({
nom: trimmed(120, 1),
secteur: trimmed(120, 1),
categorie_id: z.coerce.number().int().positive().nullable().optional(),
dirigeant: trimmed(120, 1),
email: trimmed(254).email().or(z.literal('')).optional().default(''),
tel: trimmed(30).optional().default(''),
site: trimmed(200).optional().default(''),
adresse: trimmed(300).optional().default(''),
presentation: trimmed(2000).optional().default(''),
});
export const adminMemberSchema = memberProfileSchema.extend({
valide: z.boolean().optional(),
});
// Electronic image-rights consent submitted by the member. A drawn PNG
// signature and at least one publication scope are required to authorize;
// refusing needs neither. Signature kept small (canvas ~500x160) to stay
// well under the JSON body limit.
export const imageConsentSchema = z
.object({
decision: z.enum(['accepted', 'refused']),
scopes: z.array(z.enum(['site', 'social', 'print'])).max(3).optional().default([]),
signatoryName: trimmed(120, 1),
signaturePng: z.string().max(60000).optional().default(''),
})
.refine(
(d) =>
d.decision !== 'accepted' ||
(d.signaturePng.startsWith('data:image/png;base64,') && d.scopes.length > 0),
{ message: 'une signature et au moins un support sont requis pour autoriser', path: ['signaturePng'] }
);
export const rencontreSchema = z.object({
titre: trimmed(200, 1),
date_renc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'),
heure: trimmed(20).optional().default(''),
lieu: trimmed(200).optional().default(''),
description: trimmed(2000).optional().default(''),
places: z.coerce.number().int().min(0).max(100000),
participants_par_compte: z.coerce.number().int().min(1).max(100).optional().default(1),
});
export const inscriptionAdminSchema = z.object({
nom: trimmed(120, 1),
entreprise: trimmed(120, 1),
email: trimmed(254).email().or(z.literal('')).optional().default(''),
tel: trimmed(30).optional().default(''),
rencontre_id: z.coerce.number().int().positive(),
statut: z.enum(['confirmee', 'en_attente']),
});
export const rencontrePasseeSchema = z.object({
date_label: trimmed(60, 1),
lieu: trimmed(200, 1),
titre: trimmed(200, 1),
texte: trimmed(2000, 1),
// Manual fallback participant count; ignored when rencontre_id is set.
participants: z.coerce.number().int().min(0).max(100000).optional().default(0),
rencontre_id: z.coerce.number().int().positive().nullable().optional(),
});
export const pastPhotoParam = z.object({
id: z.coerce.number().int().positive(),
photoId: z.coerce.number().int().positive(),
});
export const categorySchema = z.object({ name: trimmed(80, 1) });
export const staffRoleEnum = z.enum(['admin', 'moderator', 'treasurer']);
export const staffUserSchema = z.object({
fullName: trimmed(120, 1),
email: trimmed(254, 3).email(),
role: staffRoleEnum,
});
export const roleChangeSchema = z.object({ role: staffRoleEnum });
export const billingSeasonParam = z.object({
season: z.string().regex(/^\d{4}-\d{4}$/, 'format AAAA-AAAA'),
});
const iban = z.string().trim().max(42).refine(
(value) => !value || /^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(value.replace(/\s/g, '').toUpperCase()),
'IBAN invalide'
);
const bankingCode = (regex, max, message) => z.string().trim().max(max).refine(
(value) => !value || regex.test(value.replace(/\s/g, '').toUpperCase()),
message
).optional().default('');
export const billingSettingsSchema = z.object({
sluc_partner_amount_ht: z.coerce.number().min(0).max(1000000),
non_partner_amount_ht: z.coerce.number().min(0).max(1000000),
payment_due_days: z.coerce.number().int().min(0).max(365),
iban,
bic: bankingCode(/^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/, 14, 'BIC invalide'),
rib_account_holder: trimmed(160).optional().default(''),
rib_bank_name: trimmed(160).optional().default(''),
rib_bank_code: bankingCode(/^\d{5}$/, 7, 'le code banque doit contenir 5 chiffres'),
rib_branch_code: bankingCode(/^\d{5}$/, 7, 'le code guichet doit contenir 5 chiffres'),
rib_account_number: bankingCode(/^[A-Z0-9]{11}$/, 14, 'le numéro de compte doit contenir 11 caractères'),
rib_key: bankingCode(/^\d{2}$/, 3, 'la clé RIB doit contenir 2 chiffres'),
legal_mentions: trimmed(3000).optional().default(''),
});
export const billingTypeSchema = z.object({
billing_type: z.enum(['sluc_partner', 'non_partner']),
});
export const invoiceGenerationSchema = z.object({
member_ids: z.array(z.coerce.number().int().positive()).min(1).max(10000).optional(),
});
export const paymentSchema = z.object({
payment_method: z.enum(['carte', 'virement', 'cheque']),
paid_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'format AAAA-MM-JJ'),
});
export const contentSchema = z.object({
hero_quote_text: trimmed(300).optional(),
hero_quote_author: trimmed(120).optional(),
association_name: trimmed(160, 1).optional(),
association_address: trimmed(500).optional(),
association_email: trimmed(254).email().or(z.literal('')).optional(),
association_phone: trimmed(40).optional(),
association_siret: siret.optional(),
association_contact: trimmed(160).optional(),
association_website: z.string().trim().url().max(300).or(z.literal('')).optional(),
association_facebook_url: z.string().trim().url().max(500).or(z.literal('')).optional(),
association_linkedin_url: z.string().trim().url().max(500).or(z.literal('')).optional(),
association_president: trimmed(160).optional(),
association_vice_president: trimmed(160).optional(),
association_treasurer: trimmed(160).optional(),
association_secretary: trimmed(160).optional(),
association_board_members: trimmed(3000).optional(),
// Legal pages (mentions légales & politique de confidentialité)
association_rna: trimmed(30).optional(),
association_host_name: trimmed(200).optional(),
association_host_address: trimmed(300).optional(),
association_host_tel: trimmed(30).optional(),
legal_updated: trimmed(60).optional(),
});
// Invitation email composer: every text zone is optional — absent fields
// fall back to the default wording, an empty string hides the block.
export const emailComposeSchema = z.object({
base: z.string().trim().url().max(300).optional(),
greeting: z.string().trim().max(300).optional(),
intro: z.string().trim().max(2000).optional(),
outro: z.string().trim().max(2000).optional(),
signature: z.string().trim().max(500).optional(),
});
export const customEmailSchema = z.object({
base: z.string().trim().url().max(300).optional(),
subject: trimmed(200, 1),
kicker: trimmed(120).optional().default(''),
title: trimmed(200, 1),
body: trimmed(6000, 1),
buttonLabel: trimmed(100).optional().default(''),
buttonUrl: z.string().trim().url().max(500).or(z.literal('')).optional().default(''),
signature: trimmed(500).optional().default(''),
}).refine((data) => Boolean(data.buttonLabel) === Boolean(data.buttonUrl), {
message: 'le texte et le lien du bouton doivent être renseignés ensemble',
path: ['buttonUrl'],
});