Contributions fiables : prénom, mot d'accompagnement, liens gardés

- 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) <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-09-25 13:52:23 +02:00
1 parent 66752aa1b6
commit 8bfc78b2f5
12 files changed
+491 -141

No files matched your search

@@ -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;
+1
View File
@@ -268,6 +268,7 @@ model ContributionSession {
treeId String
submittedByUserId String?
submittedByLabel String
comment String?
mode ContributionActorMode
status ContributionSessionStatus @default(pending)
title String
+2 -2
View File
@@ -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<void> {
export async function syncPersonMediaOrder(prisma: Prisma.TransactionClient, treeId: string, personId: string): Promise<void> {
const media = await prisma.mediaItem.findMany({
where: {
treeId,
+185 -122
View File
@@ -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<typeof contributionChangeSchema>
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<string, string>
const TEMP_REF_PREFIX = 'tmp:'
const REF_FIELDS = ['partner1PersonId', 'partner2PersonId', 'parentPersonId', 'childPersonId', 'viaUnionId'] as const
function resolveTempRefs(after: Record<string, unknown>, refs: RefMap): Record<string, unknown> {
const resolved: Record<string, unknown> = { ...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<string, number> = { person: 0, union: 1, parent_child_link: 2, media: 3, annotation: 4 }
function byApplicationOrder<T extends { entityType: string; action: string }>(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,
@@ -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)
+67 -9
View File
@@ -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 && (
<div className="contrib-success-toast" role="status">{draftNotice}</div>
)}
{contribSuccess && (
<div className="contrib-success-toast" role="status">
Contribution envoyée — merci&nbsp;!
Contribution envoyée, merci&nbsp;!
</div>
)}
{/* Navbar contextuelle */}
+35 -2
View File
@@ -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
@@ -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 =
)}
<form className="cs-form" onSubmit={handleSubmit}>
{askName && (
<div className="pz-field">
<label htmlFor="cs-name">Votre prénom</label>
<input
id="cs-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value.slice(0, 120))}
placeholder="Pour que la famille sache qui a ajouté quoi"
autoComplete="given-name"
required
/>
</div>
)}
<div className="pz-field">
<label htmlFor="cs-comment">
Un mot pour accompagner <span className="cs-optional">facultatif</span>
@@ -115,7 +133,7 @@ function ContributionSessionModal({ visible, changes = [], recap = {}, loading =
<button type="button" className="pz-btn pz-btn--ghost" onClick={onCancel} disabled={loading}>
Annuler
</button>
<button type="submit" className="pz-btn pz-btn--primary" disabled={loading}>
<button type="submit" className="pz-btn pz-btn--primary" disabled={loading || (askName && !name.trim())}>
<Send size={16} strokeWidth={2} />
<PzBusy busy={loading} busyLabel="Envoi en cours">Envoyer mes contributions</PzBusy>
</button>
+27 -2
View File
@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { readContributorName } from '../utils/contributorName'
import { ArrowDown, ArrowUp, FileText, Image, MapPin, Mic, Plus, Quote, Route, Trash2, Upload, Video, X } from 'lucide-react'
import { getAcceptForMediaType } from '../utils/mediaUpload'
import GeoMediaThumbnail from './GeoMediaThumbnail'
@@ -40,6 +41,11 @@ const MediaManagerPanel = ({
const [citationText, setCitationText] = useState('')
const [videoMode, setVideoMode] = useState('file') // 'file' | 'youtube'
const [youtubeUrl, setYoutubeUrl] = useState('')
const [contributorName, setContributorName] = useState(() => readContributorName())
// Le panneau est monté dès le départ : relire le prénom à chaque ouverture (il a pu être donné entre-temps)
useEffect(() => {
if (visible) setContributorName((current) => current || readContributorName())
}, [visible])
const isCitation = mediaType === 'citation'
const isYoutubeVideo = mediaType === 'video' && videoMode === 'youtube'
@@ -48,6 +54,10 @@ const MediaManagerPanel = ({
return false
}
if (!canManage && !contributorName.trim()) {
return false
}
if (isCitation) {
return citationText.trim().length > 0
}
@@ -57,7 +67,7 @@ const MediaManagerPanel = ({
}
return Boolean(file)
}, [citationText, file, isCitation, isYoutubeVideo, loading, youtubeUrl])
}, [canManage, citationText, contributorName, file, isCitation, isYoutubeVideo, loading, youtubeUrl])
const fileAccept = useMemo(() => getAcceptForMediaType(mediaType), [mediaType])
if (!visible || !person) {
@@ -77,6 +87,7 @@ const MediaManagerPanel = ({
mediaType,
citationText: citationText.trim(),
youtubeUrl: isYoutubeVideo ? youtubeUrl.trim() : null,
submittedByLabel: canManage ? null : contributorName.trim(),
})
setFile(null)
setCaption('')
@@ -228,6 +239,20 @@ const MediaManagerPanel = ({
</div>
</div>
{!canManage && (
<div className="pz-field">
<label htmlFor="mm-contributor-name">Votre prénom</label>
<input
id="mm-contributor-name"
type="text"
value={contributorName}
onChange={(e) => setContributorName(e.target.value.slice(0, 120))}
placeholder="Pour que la famille sache qui l'a ajouté"
autoComplete="given-name"
/>
</div>
)}
{errorMessage && <div className="pz-error" role="alert">{errorMessage}</div>}
{notice && !errorMessage && <div className="pz-success" role="status">{notice}</div>}
@@ -175,7 +175,15 @@ function ContextualNavbar({
icon={<Edit3 size={20} strokeWidth={2} />}
label="Contribuer à l'arbre"
onClick={onEditClick}
active={editModeActive}
/>
{editModeActive && (
<NavButton
icon={<UserPlus size={20} strokeWidth={2} />}
label="Ajouter une personne"
onClick={onAddPersonClick}
/>
)}
<NavButton
icon={<User size={20} strokeWidth={2} />}
label="Créer un compte"
+9
View File
@@ -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,
)
+20
View File
@@ -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é
}
}