From 8bfc78b2f52f68ed7d3f51533febfcd19abf7e97 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 25 Sep 2026 13:52:23 +0200 Subject: [PATCH] =?UTF-8?q?Contributions=20fiables=20:=20pr=C3=A9nom,=20mo?= =?UTF-8?q?t=20d'accompagnement,=20liens=20gard=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prénom demandé à l'envoi d'une contribution et d'un souvenir, retenu sur l'appareil pour la fois suivante (submittedByLabel). Le propriétaire lit « par Camille » au lieu de « par Contributeur ». - Mot d'accompagnement enregistré (ContributionSession.comment, migration additive) et affiché à la relecture. - Une personne ajoutée par la famille garde ses liens : identifiant provisoire « tmp:… » remplacé par le vrai id à l'application ; un lien vers une personne refusée est écarté au lieu de faire échouer la relecture. Frère ou sœur : parents calculés aussi pour la famille. - Soumission et relecture en transaction, appliquées dans l'ordre personnes, unions, liens : tout ou rien. - Bouton « Ajouter une personne » pour la famille sur ordinateur ; après l'ajout, le panneau se ferme et un message confirme que la personne est dans les modifications ; il ne réapparaît plus au retour en édition. - Relecture : les liens se lisent « Léon, enfant de Marc ». Co-Authored-By: Claude Opus 5.5 (1M context) --- .../migration.sql | 4 + backend/prisma/schema.prisma | 1 + backend/src/lib/media-order.ts | 4 +- backend/src/routes/contributions.ts | 307 +++++++++++------- backend/test/api-security.integration.test.ts | 111 +++++++ frontend/src/App.jsx | 76 ++++- frontend/src/components/ContributionPanel.jsx | 37 ++- .../components/ContributionSessionModal.jsx | 26 +- frontend/src/components/MediaManagerPanel.jsx | 29 +- .../components/navbar/ContextualNavbar.jsx | 8 + frontend/src/hooks/useMediaManager.js | 9 + frontend/src/utils/contributorName.js | 20 ++ 12 files changed, 491 insertions(+), 141 deletions(-) create mode 100644 backend/prisma/migrations/20260925_002_contribution_comment/migration.sql create mode 100644 frontend/src/utils/contributorName.js diff --git a/backend/prisma/migrations/20260925_002_contribution_comment/migration.sql b/backend/prisma/migrations/20260925_002_contribution_comment/migration.sql new file mode 100644 index 0000000..d9d27b7 --- /dev/null +++ b/backend/prisma/migrations/20260925_002_contribution_comment/migration.sql @@ -0,0 +1,4 @@ +-- Mot d'accompagnement d'une contribution de la famille (25/09/2026). Additif, facultatif. + +-- AlterTable +ALTER TABLE "contribution_sessions" ADD COLUMN "comment" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d509bb2..a2b13b6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -268,6 +268,7 @@ model ContributionSession { treeId String submittedByUserId String? submittedByLabel String + comment String? mode ContributionActorMode status ContributionSessionStatus @default(pending) title String diff --git a/backend/src/lib/media-order.ts b/backend/src/lib/media-order.ts index b9f2c29..796c74c 100644 --- a/backend/src/lib/media-order.ts +++ b/backend/src/lib/media-order.ts @@ -1,8 +1,8 @@ -import type { PrismaClient } from '@prisma/client' +import type { Prisma } from '@prisma/client' // Renumérote les souvenirs acceptés d'une personne et met en avant les trois premiers. // Les souvenirs en attente de relecture ne comptent pas. -export async function syncPersonMediaOrder(prisma: PrismaClient, treeId: string, personId: string): Promise { +export async function syncPersonMediaOrder(prisma: Prisma.TransactionClient, treeId: string, personId: string): Promise { const media = await prisma.mediaItem.findMany({ where: { treeId, diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index cf81afd..f3f9a00 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -28,7 +28,8 @@ const contributionChangeSchema = z.object({ const createSessionSchema = z.object({ title: z.string().min(2).max(120), - submittedByLabel: z.string().min(1).max(120).optional(), + submittedByLabel: z.string().trim().min(1).max(120).optional(), + comment: z.string().trim().max(500).optional().nullable(), changes: z.array(contributionChangeSchema).min(1), }) @@ -130,7 +131,43 @@ type ContributionStatus = 'pending' | 'approved' | 'rejected' type ParsedChange = z.infer -type TxClient = FastifyInstance['prisma'] +type TxClient = Prisma.TransactionClient + +// Identifiants provisoires : une personne ajoutée dans une contribution porte `ref: "tmp:…"`, +// ses liens (union, parent-enfant) y font référence ; remplacés par le vrai id à l'application. +type RefMap = Map +const TEMP_REF_PREFIX = 'tmp:' +const REF_FIELDS = ['partner1PersonId', 'partner2PersonId', 'parentPersonId', 'childPersonId', 'viaUnionId'] as const + +function resolveTempRefs(after: Record, refs: RefMap): Record { + const resolved: Record = { ...after } + for (const field of REF_FIELDS) { + const value = resolved[field] + if (typeof value === 'string' && value.startsWith(TEMP_REF_PREFIX)) { + const realId = refs.get(value) + if (!realId) { + throw new ContributionRouteError(400, 'unresolved_reference') + } + resolved[field] = realId + } + } + return resolved +} + +function recordTempRef(after: unknown, createdId: string, refs: RefMap) { + if (isRecord(after) && typeof after.ref === 'string' && after.ref.startsWith(TEMP_REF_PREFIX)) { + refs.set(after.ref, createdId) + } +} + +// Personnes d'abord, puis unions, puis liens : un lien vers une personne ajoutée s'applique après elle +const ENTITY_ORDER: Record = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4 } +function byApplicationOrder(changes: T[]): T[] { + const rank = (change: T) => (ENTITY_ORDER[change.entityType] ?? 9) * 10 + (change.action === 'create' ? 0 : 1) + return changes.map((change, index) => ({ change, index })) + .sort((a, b) => rank(a.change) - rank(b.change) || a.index - b.index) + .map(({ change }) => change) +} export class ContributionRouteError extends Error { statusCode: number @@ -245,6 +282,7 @@ async function applyContributionChange( treeId: string, change: ParsedChange, actorUserId: string | null, + refs: RefMap = new Map(), ): Promise<{ entityId: string | null }> { // Les annotations sont appliquées par le client après la relecture (batchAnnotations) : // rien à faire ici. Avant, elles tombaient dans la branche des liens et faisaient échouer la relecture. @@ -310,6 +348,7 @@ async function applyContributionChange( }, }) + recordTempRef(change.after, created.id, refs) return { entityId: created.id } } @@ -385,7 +424,7 @@ async function applyContributionChange( throw new ContributionRouteError(400, 'invalid_change_after') } - const parsed = unionCreateSchema.safeParse(change.after) + const parsed = unionCreateSchema.safeParse(resolveTempRefs(change.after, refs)) if (!parsed.success) { throw new ContributionRouteError(400, 'invalid_change_after') } @@ -420,6 +459,7 @@ async function applyContributionChange( }, }) + recordTempRef(change.after, created.id, refs) return { entityId: created.id } } @@ -551,7 +591,7 @@ async function applyContributionChange( throw new ContributionRouteError(400, 'invalid_change_after') } - const parsed = linkCreateSchema.safeParse(change.after) + const parsed = linkCreateSchema.safeParse(resolveTempRefs(change.after, refs)) if (!parsed.success) { throw new ContributionRouteError(400, 'invalid_change_after') } @@ -966,59 +1006,67 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => { try { const context = await resolveSubmissionContext(app, request.actor, params.data.id) const now = new Date() - const session = await app.prisma.contributionSession.create({ - data: { - treeId: params.data.id, - submittedByUserId: context.submittedByUserId, - submittedByLabel: payload.data.submittedByLabel?.trim() || context.submittedByLabel, - mode: context.mode, - status: context.autoStatus, - title: payload.data.title.trim(), - reviewedAt: context.autoStatus === 'approved' ? now : undefined, - reviewedBy: context.autoStatus === 'approved' ? context.submittedByUserId : undefined, - }, - }) - const createdChanges = [] - - for (const change of payload.data.changes) { - let effectiveEntityId = change.entityId ?? null - - if (context.autoStatus === 'approved') { - const applied = await applyContributionChange(app.prisma, params.data.id, change, context.submittedByUserId) - effectiveEntityId = applied.entityId - } - - const createdChange = await app.prisma.contributionChange.create({ + // Tout ou rien : une erreur au milieu n'applique rien + const { session, createdChanges } = await app.prisma.$transaction(async (tx) => { + const session = await tx.contributionSession.create({ data: { - sessionId: session.id, - entityType: change.entityType, - entityId: effectiveEntityId, - action: change.action, - beforeJson: toPrismaJson(change.before), - afterJson: toPrismaJson(change.after), - conflictState: change.conflictState ?? 'none', - decision: context.autoStatus === 'approved' ? 'approved' : undefined, + treeId: params.data.id, + submittedByUserId: context.submittedByUserId, + submittedByLabel: payload.data.submittedByLabel?.trim() || context.submittedByLabel, + comment: payload.data.comment?.trim() || null, + mode: context.mode, + status: context.autoStatus, + title: payload.data.title.trim(), + reviewedAt: context.autoStatus === 'approved' ? now : undefined, + reviewedBy: context.autoStatus === 'approved' ? context.submittedByUserId : undefined, }, }) - createdChanges.push(createdChange) - } + const refs: RefMap = new Map() + const createdChanges = [] - await app.prisma.auditLog.create({ - data: { - treeId: params.data.id, - actorType: context.actorType, - actorId: context.actorId, - action: 'contribution_session_submitted', - entityType: 'contribution_session', - entityId: session.id, - payloadJson: { - status: context.autoStatus, - changesCount: createdChanges.length, + for (const change of byApplicationOrder(payload.data.changes)) { + let effectiveEntityId = change.entityId ?? null + + if (context.autoStatus === 'approved') { + const applied = await applyContributionChange(tx, params.data.id, change, context.submittedByUserId, refs) + effectiveEntityId = applied.entityId + } + + const createdChange = await tx.contributionChange.create({ + data: { + sessionId: session.id, + entityType: change.entityType, + entityId: effectiveEntityId, + action: change.action, + beforeJson: toPrismaJson(change.before), + afterJson: toPrismaJson(change.after), + conflictState: change.conflictState ?? 'none', + decision: context.autoStatus === 'approved' ? 'approved' : undefined, + }, + }) + + createdChanges.push(createdChange) + } + + await tx.auditLog.create({ + data: { + treeId: params.data.id, + actorType: context.actorType, + actorId: context.actorId, + action: 'contribution_session_submitted', + entityType: 'contribution_session', + entityId: session.id, + payloadJson: { + status: context.autoStatus, + changesCount: createdChanges.length, + }, }, - }, - }) + }) + + return { session, createdChanges } + }, { timeout: 30_000 }) return reply.code(201).send({ session, @@ -1119,93 +1167,108 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => { explicitDecisions.set(changeDecision.changeId, changeDecision.decision) } - let approvedCount = 0 - let rejectedCount = 0 - let conflictCount = 0 + const { updatedSession, approvedCount, rejectedCount, conflictCount, status } = await app.prisma.$transaction(async (tx) => { + let approvedCount = 0 + let rejectedCount = 0 + let conflictCount = 0 + const refs: RefMap = new Map() - for (const change of session.changes) { - let decision = explicitDecisions.get(change.id) ?? payload.data.decision + for (const change of byApplicationOrder(session.changes)) { + let decision = explicitDecisions.get(change.id) ?? payload.data.decision - const hasConflict = await detectConflict(app.prisma, params.data.id, { - entityType: change.entityType, - action: change.action, - entityId: change.entityId, - beforeJson: change.beforeJson, - }) - - if (hasConflict && change.conflictState !== 'needs_review') { - await app.prisma.contributionChange.update({ - where: { id: change.id }, - data: { conflictState: 'needs_review' }, + const hasConflict = await detectConflict(tx, params.data.id, { + entityType: change.entityType, + action: change.action, + entityId: change.entityId, + beforeJson: change.beforeJson, }) - conflictCount += 1 - } - if (decision === 'approved') { - await applyContributionChange( - app.prisma, - params.data.id, - { - entityType: change.entityType, - action: change.action, - entityId: change.entityId, - before: change.beforeJson, - after: change.afterJson, - conflictState: change.conflictState, - }, - membership.userId, - ) - - approvedCount += 1 - } else { - if (change.entityType === 'media' && change.entityId) { - await app.prisma.mediaItem.updateMany({ - where: { id: change.entityId, treeId: params.data.id }, - data: { status: 'rejected', deletedAt: new Date() }, + if (hasConflict && change.conflictState !== 'needs_review') { + await tx.contributionChange.update({ + where: { id: change.id }, + data: { conflictState: 'needs_review' }, }) + conflictCount += 1 } - rejectedCount += 1 + + if (decision === 'approved') { + try { + await applyContributionChange( + tx, + params.data.id, + { + entityType: change.entityType, + action: change.action, + entityId: change.entityId, + before: change.beforeJson, + after: change.afterJson, + conflictState: change.conflictState, + }, + membership.userId, + refs, + ) + approvedCount += 1 + } catch (error) { + // Lien vers une personne ajoutée mais refusée : le lien est écarté, pas la relecture + if (error instanceof ContributionRouteError && error.code === 'unresolved_reference') { + decision = 'rejected' + rejectedCount += 1 + } else { + throw error + } + } + } else { + if (change.entityType === 'media' && change.entityId) { + await tx.mediaItem.updateMany({ + where: { id: change.entityId, treeId: params.data.id }, + data: { status: 'rejected', deletedAt: new Date() }, + }) + } + rejectedCount += 1 + } + + await tx.contributionChange.update({ + where: { + id: change.id, + }, + data: { + decision, + }, + }) } - await app.prisma.contributionChange.update({ + const status: ContributionStatus = approvedCount > 0 ? 'approved' : 'rejected' + + const updatedSession = await tx.contributionSession.update({ where: { - id: change.id, + id: session.id, }, data: { - decision, + status, + reviewedAt: new Date(), + reviewedBy: membership.userId, }, }) - } - const status: ContributionStatus = approvedCount > 0 ? 'approved' : 'rejected' - - const updatedSession = await app.prisma.contributionSession.update({ - where: { - id: session.id, - }, - data: { - status, - reviewedAt: new Date(), - reviewedBy: membership.userId, - }, - }) - - await app.prisma.auditLog.create({ - data: { - treeId: params.data.id, - actorType: 'user', - actorId: membership.userId, - action: 'contribution_session_reviewed', - entityType: 'contribution_session', - entityId: session.id, - payloadJson: { - approvedCount, - rejectedCount, - conflictCount, - finalStatus: status, + await tx.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: membership.userId, + action: 'contribution_session_reviewed', + entityType: 'contribution_session', + entityId: session.id, + payloadJson: { + approvedCount, + rejectedCount, + conflictCount, + finalStatus: status, + }, }, - }, - }) + }) + + return { updatedSession, approvedCount, rejectedCount, conflictCount, status } + }, { timeout: 30_000 }) return reply.send({ session: updatedSession, diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index 4707845..5c9edff 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -673,6 +673,117 @@ describe('API security and authz', () => { await app.close() }) + // Contributions fiables (25/09/2026) : mot d'accompagnement, liens d'une personne ajoutée + const signShareToken = (app: any) => app.jwt.sign({ + kind: 'tree_access', + sub: 'tree:tree-1:contributor', + treeId: 'tree-1', + role: 'contributor', + accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(), + }) + + it('stores the contributor first name and comment', async () => { + const app = createApp() + await app.ready() + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'pending' } }) + + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-1/contributions/sessions', + headers: { authorization: `Bearer ${signShareToken(app)}` }, + payload: { + title: 'Contribution de Camille', + submittedByLabel: 'Camille', + comment: "J'ai ajouté mon cousin", + changes: [{ entityType: 'person', action: 'create', after: { firstName: 'Léon', lastName: 'Martin' } }], + }, + }) + + expect(response.statusCode).toBe(201) + expect(prismaMock.contributionSession.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ submittedByLabel: 'Camille', comment: "J'ai ajouté mon cousin" }) }), + ) + await app.close() + }) + + it('links a person added in the same contribution, even when the link comes first', async () => { + const app = createApp() + await app.ready() + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'direct' } }) + prismaMock.person.create.mockResolvedValue({ id: 'person-new' }) + + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-1/contributions/sessions', + headers: { authorization: `Bearer ${signShareToken(app)}` }, + payload: { + title: 'Un enfant pour Alice', + changes: [ + { entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'person-1', childPersonId: 'tmp:léon', parentageType: 'biologique' } }, + { entityType: 'person', action: 'create', after: { ref: 'tmp:léon', firstName: 'Léon', lastName: 'Martin' } }, + ], + }, + }) + + expect(response.statusCode).toBe(201) + expect(prismaMock.parentChildLink.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ parentPersonId: 'person-1', childPersonId: 'person-new' }) }), + ) + expect(prismaMock.$transaction).toHaveBeenCalled() + await app.close() + }) + + it('drops the link of a rejected new person instead of failing the review', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.contributionSession.findFirst.mockResolvedValue({ + id: 'session-1', + treeId: 'tree-1', + status: 'pending', + changes: [ + { id: 'change-link', entityType: 'parent_child_link', action: 'create', entityId: null, beforeJson: null, afterJson: { parentPersonId: 'person-1', childPersonId: 'tmp:léon' }, conflictState: 'none' }, + { id: 'change-person', entityType: 'person', action: 'create', entityId: null, beforeJson: null, afterJson: { ref: 'tmp:léon', firstName: 'Léon', lastName: 'Martin' }, 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', + changeDecisions: [{ changeId: 'change-person', decision: 'rejected' }], + }, + }) + + expect(response.statusCode).toBe(200) + expect(prismaMock.parentChildLink.create).not.toHaveBeenCalled() + expect(prismaMock.contributionChange.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'change-link' }, data: { decision: 'rejected' } }), + ) + await app.close() + }) + + it('refuses a link to an unknown provisional person at submission', async () => { + const app = createApp() + await app.ready() + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null, settings: { contributorPolicy: 'direct' } }) + + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-1/contributions/sessions', + headers: { authorization: `Bearer ${signShareToken(app)}` }, + payload: { + title: 'Lien orphelin', + changes: [{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'person-1', childPersonId: 'tmp:personne' } }], + }, + }) + + expect(response.statusCode).toBe(400) + expect(response.json().error).toBe('unresolved_reference') + await app.close() + }) + it('allows admin to list pending contribution sessions', async () => { const app = createApp() const token = await createUserToken(app) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e6363cf..a60de94 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -61,6 +61,7 @@ import { import './App.css' import './styles/pz.css' import { setBooting } from './bootSignal.js' +import { saveContributorName } from './utils/contributorName' function yearToIsoDate(value) { if (value === null || value === undefined || value === '') { @@ -202,10 +203,14 @@ function buildContributionChangesFromDraft(draft = {}) { if (!person || typeof person !== 'object') continue 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 + changes.push({ entityType: 'person', action: 'create', after: { + ...(ref ? { ref } : {}), firstName: String(person.firstName).trim(), lastName: String(person.lastName).trim(), birthName: person.birthName ? String(person.birthName).trim() : null, @@ -213,6 +218,32 @@ function buildContributionChangesFromDraft(draft = {}) { deathDate: person.deathDate || null, }, }) + + if (!ref) continue + + const linkedParentIds = new Set() + for (const rel of Array.isArray(person.relations) ? person.relations : []) { + if (!rel?.personId) continue + const relatedId = String(rel.personId) + if (rel.type === 'child') { + linkedParentIds.add(relatedId) + changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: 'biologique' } }) + } else if (rel.type === 'parent') { + changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: 'biologique' } }) + } else if (rel.type === 'spouse') { + changes.push({ entityType: 'union', action: 'create', after: { partner1PersonId: relatedId, partner2PersonId: ref, unionType: 'mariage' } }) + } + } + + for (const link of Array.isArray(person.siblingParentLinks) ? person.siblingParentLinks : []) { + if (!link?.parentId || linkedParentIds.has(String(link.parentId))) continue + linkedParentIds.add(String(link.parentId)) + changes.push({ + entityType: 'parent_child_link', + action: 'create', + after: { parentPersonId: String(link.parentId), childPersonId: ref, viaUnionId: link.unionId || null, parentageType: 'biologique' }, + }) + } } const modifiedPersons = draft.modifiedPersons && typeof draft.modifiedPersons === 'object' @@ -349,10 +380,11 @@ function buildContributionChangesFromDraft(draft = {}) { function buildContribChangesList(draft = {}) { const items = [] - const addedPersonIds = (Array.isArray(draft.addedPersons) ? draft.addedPersons : []).map(String) + const addedPersonEntries = Array.isArray(draft.addedPersons) ? draft.addedPersons : [] + const addedPersonIds = addedPersonEntries.filter((entry) => typeof entry !== 'object').map(String) - for (const id of addedPersonIds) { - const d = draft.modifiedPersons?.[id] || {} + for (const entry of addedPersonEntries) { + const d = entry && typeof entry === 'object' ? entry : (draft.modifiedPersons?.[String(entry)] || {}) const name = `${d.firstName || ''} ${d.lastName || ''}`.trim() || 'Nouvelle personne' items.push({ entityType: 'person', action: 'create', label: name }) } @@ -812,6 +844,10 @@ function App() { // Panneau ajout personne const [addPersonPanelVisible, setAddPersonPanelVisible] = useState(false) + // Quitter le mode édition ferme le panneau d'ajout (sinon il réapparaît au retour, vide) + useEffect(() => { + if (!editMode.isActive) setAddPersonPanelVisible(false) + }, [editMode.isActive]) const [addPersonLoading, setAddPersonLoading] = useState(false) const [addPersonError, setAddPersonError] = useState('') @@ -835,7 +871,7 @@ function App() { // Un frère ou une sœur reçoit les mêmes parents que la personne choisie : // sans parent connu, on s'arrête avant de créer quoi que ce soit. const siblingParentLinks = [] - if (tree.canEditCurrentTree || isDemoMode) { + if (Array.isArray(formData.relations)) { for (const rel of formData.relations) { if (rel.type !== 'sibling') continue const links = getSiblingParentLinks(rel.personId, filiations, unions) @@ -934,14 +970,20 @@ function App() { const addedPersons = [ ...(draft.addedPersons || []), { + ref: `tmp:${makeLocalId('personne')}`, firstName: formData.firstName, lastName: formData.lastName, birthName: formData.birthName || null, birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, + relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })), + siblingParentLinks, }, ] editMode.updateDraft({ addedPersons }) + // La personne n'apparaît dans l'arbre qu'après relecture : le dire, et fermer le panneau + setAddPersonPanelVisible(false) + setDraftNotice(`${formData.firstName} est dans vos modifications. Envoyez-les quand vous avez fini.`) return } @@ -1019,6 +1061,12 @@ function App() { const [contribSubmitLoading, setContribSubmitLoading] = useState(false) const [contribSubmitError, setContribSubmitError] = useState('') const [contribSuccess, setContribSuccess] = useState(false) + const [draftNotice, setDraftNotice] = useState('') + useEffect(() => { + if (!draftNotice) return undefined + const timer = setTimeout(() => setDraftNotice(''), 4500) + return () => clearTimeout(timer) + }, [draftNotice]) // Soumission des modifications const handleEditSubmit = useCallback(async () => { @@ -1114,7 +1162,7 @@ function App() { } }, [userRole.isAdmin, auth.userAuth.token, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps - const handleContribSessionSubmit = useCallback(async ({ comment }) => { + const handleContribSessionSubmit = useCallback(async ({ comment, name }) => { setContribSubmitLoading(true) setContribSubmitError('') @@ -1129,10 +1177,16 @@ function App() { const now = new Date() const dateStr = now.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }) - const userName = auth.userAuth.user?.name || 'Anonyme' - const title = `Contribution de ${userName} · ${dateStr}` + const contributorName = (name || auth.userAuth.user?.name || '').trim() + if (name) saveContributorName(name) + const title = `Contribution de ${contributorName || 'la famille'} · ${dateStr}`.slice(0, 120) - const result = await contrib.handleSubmitContributionSession({ title, comment, changes }) + const result = await contrib.handleSubmitContributionSession({ + title, + comment: comment || null, + ...(contributorName ? { submittedByLabel: contributorName } : {}), + changes, + }) if (!result?.ok) { setContribSubmitError(result?.error || "Erreur lors de l'envoi des contributions") @@ -2431,13 +2485,17 @@ function App() { })()} loading={contribSubmitLoading} error={contribSubmitError} + askName={!auth.userAuth.user?.name} onSubmit={handleContribSessionSubmit} onCancel={() => setShowContribModal(false)} /> {/* Toast succès contribution */} + {draftNotice && !contribSuccess && ( +
{draftNotice}
+ )} {contribSuccess && (
- Contribution envoyée — merci ! + Contribution envoyée, merci !
)} {/* Navbar contextuelle */} diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index 22e14e8..f737bb2 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' import { API_BASE_URL } from '../api/client' +import { persons as treePersons } from '../data/mockData' import { Check, ChevronDown, Copy, RefreshCw, X } from 'lucide-react' import './SettingsPanels.css' import PzBusy from './PzBusy.jsx' @@ -71,7 +72,39 @@ function displayVal(v) { return String(v) } -function getPersonDisplayName(change) { +// Nom d'une personne citée par un lien : dans l'arbre, ou ajoutée dans la même contribution (id provisoire « tmp: ») +function nameOfPersonRef(id, sessionChanges = []) { + if (!id) return null + const key = String(id) + if (key.startsWith('tmp:')) { + const added = sessionChanges.find((c) => c.entityType === 'person' && c.afterJson?.ref === key) + const name = `${added?.afterJson?.firstName || ''} ${added?.afterJson?.lastName || ''}`.trim() + return name || 'la nouvelle personne' + } + const person = treePersons.find((candidate) => String(candidate.id) === key) + const name = `${person?.firstName || ''} ${person?.lastName || ''}`.trim() + return name || null +} + +function getRelationLabel(change, sessionChanges) { + const data = change.afterJson && typeof change.afterJson === 'object' ? change.afterJson : null + if (!data) return null + if (change.entityType === 'parent_child_link') { + const child = nameOfPersonRef(data.childPersonId, sessionChanges) + const parent = nameOfPersonRef(data.parentPersonId, sessionChanges) + return child && parent ? `${child}, enfant de ${parent}` : null + } + if (change.entityType === 'union') { + const a = nameOfPersonRef(data.partner1PersonId, sessionChanges) + const b = nameOfPersonRef(data.partner2PersonId, sessionChanges) + return a && b ? `${a} et ${b}` : null + } + return null +} + +function getPersonDisplayName(change, sessionChanges = []) { + const relationLabel = getRelationLabel(change, sessionChanges) + if (relationLabel) return relationLabel if (change.entityType === 'annotation') { const data = change.afterJson || change.after || {} return formatAnnotationType(data.type) @@ -413,7 +446,7 @@ const ContributionPanel = ({ const decision = getChangeDecision(session.id, change.id) const isApproved = decision === 'approved' const isRejected = decision === 'rejected' - const personName = getPersonDisplayName(change) + const personName = getPersonDisplayName(change, session.changes || []) const fields = getFieldRows(change) const annPreview = change.entityType === 'annotation' ? getAnnotationPreview(change) : null const mediaPreview = change.entityType === 'media' ? getMediaPreview(change, treeId, mediaToken) : null diff --git a/frontend/src/components/ContributionSessionModal.jsx b/frontend/src/components/ContributionSessionModal.jsx index b4bc6a0..fd189df 100644 --- a/frontend/src/components/ContributionSessionModal.jsx +++ b/frontend/src/components/ContributionSessionModal.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { Send, X } from 'lucide-react' import './ContributionSessionModal.css' import PzBusy from './PzBusy.jsx' +import { readContributorName } from '../utils/contributorName' function formatAction(action) { if (action === 'create') return 'Ajout' @@ -23,13 +24,15 @@ function formatAction(action) { * @param {Function} props.onSubmit - Handler soumission ({ comment }) * @param {Function} props.onCancel - Handler annulation */ -function ContributionSessionModal({ visible, changes = [], recap = {}, loading = false, error = '', onSubmit, onCancel }) { +function ContributionSessionModal({ visible, changes = [], recap = {}, loading = false, error = '', askName = true, onSubmit, onCancel }) { const [comment, setComment] = useState('') + const [name, setName] = useState('') - // Reset à l'ouverture + // Reset à l'ouverture ; le prénom déjà donné sur cet appareil est repris useEffect(() => { if (visible) { setComment('') + setName(readContributorName()) } }, [visible]) @@ -47,7 +50,8 @@ function ContributionSessionModal({ visible, changes = [], recap = {}, loading = const handleSubmit = (e) => { e.preventDefault() - onSubmit({ comment: comment.trim() }) + if (askName && !name.trim()) return + onSubmit({ comment: comment.trim(), name: name.trim() }) } const totalChanges = (recap.added || 0) + (recap.modified || 0) + (recap.deleted || 0) @@ -94,6 +98,20 @@ function ContributionSessionModal({ visible, changes = [], recap = {}, loading = )}
+ {askName && ( +
+ + setName(e.target.value.slice(0, 120))} + placeholder="Pour que la famille sache qui a ajouté quoi" + autoComplete="given-name" + required + /> +
+ )}
+ {!canManage && ( +
+ + setContributorName(e.target.value.slice(0, 120))} + placeholder="Pour que la famille sache qui l'a ajouté" + autoComplete="given-name" + /> +
+ )} + {errorMessage &&
{errorMessage}
} {notice && !errorMessage &&
{notice}
} diff --git a/frontend/src/components/navbar/ContextualNavbar.jsx b/frontend/src/components/navbar/ContextualNavbar.jsx index 9c6c25e..113c685 100644 --- a/frontend/src/components/navbar/ContextualNavbar.jsx +++ b/frontend/src/components/navbar/ContextualNavbar.jsx @@ -175,7 +175,15 @@ function ContextualNavbar({ icon={} label="Contribuer à l'arbre" onClick={onEditClick} + active={editModeActive} /> + {editModeActive && ( + } + label="Ajouter une personne" + onClick={onAddPersonClick} + /> + )} } label="Créer un compte" diff --git a/frontend/src/hooks/useMediaManager.js b/frontend/src/hooks/useMediaManager.js index 56cab5b..01d1f6e 100644 --- a/frontend/src/hooks/useMediaManager.js +++ b/frontend/src/hooks/useMediaManager.js @@ -3,6 +3,7 @@ import { uploadPersonMedia, reorderPersonMedia, deletePersonMedia } from '../api import { getPersonMedias } from '../data/mockData' import { fileToBase64, getAccountErrorMessage } from '../utils/errorMessages' import { getUploadMimeType, resolveUploadMediaType, validateMediaFile } from '../utils/mediaUpload' +import { saveContributorName } from '../utils/contributorName' // Souvenir envoyé par la famille : en attente de la relecture du propriétaire function noticeFor(created) { @@ -57,11 +58,16 @@ export function useMediaManager({ mediaType, citationText = '', youtubeUrl = null, + submittedByLabel = null, }) => { if (!canUploadMedia || !selectedPerson || !treeContext.treeId || !treeContext.accessToken) { return } + // Prénom de la personne qui propose (famille), retenu pour la fois suivante + const byLabel = submittedByLabel ? { submittedByLabel } : {} + if (submittedByLabel) saveContributorName(submittedByLabel) + const personId = selectedPerson.id // YouTube video — no file needed @@ -77,6 +83,7 @@ export function useMediaManager({ youtubeUrl, caption: caption || null, source, + ...byLabel, }, treeContext.accessToken, ) @@ -117,6 +124,7 @@ export function useMediaManager({ type: 'citation', caption: resolvedCitationText, source, + ...byLabel, }, treeContext.accessToken, ) @@ -134,6 +142,7 @@ export function useMediaManager({ dataBase64, caption, source, + ...byLabel, }, treeContext.accessToken, ) diff --git a/frontend/src/utils/contributorName.js b/frontend/src/utils/contributorName.js new file mode 100644 index 0000000..eb5a0e9 --- /dev/null +++ b/frontend/src/utils/contributorName.js @@ -0,0 +1,20 @@ +// Prénom de la personne qui contribue, retenu sur cet appareil pour ne pas le redemander. +const STORAGE_KEY = 'parenthese_contributor_name' + +export function readContributorName() { + try { + return localStorage.getItem(STORAGE_KEY) || '' + } catch { + return '' + } +} + +export function saveContributorName(name) { + const value = String(name || '').trim().slice(0, 120) + if (!value) return + try { + localStorage.setItem(STORAGE_KEY, value) + } catch { + // stockage indisponible : le prénom sera redemandé + } +}