feat: support multiple senders per document #216
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Document.senderis a@ManyToOnerelationship (singlesender_idFK), but real-world documents can have multiple senders — e.g. Von-column entries like "Gerd u Brigitte de Gruyter" or "Hans u Alma Cram". Currently the model can only store one sender per document.This is a prerequisite for #214 (Von-column multi-person splitting). Without this change, routing Von entries through
parseReceivers()would produce multiple persons with nowhere to store them.Solution
Change the sender relationship from
@ManyToOneto@ManyToMany, symmetric with how receivers already work.Backend
Document.java@ManyToOne Person senderwith@ManyToMany Set<Person> senders(new join tabledocument_senders)V{n}__multiple_senders.sqldocument_sendersjoin table, migrate existingsender_iddata, dropsender_idcolumnDocumentUpdateDTO.javasenderId(single UUID) tosenderIds(List)DocumentService.javaDocumentSpecifications.javaMassImportService.javadoc.setSender()→doc.getSenders().add()Frontend
api.tsPersonMultiSelectorPersonChipRow)DocumentMetadataDrawer.svelteAcceptance criteria
Found in
Architecture discussion on #214 — identified as a prerequisite for Von-column multi-person splitting.
👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
DocumentUpdateDTOfield rename — The issue says changesenderId(UUID) tosenderIds(List). This is a breaking API change — every frontend form action and server load function that reads or writessenderIdwill need updating. Has the full list of call sites been mapped? I'd search forsenderIdacross the frontend before starting. Also, the new field should probably beSet<UUID>notList<UUID>for consistency withreceiverswhich usesSet<Person>.Sender input component choice — The issue suggests reusing
PersonMultiSelectorPersonChipRow. Currently the sender field usesPersonTypeahead(single-person selector). Switching toPersonMultiSelectchanges the interaction pattern significantly — chip-based multi-select vs. typeahead with single result. Is that the right UX, or should we keep the typeahead but allow multiple selections? This affects which component tests need updating.Test strategy — The migration is the riskiest part. I'd want:
@DataJpaTestwith Testcontainers that loads the migration on a DB with existing single-sender data and verifies the join table is correctly populatedDocumentServiceupdate logic handlingsenderIdsas a set+page.server.tsload function test verifying the newsendersarray is passed to the pageDocumentSpecifications.javasender filter — Currently the sender search probably does a simpledoc.sender.id = :senderIdjoin. With@ManyToMany, this becomes a subquery or exists-join on thedocument_senderstable. The Specification needs to mirror the pattern already used for receiver filtering — worth checking that the receiver spec is correct before duplicating it for senders.Suggestions
Document.javaentity change. Write the migration test first (red), then the migration (green). The rest of the backend and frontend changes flow from there.senderId→senderIdsin the DTO, but keep the API backward-compatible for one release by accepting both (single UUID auto-wrapped into a singleton set). This avoids a hard cutover.🏗️ Markus Keller — Application Architect
Questions & Observations
Join table design — The
document_sendersjoin table should mirrordocument_receiversexactly:(document_id UUID, person_id UUID, PRIMARY KEY (document_id, person_id))with FK constraints andON DELETE CASCADEon the document side. Is there an ordering requirement (first sender = primary sender)? If so, a join table with anordinalcolumn would be needed instead of a plain@ManyToMany. I'd say no — senders are unordered, same as receivers.Fetch strategy —
receiversusesFetchType.EAGERon the@ManyToMany. The same should apply tosendersfor consistency, but be aware this means everydocumentRepository.findById()now produces two additional joins. For 1500 documents with typically 1 sender each, this is fine. Worth noting for future scale.Migration safety — The migration needs to be split into two steps within the same Flyway file: (1) create
document_sendersand populate fromsender_id, (2) dropsender_id. If step 2 runs before step 1 completes, data is lost. Standard approach:INSERT INTO document_senders SELECT id, sender_id FROM documents WHERE sender_id IS NOT NULL, thenALTER TABLE documents DROP COLUMN sender_id.Search/filter symmetry — With senders and receivers both being
@ManyToManytoPerson, theDocumentSpecificationscode for filtering by sender and by receiver should be structurally identical. Consider extracting a shared spec methodhasPersonInRole(role, personId)that both use.Suggestions
ordinalcolumn to the join table unless there's a concrete requirement for sender ordering. YAGNI.🧪 Sara Holt — QA Engineer & Test Strategist
Questions & Observations
Migration test coverage — This is the highest-risk change. I want an integration test (
@SpringBootTest+ Testcontainers) that:sender_idvalues using raw SQL (pre-migration state)document_sendersrows match the originalsender_idvaluessender_id = NULLhave no rows indocument_senderssender_idcolumn no longer existsAcceptance criteria gaps — The criteria say "Search/filter by sender still works" but don't specify: does filtering by one sender return documents where that person is one of multiple senders, or only documents where they're the sole sender? The answer should be "any document where that person is a sender" (same as receiver filtering), but this needs to be explicit in the test.
Regression scope — Sender is referenced in:
Each of these is a regression test target. The conversation pages are the one most likely to break silently — they probably assume a single sender when building the correspondence timeline.
Edge case: document with 0 senders — The acceptance criteria include "0 senders". Is this a new valid state, or was it already possible (nullable
sender_id)? If it's new, existing code that doesdoc.getSender().getLastName()without a null check will NPE. With@ManyToManyand an empty set, the risk shifts todoc.getSenders().iterator().next()or similar first-element access patterns.Suggestions
🔒 Nora "NullX" Steiner — Application Security Engineer
Questions & Observations
Mass assignment risk on the new
senderIdsfield — TheDocumentUpdateDTOcurrently hassenderId(single UUID). Changing tosenderIds(list/set of UUIDs) means the API now accepts an array of person IDs. The existing@RequirePermission(WRITE_ALL)on the update endpoint covers authorization, but verify that:senderIdsexists in thepersonstable before creating the associationNo new attack surface — This is a structural change to an existing relationship. The trust boundary doesn't change: all sender data comes through the same authenticated API or the import pipeline. The
@ManyToManyjoin table uses parameterized JPA queries, so no injection risk from the new table.Migration data integrity — The migration should include a constraint:
FOREIGN KEY (person_id) REFERENCES persons(id)on the new join table. Without it, orphaned person references could accumulate if persons are deleted without cascading todocument_senders.Suggestions
senderIdis validated against thepersonstable before association. Standard data integrity, not a security issue per se.🎨 Leonie Voss — UI/UX Design Lead
Questions & Observations
Sender display in document list — Currently the document list shows a single sender name per row. With multiple senders, how should this display? Options:
PersonChipRowI'd recommend the overflow pill approach — it's already established in the receiver display and keeps the table column width manageable. The
OverflowPillButtoncomponent already exists for this.Document edit form — sender input — Switching from
PersonTypeahead(single select) toPersonMultiSelect(chip-based multi-select) is the right call. ThePersonMultiSelectcomponent already handles the interaction pattern: typeahead search → select → chip appears → can remove chips. No new component needed.Document detail view / metadata drawer — The sender currently displays as a single
PersonChip. With multiple senders, usePersonChipRow(same as receivers). This makes the sender and receiver sections visually symmetric, which is cleaner.Mobile consideration — Multiple sender chips in the metadata drawer need to wrap gracefully on small viewports.
PersonChipRowalready handles this withflex-wrap, so no additional work needed — just verify it at 320px.Suggestions
PersonMultiSelectin the edit form andPersonChipRowin detail/drawer views⚙️ Tobias Wendt — DevOps & Platform Engineer
Questions & Observations
Flyway migration on production data — The migration needs to run on the production database with 1500+ documents. The
INSERT INTO document_senders SELECT id, sender_id FROM documents WHERE sender_id IS NOT NULLis a single-pass bulk insert — should complete in under a second for this data volume. No downtime concern, but the migration should be tested against a copy of production data before deploying.No infrastructure changes — No new services, no config changes, no new environment variables. The Docker Compose setup is unaffected. The only thing that changes is the database schema (handled by Flyway) and the application code.
API type regeneration — The issue mentions
npm run generate:apito regenerate TypeScript types. This requires the backend running with--spring.profiles.active=dev. Make sure the CI pipeline or the developer workflow includes this step after the backend changes are merged — stale types will cause frontend build failures.Suggestions