diff --git a/backend/prisma/migrations/20261023_001_a_verifier/migration.sql b/backend/prisma/migrations/20261023_001_a_verifier/migration.sql new file mode 100644 index 0000000..08b17de --- /dev/null +++ b/backend/prisma/migrations/20261023_001_a_verifier/migration.sql @@ -0,0 +1,3 @@ +-- « À vérifier » sur une personne (feat/a-verifier). Migration additive : booléen à faux par défaut. +-- AlterTable +ALTER TABLE "persons" ADD COLUMN "toVerify" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 6875c75..a6856a1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -237,6 +237,8 @@ model Person { deathDate DateTime? // Décédé(e) sans date connue (issue #77) : vrai seulement quand deathDate est vide isDeceased Boolean @default(false) + // « À vérifier » : la famille n'est pas sûre de cette personne et le signale (retour d'Anaïs, 23/10) + toVerify Boolean @default(false) birthPlace String? // Précision d'une date importée (« vers », « avant », « entre »…) et sa borne de fin pour « entre … et … » birthDatePrecision String? diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 3d6f639..3b62127 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -78,6 +78,7 @@ const personCreateSchema = z.object({ birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), + toVerify: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), @@ -96,6 +97,7 @@ const personUpdateSchema = z birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), + toVerify: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), @@ -410,6 +412,7 @@ async function applyContributionChange( birthDate: toDateOrNull(parsed.data.birthDate), deathDate: toDateOrNull(parsed.data.deathDate), isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased), + toVerify: Boolean(parsed.data.toVerify), birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace), deathPlace: toTrimmedStringOrNull(parsed.data.deathPlace), profession: toTrimmedStringOrNull(parsed.data.profession), @@ -479,6 +482,7 @@ async function applyContributionChange( birthDate: nextDates.birthDate, deathDate: nextDates.deathDate, isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased, + toVerify: parsed.data.toVerify, birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined, deathPlace: parsed.data.deathPlace !== undefined ? toTrimmedStringOrNull(parsed.data.deathPlace) : undefined, profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined, @@ -878,6 +882,7 @@ async function snapshotEntity( birthDate: true, deathDate: true, isDeceased: true, + toVerify: true, birthPlace: true, deathPlace: true, profession: true, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index 2035c78..41da55b 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -93,6 +93,7 @@ const createPersonSchema = z.object({ birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), + toVerify: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), @@ -111,6 +112,8 @@ const patchPersonSchema = z birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), + // « À vérifier » : la famille n'est pas sûre de cette personne (retour d'Anaïs, 23/10) + toVerify: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), @@ -1321,6 +1324,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { deathDate: toDateOrNull(payload.data.deathDate), // Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased), + toVerify: Boolean(payload.data.toVerify), birthPlace: toTrimmedStringOrNull(payload.data.birthPlace), deathPlace: toTrimmedStringOrNull(payload.data.deathPlace), profession: toTrimmedStringOrNull(payload.data.profession), @@ -1391,6 +1395,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { birthDate?: Date | null deathDate?: Date | null isDeceased?: boolean + toVerify?: boolean birthPlace?: string | null deathPlace?: string | null profession?: string | null @@ -1433,6 +1438,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { data.isDeceased = false } + if (payload.data.toVerify !== undefined) { + data.toVerify = payload.data.toVerify + } + if (payload.data.birthPlace !== undefined) { data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace) } diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 82b8fee..89bb59e 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -15,6 +15,7 @@ export type GraphPayload = { birthDate: string | null deathDate: string | null isDeceased: boolean + toVerify: boolean birthPlace: string | null deathPlace: string | null profession: string | null @@ -105,6 +106,7 @@ export function buildGraphPayload( birthDate: formatDate(person.birthDate), deathDate: formatDate(person.deathDate), isDeceased: Boolean(person.isDeceased) && !person.deathDate, + toVerify: Boolean(person.toVerify), birthPlace: person.birthPlace, deathPlace: person.deathPlace, profession: person.profession, diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index d9f56cd..1f32072 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -1211,6 +1211,28 @@ describe('Surnom (nickname) en écriture directe', () => { }) }) +describe('« À vérifier » en écriture directe (retour d’Anaïs, 23/10)', () => { + it('POST et PATCH posent et retirent le drapeau, absent = inchangé', async () => { + const app = createApp() + const token = await createUserToken(app) + const headers = { authorization: `Bearer ${token}` } + + const created = await app.inject({ method: 'POST', url: '/trees/tree-1/persons', headers, payload: { firstName: 'Jeanne', lastName: 'Martin', toVerify: true } }) + expect(created.statusCode).toBe(201) + expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: true }) + + const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers, payload }) + expect((await patch({ toVerify: true })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: true }) + expect((await patch({ toVerify: false })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ toVerify: false }) + expect((await patch({ nickname: 'Loulou' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).not.toHaveProperty('toVerify') + expect((await patch({ toVerify: 'oui' })).statusCode).toBe(400) + await app.close() + }) +}) + describe('Sexe d’une personne en écriture directe (issue #114)', () => { it('PATCH ramène le sexe à male / female / other, null = non précisé, refuse une valeur inconnue', async () => { const app = createApp() diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index 2c23d4a..72f4e65 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -1151,6 +1151,33 @@ describe('API security and authz', () => { await app.close() }) + it('« à vérifier » proposé par la famille : appliqué à l’approbation, en création comme en modification', async () => { + const app = createApp() + const token = await createUserToken(app) + + prismaMock.contributionSession.findFirst.mockResolvedValue({ + id: 'session-1', + treeId: 'tree-1', + status: 'pending', + changes: [ + { id: 'change-1', entityType: 'person', action: 'create', entityId: null, beforeJson: null, afterJson: { firstName: 'Alice', lastName: 'Martin', toVerify: true }, conflictState: 'none' }, + { id: 'change-2', entityType: 'person', action: 'update', entityId: 'person-1', beforeJson: { toVerify: false }, afterJson: { toVerify: true }, conflictState: 'none' }, + ], + }) + + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-1/contributions/sessions/session-1/review', + headers: { authorization: `Bearer ${token}` }, + payload: { decision: 'approved' }, + }) + + expect(response.statusCode).toBe(200) + expect(prismaMock.person.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ firstName: 'Alice', toVerify: true }) })) + expect(prismaMock.person.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'person-1' }), data: expect.objectContaining({ toVerify: true }) })) + await app.close() + }) + it('allows owner to upload media below image limit', async () => { const app = createApp() const token = await createUserToken(app) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 155304e..869221b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -317,6 +317,7 @@ function buildContributionChangesFromDraft(draft = {}) { if (source.isAlive === true) after.deathDate = null after.isDeceased = source.isAlive === false } + if (Object.prototype.hasOwnProperty.call(source, 'toVerify')) after.toVerify = Boolean(source.toVerify) if (Object.keys(after).length > 0) { changes.push({ @@ -1349,6 +1350,7 @@ function App() { if (rawChanges.isAlive === true) payload.deathDate = null payload.isDeceased = rawChanges.isAlive === false } + if (Object.prototype.hasOwnProperty.call(rawChanges, 'toVerify')) payload.toVerify = Boolean(rawChanges.toVerify) if (Object.prototype.hasOwnProperty.call(rawChanges, 'note')) payload.notes = rawChanges.note || null if (Object.keys(payload).length > 0) { @@ -1639,6 +1641,7 @@ function App() { localPerson.isAlive = changes.isAlive === true localPerson.isDeceased = changes.isAlive === false && !localPerson.deathYear } + if (Object.prototype.hasOwnProperty.call(changes, 'toVerify')) localPerson.toVerify = Boolean(changes.toVerify) tree.setSelectedPerson({ ...localPerson }) } return @@ -1665,6 +1668,7 @@ function App() { if (changes.isAlive === true) payload.deathDate = null payload.isDeceased = changes.isAlive === false } + if (Object.prototype.hasOwnProperty.call(changes, 'toVerify')) payload.toVerify = Boolean(changes.toVerify) if (Object.prototype.hasOwnProperty.call(changes, 'note')) payload.notes = changes.note || null if (Object.keys(payload).length > 0) { diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index b6a2fa9..21965ac 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -96,11 +96,13 @@ const PERSON_FIELD_LABELS = { nationality: t('Nationalité'), region: t('Région'), notes: t('Notes'), + toVerify: t('À vérifier'), } function displayVal(v, key) { // Sexe : « Non précisé » plutôt que « vide » if (key === 'sex') return sexLabel(v) + if (key === 'toVerify') return v ? t('Oui') : t('Non') if (v === null || v === undefined || v === '') return null return String(v) } diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index e90819c..c0ea473 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -199,6 +199,8 @@ } .pc-alive-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pz-faint); } .pc-alive.is-alive .pc-alive-dot { background: var(--pz-success); } +.pc-verify.is-on { background: var(--pz-a100); color: var(--pz-a700); border-color: transparent; } +.pc-verify.is-on .pc-alive-dot { background: var(--pz-a500); } /* Liens (relations) */ .pc-rel-row { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 4px 0; } diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index ee7b2e2..210b863 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -752,6 +752,7 @@ const PersonCard = ({ )} + {!editMode && person.toVerify && {t('À vérifier')}} {!editMode && person.profession && {person.profession}} @@ -949,6 +950,21 @@ const PersonCard = ({ )} + {editMode && ( + // La famille n'est pas sûre de cette personne : marquée sur l'arbre et dans la fiche (retour d'Anaïs, 23/10) +