diff --git a/backend/src/lib/person-names.ts b/backend/src/lib/person-names.ts new file mode 100644 index 0000000..679cf9f --- /dev/null +++ b/backend/src/lib/person-names.ts @@ -0,0 +1,4 @@ +// Nom de famille facultatif, prénom facultatif (prénom inconnu = vide) : une personne garde au moins un des deux +export function hasAnyName(firstName: string, lastName: string): boolean { + return Boolean(firstName.trim() || lastName.trim()) +} diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index c95b3bd..928c2b3 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -13,6 +13,7 @@ import type { Actor, MembershipRole } from '../types/auth.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' import { nextSiblingOrder } from '../lib/sibling-order.js' import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js' +import { hasAnyName } from '../lib/person-names.js' const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/ @@ -69,7 +70,7 @@ const reviewSessionSchema = z.object({ const personCreateSchema = z.object({ firstName: z.string().trim().max(120), - lastName: z.string().min(1).max(120), + lastName: z.string().trim().max(120), birthName: z.string().max(120).optional().nullable(), nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), @@ -81,12 +82,12 @@ const personCreateSchema = z.object({ nationality: z.string().max(120).optional().nullable(), sex: z.string().max(20).optional().nullable(), notes: z.string().max(2000).optional().nullable(), -}) +}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] }) const personUpdateSchema = z .object({ firstName: z.string().trim().max(120).optional(), - lastName: z.string().min(1).max(120).optional(), + lastName: z.string().trim().max(120).optional(), birthName: z.string().max(120).optional().nullable(), nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), @@ -427,6 +428,16 @@ async function applyContributionChange( }) : null + if (parsed.data.firstName !== undefined || parsed.data.lastName !== undefined) { + const before = await tx.person.findFirst({ + where: { id: change.entityId, treeId, deletedAt: null }, + select: { firstName: true, lastName: true }, + }) + if (before && !hasAnyName(parsed.data.firstName ?? before.firstName, parsed.data.lastName ?? before.lastName)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + } + const result = await tx.person.updateMany({ where: { id: change.entityId, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index b82ae7b..c95ab95 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -16,6 +16,7 @@ import { reserveTreeUnlockAttempt, serializePasswordCheck } from '../lib/unlock- import type { MembershipRole } from '../types/auth.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js' +import { hasAnyName } from '../lib/person-names.js' import { ContributionRouteError, resolveSubmissionContext } from './contributions.js' const MAX_TREES_PER_ACCOUNT = 10 @@ -85,7 +86,7 @@ const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/ const createPersonSchema = z.object({ firstName: z.string().trim().max(120), - lastName: z.string().min(1).max(120), + lastName: z.string().trim().max(120), birthName: z.string().max(120).optional().nullable(), nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), @@ -97,12 +98,12 @@ const createPersonSchema = z.object({ nationality: z.string().max(120).optional().nullable(), sex: z.string().max(20).optional().nullable(), notes: z.string().max(2000).optional().nullable(), -}) +}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] }) const patchPersonSchema = z .object({ firstName: z.string().trim().max(120).optional(), - lastName: z.string().min(1).max(120).optional(), + lastName: z.string().trim().max(120).optional(), birthName: z.string().max(120).optional().nullable(), nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), @@ -1367,6 +1368,17 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.code(403).send({ error: 'forbidden' }) } + // Au moins un nom : on regarde le résultat de la modification, pas seulement ce qu'elle envoie + if (payload.data.firstName !== undefined || payload.data.lastName !== undefined) { + const before = await app.prisma.person.findFirst({ + where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, + select: { firstName: true, lastName: true }, + }) + if (before && !hasAnyName(payload.data.firstName ?? before.firstName, payload.data.lastName ?? before.lastName)) { + return reply.code(400).send({ error: 'invalid_payload', details: 'name_required' }) + } + } + const data: { firstName?: string lastName?: string diff --git a/backend/test/person-names.test.ts b/backend/test/person-names.test.ts new file mode 100644 index 0000000..3d96e2b --- /dev/null +++ b/backend/test/person-names.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { hasAnyName } from '../src/lib/person-names' +import { gedcomNameValue } from '../src/routes/gedcom' + +// Nom de famille facultatif : une personne garde au moins un prénom ou un nom +describe('au moins un nom', () => { + it('un prénom ou un nom suffit', () => { + expect(hasAnyName('Léa', '')).toBe(true) + expect(hasAnyName('', 'Lambert')).toBe(true) + expect(hasAnyName(' ', ' Lambert ')).toBe(true) + }) + + it('ni prénom ni nom : refusé', () => { + expect(hasAnyName('', '')).toBe(false) + expect(hasAnyName(' ', ' ')).toBe(false) + }) + + it('export GEDCOM sans nom de famille : « Léa // », valide', () => { + expect(gedcomNameValue('Léa', '')).toBe('Léa //') + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 3dfd6b7..de7d4be 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -228,7 +228,8 @@ function buildContributionChangesFromDraft(draft = {}) { for (const person of addedPersons) { if (!person || typeof person !== 'object') continue // Prénom inconnu (chaîne vide) : la personne part quand même, nom de famille obligatoire comme avant - if (!person.lastName) continue + // Nom de famille facultatif : seule une personne sans aucun nom est ignorée + if (!person.firstName && !person.lastName) continue // Identifiant provisoire : le serveur le remplace par le vrai id pour créer les liens const ref = typeof person.ref === 'string' && person.ref.startsWith('tmp:') ? person.ref : null diff --git a/frontend/src/components/AddPersonPanel.css b/frontend/src/components/AddPersonPanel.css index 007f39c..3d1689e 100644 --- a/frontend/src/components/AddPersonPanel.css +++ b/frontend/src/components/AddPersonPanel.css @@ -111,3 +111,6 @@ /* Colonnes alignées en haut : la case « décédé(e) » sous le décès ne décale pas la naissance */ .apn-row { align-items: start; } + +/* Titre de section en casse normale (pas de sur-titre en capitales dans le panneau d'ajout) */ +.apn-section-title { font: 500 14px/1.3 var(--pz-sans); color: var(--pz-ink); margin: 0 0 10px; } diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index cd62fc0..b949392 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -49,6 +49,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on const [relationDropdownOpen, setRelationDropdownOpen] = useState(false) const [selectedRelationType, setSelectedRelationType] = useState('parent') const [touched, setTouched] = useState({}) + // Prénom inconnu sans nom de famille : la personne n'aurait aucun nom + const [lastNameMissing, setLastNameMissing] = useState(false) const [justAdded, setJustAdded] = useState(false) const firstNameRef = useRef(null) @@ -103,6 +105,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on setDateFieldsKey((k) => k + 1) setRelations([]) setTouched({}) + setLastNameMissing(false) setJustAdded(false) firstNameRef.current?.focus() } @@ -139,6 +142,11 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on return } + if (unknownFirstName && !lastName.trim()) { + setLastNameMissing(true) + return + } + setJustAdded(true) onSubmit({ @@ -176,7 +184,6 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on >
{t('Nouvelle fiche')}
{t('Il faut au moins un prénom.')}
)} + {lastNameMissing &&{t('Il faut au moins un nom si le prénom est inconnu.')}
}{t('Il faut au moins un prénom.')}
} + {lastNameMissing &&{t('Il faut au moins un nom si le prénom est inconnu.')}
}