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) +
+ {t('Vérification')} + +
+ )} {infoRow('deathYear', t('Décès'), deathFilled, renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') }), deceasedNow)} {infoRow('deathPlace', t('Lieu de décès'), isFilled(getEditValue('deathPlace', person.deathPlace)), renderEditableValue({ field: 'deathPlace', value: person.deathPlace || '', placeholder: t('Lieu de décès'), format: formatPlace }))} {!editMode && ( diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 173b30d..40a726f 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -603,6 +603,8 @@ function drawPersonNode(ctx, node, pos, nodeProgress, { } drawFrame(ctx, frameType, person, img) + // « À vérifier » : petit rond terracotta avec un « ? » en haut à droite du cadre, absent au dézoom + if (person.toVerify && withDetail) drawToVerifyMark(ctx) ctx.restore() // --- Labels (estompés puis absents en dézoom) --- @@ -928,6 +930,25 @@ function resolveFrameType(person, img) { return resolveFrameKind(person.frameType || 'round', ratio, cropShape(person.avatarCrop)) } +function drawToVerifyMark(ctx) { + const r = 9 + const x = PERSON_R * 0.74, y = -PERSON_R * 0.74 + ctx.shadowColor = 'transparent' + ctx.fillStyle = '#FFFFFF' + ctx.beginPath() + ctx.arc(x, y, r + 1.5, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#D2694A' + ctx.beginPath() + ctx.arc(x, y, r, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = '#FFFFFF' + ctx.font = '600 12px "DM Sans", system-ui, sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('?', x, y + 0.5) +} + function drawFrame(ctx, frameType, person, img) { if (frameType === 'polaroid') { const pw = PERSON_R + 4 diff --git a/frontend/src/components/search/SearchResultsDropdown.jsx b/frontend/src/components/search/SearchResultsDropdown.jsx index 7810f55..edf46fc 100644 --- a/frontend/src/components/search/SearchResultsDropdown.jsx +++ b/frontend/src/components/search/SearchResultsDropdown.jsx @@ -9,6 +9,7 @@ const REASON_LABELS = { Lieu: t('Lieu'), Date: t('Date'), Contenu: t('Contenu'), + 'À vérifier': t('À vérifier'), } /** diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index 76a6d06..2b4e0b6 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -113,6 +113,8 @@ function mapRemotePersonToLocal(person, current = null) { // Décédé(e) sans date connue (issue #77) isDeceased: Boolean(person?.isDeceased) && !deathYear, isAlive: !deathYear && !person?.isDeceased, + // « À vérifier » : la famille n'est pas sûre de cette personne + toVerify: person ? Boolean(person.toVerify) : Boolean(current?.toVerify), frameType: current?.frameType || pickFrameType(person?.id), note: person?.notes || null, birthPlace: person?.birthPlace || null, diff --git a/frontend/src/data/mockData.test.js b/frontend/src/data/mockData.test.js index 8163809..1da0da0 100644 --- a/frontend/src/data/mockData.test.js +++ b/frontend/src/data/mockData.test.js @@ -129,4 +129,14 @@ describe('personnes identifiées sur un souvenir', () => { { personId: 'p-2', x: null, y: null }, ]) }) -}) \ No newline at end of file +}) +describe('« À vérifier » au chargement du graphe', () => { + it('garde le drapeau, absent = faux', () => { + loadGraphData({ persons: [ + { id: 'a', firstName: 'Jeanne', lastName: 'Martin', toVerify: true }, + { id: 'b', firstName: 'Paul', lastName: 'Martin' }, + ] }) + expect(getPersonById('a').toVerify).toBe(true) + expect(getPersonById('b').toVerify).toBe(false) + }) +}) diff --git a/frontend/src/hooks/usePersonSearch.js b/frontend/src/hooks/usePersonSearch.js index 404e620..5e56062 100644 --- a/frontend/src/hooks/usePersonSearch.js +++ b/frontend/src/hooks/usePersonSearch.js @@ -29,6 +29,12 @@ const SCORE_WEIGHTS = { NOTE_CONTAINS: 20, } +// Taper « à vérifier » (avec ou sans accents, même en partie) liste les personnes marquées à vérifier +const asksToVerify = (q) => { + const plain = q.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, ' ') + return plain.length >= 5 && 'a verifier'.startsWith(plain) +} + /** * Seuil minimum de score pour afficher un résultat */ @@ -45,6 +51,7 @@ function calculateScore(query, person) { const q = query.toLowerCase().trim() if (!q || q.length < 2) return 0 + if (person.toVerify && asksToVerify(q)) return SCORE_WEIGHTS.NAME_EXACT // Normaliser les données de la personne const firstName = (person.firstName || '').toLowerCase() @@ -155,6 +162,10 @@ function getMatchReason(query, person) { const birthPlace = (person.birthPlace || '').toLowerCase() const birthYear = person.birthYear?.toString() || '' + if (person.toVerify && asksToVerify(q)) { + return 'À vérifier' + } + if (fullName === q || firstName === q || lastName === q) { return 'Nom' } diff --git a/frontend/src/hooks/usePersonSearch.test.js b/frontend/src/hooks/usePersonSearch.test.js index ee5721d..46a38f0 100644 --- a/frontend/src/hooks/usePersonSearch.test.js +++ b/frontend/src/hooks/usePersonSearch.test.js @@ -18,3 +18,19 @@ describe('recherche par surnom', () => { expect(getMatchReason('dupont', robert)).toBe('Nom') }) }) + +describe('recherche « à vérifier »', () => { + const doute = { firstName: 'Jeanne', lastName: 'Martin', toVerify: true } + const sure = { firstName: 'Paul', lastName: 'Martin', toVerify: false } + + it('trouve les personnes marquées, avec ou sans accents, même en partie', () => { + for (const q of ['à vérifier', 'a verifier', 'À VÉRIF', 'a ver']) { + expect(calculateScore(q.toLowerCase(), doute)).toBeGreaterThanOrEqual(MIN_SCORE_THRESHOLD) + } + expect(getMatchReason('a verifier', doute)).toBe('À vérifier') + }) + + it('ne remonte pas les personnes non marquées', () => { + expect(calculateScore('a verifier', sure)).toBeLessThan(MIN_SCORE_THRESHOLD) + }) +}) diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index dd0143a..03072e5 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -49,6 +49,7 @@ export default { 'Surnom': 'Nickname', 'Loulou': 'Lulu', 'Statut': 'Status', + 'Vérification': 'Check', 'En vie': 'Living', 'Décédé(e)': 'Deceased', 'Décédé': 'Deceased',