feat(events): surface absence/assignment conflicts across the app#250
Merged
Conversation
Layers three coordinated interventions so absence/assignment conflicts stop slipping through: - Blocking modal on the "mes absences" creation flow lists each overlapping assignment with the responsible to contact by name (falls back to a generic wording when there is none). - Amber "Conflit" / "N conflits" badge on every upcoming event card in the programme list, plus a `?hasConflicts=true` filter the dashboard card lands on. - Aggregated urgent-item card on the dashboard for template responsibles and `Permission.ProgramManager` holders — shows up to three absentee names and deep-links into the filtered event list. Also fixes a pre-existing ID-type bug in `checkDayOffConflict` and `refreshConflictFlags`: they mixed `UserAccount.id` (from `Event.createdById`) with `Member.id` (from `assigneeId` / `assistantId`), so the `hasConflict` invariant was only maintained by coincidence for users whose `Member.id` happened to equal their `UserAccount.id`. They now join through `Event.createdBy.memberId` and filter assignments consistently by `Member.id`; `days-off` callers pass `currentUser.member?.id` and skip the refresh for accounts with no linked member. Broadens the overlap lookup to untemplated events at the same time (excluding Off events themselves). Regression pins: - `refreshConflictFlags` filters part / service assignments by `memberId`, includes untemplated events, and excludes Off events. - `checkDayOffConflict` joins via `createdBy.memberId`. - `getResponsibleConflicts` self-heals when `hasConflict` clears on the underlying assignment; `ProgramManager` sees conflicts on untemplated events even without template responsibility.
Code changes: - `applyHasConflictsFilter` now composes via `AND` so a caller (or future filter) can freely set its own top-level `OR` without either clause clobbering the other. - `resolveResponsibleName` surfaces every named responsible on a template, deterministically sorted, instead of arbitrarily picking the first row Prisma returns. - `ResponsibleConflictsSummary` swaps `additionalAbsenteesCount` for `totalAbsenteesCount`; the formatter derives `extras = total - shown` so the `shown + extras = total` invariant is unforgeable. - Days-off action now logs a distinguishing info line when the conflict check is skipped because the current account has no linked Member, so ops can tell that branch apart from "member exists, zero conflicts". Test additions: - `refreshConflictFlags` unit tests now assert on `data.hasConflict` for both directions (true → set, false → clear) and iterate over multiple overlapping events. - `deleteDayOff` gets first-time unit coverage: delete-and-return, refresh when `memberId` is provided, skip when it is `null`. - `getResponsibleConflicts` gets an explicit null-assignee row test so the `record()` skip guard is behaviourally pinned. - `event-filters` gets a combined-params test asserting hasConflicts composes cleanly with from/to and publisher filters. - `list-user-conflicts-in-range` gets a multi-responsible test pinning the alphabetical join. Comment cleanups: - Trimmed `list-user-conflicts-in-range.server.ts`, `days-off/new.tsx`, `get-responsible-conflicts.server.ts`, `build-urgent-items.ts`, and `event-filters.server.ts` to keep the *why* and drop restatements / caller names / speculative future-tense. - Replaced the "at line 578" reference in `dashboard.integration.test.ts` with a name-based reference so an insert above cannot silently invalidate the cross-ref.
…mbing Security: - Gate the `getResponsibleConflicts` call on `ProgramViewer` (or `ProgramManager`). The card deep-links to `/programs?hasConflicts=true` which requires `ProgramViewer`; users without it now skip the query and the card is never rendered for them. Cleanups (no behavioural change): - Extract the shared event/template/responsibles select shape into a single `eventWithResponsiblesSelect` const in `list-user-conflicts-in-range.server.ts`; derive the private `EventWithResponsibles` type via `Prisma.EventGetPayload` so it stays welded to the schema. - Inline `formatResponsibleConflictLabel` into `urgentResponsibleConflictItems` (single call site); label formatting is now covered directly by the item builder's own tests. - Inline `pickConflictModalTitle` into `DayOffConflictModal` and delete the now-empty `day-off-conflict-helpers.ts` + its test file. - Collapse the `let conflicts` + `if/else` in `days-off/new.tsx` into a ternary, hoisting the null-member log line above it.
mindsers
force-pushed
the
feat/absence-conflict-awareness
branch
from
July 15, 2026 23:30
6a00c8f to
e44ad6c
Compare
Copy:
- Title now leads with "Attention :" and names the conflict directly:
"Attention : conflit avec {une affectation planifiée | {count} affectations planifiées}".
- Intro softens the warning while still nudging action:
"Votre absence est bien enregistrée. Elle chevauche cependant des
affectations déjà prévues — pensez à prévenir au plus vite les
responsables concernés…"
- Fallback row uses the definite article (`le responsable du programme`).
- Button becomes a commitment: "J'ai compris, je les préviens".
Visuals:
- Amber `AlertTriangle` badge stacked above title + description, all
centered — replaces the desktop `AlertDialogMedia` layout that put
the icon in a left column with the header body on the right.
- Conflict list wraps in an amber-tinted panel with a bullet dot per
row, so the list reads as a distinct block instead of drifting body
text under the intro.
English strings mirror the French tone shift.
10 tasks
mindsers
added a commit
that referenced
this pull request
Jul 16, 2026
refreshConflictFlags filtered with `kind: { key: { not: 'off' } }`. Prisma
inner-joins through the `kind` relation, so events where `kindId` is NULL
were silently dropped from the refresh loop. Seeded templates leave
`kindId` null, so every event generated from them inherited that null —
meaning the hasConflict flag never flipped when a publisher added an
overlapping absence.
That in turn broke:
- the amber Conflit badge on the events list,
- the absence badge next to the assignee on the event view page,
- the release-blocking policy (assertCanRelease saw hasConflict=false and
let the manager publish anyway).
Switch to `NOT: { kind: { key: 'off' } }`, which matches the events-list
filter shape from #250 and includes null-kind events. Update the
unit-level query-shape pin and add an integration test that reproduces
the full flow with a null-kind draft event so this can't silently
regress.
mindsers
added a commit
that referenced
this pull request
Jul 16, 2026
Adds an explicit status column on Event (draft | released) so a programme manager can build a schedule in private and publish it in one step. While an event is draft: - it stays off the public display board and the /board deep-link viewer, - assignment mutations do not fire notifications (release re-enqueues one assigned notification per current assignee, with the debounce shortened from 2h to 30 min as a safety net), - absence conflicts no longer block save — they set hasConflict=true and block release instead. Managers spot them via the amber Conflit badge from #250; assertCanRelease enumerates offenders in the release error. - publisher- and manager-facing conflict / upcoming queries filter it out. Draft conflicts are not urgent — they only become actionable at release time. Un-release is a first-class action; it flips status back to draft, hides the event again, and cancels pending NotificationEvent rows for the event's assignments (mails already sent stay sent). UX: Brouillon badge on the events-list rows and the event detail header, Publier / Repasser en brouillon single-event and bulk actions in the sticky selection bar, success/error toasts via session flash. Day-off events explicitly set status='released' at creation so the existing conflict pipeline continues to see them.
mindsers
added a commit
that referenced
this pull request
Jul 16, 2026
refreshConflictFlags filtered with `kind: { key: { not: 'off' } }`. Prisma
inner-joins through the `kind` relation, so events where `kindId` is NULL
were silently dropped from the refresh loop. Seeded templates leave
`kindId` null, so every event generated from them inherited that null —
meaning the hasConflict flag never flipped when a publisher added an
overlapping absence.
That in turn broke:
- the amber Conflit badge on the events list,
- the absence badge next to the assignee on the event view page,
- the release-blocking policy (assertCanRelease saw hasConflict=false and
let the manager publish anyway).
Switch to `NOT: { kind: { key: 'off' } }`, which matches the events-list
filter shape from #250 and includes null-kind events. Update the
unit-level query-shape pin and add an integration test that reproduces
the full flow with a null-kind draft event so this can't silently
regress.
mindsers
added a commit
that referenced
this pull request
Jul 16, 2026
* feat(events): draft/released workflow for programme events Adds an explicit status column on Event (draft | released) so a programme manager can build a schedule in private and publish it in one step. While an event is draft: - it stays off the public display board and the /board deep-link viewer, - assignment mutations do not fire notifications (release re-enqueues one assigned notification per current assignee, with the debounce shortened from 2h to 30 min as a safety net), - absence conflicts no longer block save — they set hasConflict=true and block release instead. Managers spot them via the amber Conflit badge from #250; assertCanRelease enumerates offenders in the release error. - publisher- and manager-facing conflict / upcoming queries filter it out. Draft conflicts are not urgent — they only become actionable at release time. Un-release is a first-class action; it flips status back to draft, hides the event again, and cancels pending NotificationEvent rows for the event's assignments (mails already sent stay sent). UX: Brouillon badge on the events-list rows and the event detail header, Publier / Repasser en brouillon single-event and bulk actions in the sticky selection bar, success/error toasts via session flash. Day-off events explicitly set status='released' at creation so the existing conflict pipeline continues to see them. * fix(events): refresh hasConflict for null-kind events refreshConflictFlags filtered with `kind: { key: { not: 'off' } }`. Prisma inner-joins through the `kind` relation, so events where `kindId` is NULL were silently dropped from the refresh loop. Seeded templates leave `kindId` null, so every event generated from them inherited that null — meaning the hasConflict flag never flipped when a publisher added an overlapping absence. That in turn broke: - the amber Conflit badge on the events list, - the absence badge next to the assignee on the event view page, - the release-blocking policy (assertCanRelease saw hasConflict=false and let the manager publish anyway). Switch to `NOT: { kind: { key: 'off' } }`, which matches the events-list filter shape from #250 and includes null-kind events. Update the unit-level query-shape pin and add an integration test that reproduces the full flow with a null-kind draft event so this can't silently regress. * refactor(events): shorten the release-blocked error toast The policy used to enumerate every offending part and person in the error message. With more than one conflict on the same event, the toast became unreadable — and the event view page already shows every conflicting assignment inline (badge next to the assignee), so the toast was duplicating information the manager can see in context. Drop the enumeration; the toast is now a fixed one-line "Impossible de publier : cet événement a des conflits d'absence à résoudre." Trim the event-status include shape accordingly (names are no longer needed to build the error). * refactor(events): flatten the bulk-release blocked toast Previously the bulk-release error nested the single-event error inside a countdown: "N évènements n'ont pas pu être publiés : Impossible de publier : cet événement a des conflits d'absence à résoudre." Two sentences chained by a colon read badly and repeat "publier" twice. Since bulk-release only fails on absence conflicts (the only path returning { error } from releaseEvent), the reason token added no signal. Flatten the string to a single self-contained line and drop the reason argument. * fix(events): address PR review findings on the draft/release workflow Aggregates eight review-follow-up fixes into one commit — they're all correctness / hardening on the same feature surface: C1. getNextMeeting on the dashboard leaked drafts to publishers AND used the `kind: { key: { not } }` inner-join shape that silently drops null-kind events. Same two bugs the earlier commit fixed elsewhere; the sweep missed this one. Filter to status='released' and switch the kind exclusion to `NOT: { kind }`. I1. refreshConflictFlags clobbered the hasConflict flag whenever the refreshed member wasn't the only participant on a row. Alice removes her absence → refresh writes Alice's (false) result over Bob's still valid conflict. Recompute per row now as (assigneeConflict OR assistantConflict). Adds an integration pin with two members on one shared part. I2 audit divergence. audit() writes on unscopedDb, so it escapes the release transaction — a rollback would leave phantom EventReleased / EventUnreleased rows. Switched to auditInTransaction so the audit row rolls back with the state flip. I2 tx timeout. Threaded TransactionOptions through withScopeFromContext. Bulk release / unrelease routes now use a 30s tx timeout so a large batch does not hit Prisma's 5s default. I2b. Added a shared Zod schema (bulkEventIdsSchema) that bounds the input list to positive integers, non-empty, max 500 items. Wired into bulk-release, bulk-unrelease, and pre-existing bulk-delete. I3/I4/S2. Added a createLogger('event-status') and info-level lines on successful release / unrelease with counts, warn-level on conflict-blocked release, plus a debug-level line in dispatchAssignmentDiffs when short-circuiting on a draft event — mirrors the "no linked account" pattern in the same file. I5 empty-in guard. unreleaseEvent now skips the notificationEvent updateMany entirely when the event has no assignments, so we do not rely on Prisma's empty-`in: []` semantics. Simplify #19. Extracted bulkReleaseEvents / bulkUnreleaseEvents into event-status.server.ts and filterToManageableEventIds into programme-auth.server.ts. The three bulk routes drop below the 15 cognitive-complexity budget and the ~10 duplicated auth lines collapse to one call site. * fix(events): address second-pass PR review findings Batches ten review-follow-up fixes on the draft/release workflow. C2 (whitelist notifications). dispatchAssignmentDiffs used a blacklist (status === 'draft' → skip). Any future status value would silently notify. Flipped to `status !== 'released'` so unhandled states fail closed, and log the actual status at debug. C1 (per-event withScope for bulk release/unrelease). A single 30s tx covering the whole batch could still be blown by a 500-id cap × N assignments each; failure surfaced as an opaque 500 with no partial-progress feedback. bulkReleaseEvents / bulkUnreleaseEvents now open a fresh withScope per event, so a failing event only rolls back itself. Route splits into two phases: (1) auth + filter in one scoped tx, (2) per-event scoped release. Tests mock withScope to make the boundary observable. I1 (per-congregation validation on the manager path). filterToManage- ableEventIds early-returned the input list untouched for ProgramManager, so a cross-tenant id would silently land in downstream `notFound`. Manager path now runs the ids through event.findMany scoped by congregationId. Signature also gains a `can:` predicate (matching canEditEvent / canManageAnyProgram) instead of a trailing boolean, kills the boolean-trap. I5 (release notify errors are non-fatal). One notifyAssignment throw used to roll back the state flip AND the audit row AND, under bulk, the batch. Wrapped in per-slot try/catch that logs the failing memberId; the release itself completes. Matches the fire-and-forget pattern used by assign-part / assign-service. I3 (client-side toast on invalid_payload). The Zod-rejection response lands no server-side flash cookie, so the layout toast never fired. list.tsx now surfaces a client-side sonner toast on `{ok:false, error:'invalid_payload'}` for both fetchers. I5-integration (test isolation). draft-conflict-flow.integration.test had a shared beforeAll fixture that the first describe mutated, leaving the second describe reading order-dependent state. Restructured to provision a fresh scoped congregation per `it`. CR-I-N2 (bulk-delete audit trail). bulkDeleteEvents wrote via db.event.deleteMany with no audit row — the same governance gap release/unrelease already closed. Added AuditAction.EventDeleted and one auditInTransaction row per requested id. Also returns Prisma's `count` so the route logs the truthful number. G5 / G6 (regression pins on releaseEvent / unreleaseEvent). Two cheap unit-test additions: (G5) pins that releaseEvent threads status='released' into the notification ctx so dispatchAssignmentDiffs does not self-suppress the burst; (G6) pins that unreleaseEvent's notificationEvent.updateMany filters by congregationId. G8 (integration pin for audit rollback atomicity). New event-status.integration.test.ts wraps releaseEvent / unreleaseEvent in a testDb.$transaction that then throws; asserts the AuditLog is empty for that event. Polish. Added EventStatus type/const in features/events/model so the 'draft' / 'released' literals stop drifting. Empty-input guard on filterToManageableEventIds (no wasted findMany). * fix(events): address third-pass PR review findings Batches nine follow-up fixes. C1 (rebase). origin/main advanced with the post-login-redirect feature. Merged main into the branch so the PR diff no longer silently reverts that shipped work. C2 (release notifications OUT of the tx). safeNotify's "log and continue" only worked for non-Prisma errors — a Prisma error inside a Postgres tx marks it aborted, the catch swallows, releaseEvent returns ok, but Postgres converts the eventual COMMIT to ROLLBACK. Ghost releases. Split releaseEvent into a tx-only half (state flip + audit) that returns notifyTargets, and a new fireReleaseNotifications helper that opens a fresh per-notify withScope OUTSIDE the release tx. A single notify failure now only affects that notify. Bulk paths call the two in sequence. I1 (bulk failure bucket). bulkReleaseEvents / bulkUnreleaseEvents wrap the per-event withScope in try/catch and land pool-exhaustion / connection-reset failures in a new `failed` bucket. The batch loop keeps going instead of 500'ing the whole action. I2 (EventStatus sweep). Swept all bare 'draft' / 'released' literals across events, dashboard, display-board, notify-assignment, and the list/view routes. The type is now consumed at every call site so a typo would fail the build. I3 (notFound + failed surfaced in bulk routes). The routes now flash programs_bulk_not_found and programs_release_failed_bulk / programs_unrelease_failed_bulk when those buckets are non-empty, so a whole-batch drop no longer renders as invisible success. I4 (audit-log viewer registrations). event.released / event.unreleased / event.deleted actions and the Event entity type are now translated and appear in the audit-log filter's SelectGroup. S1 (filter drop-reason logging). filterToManageableEventIds emits a warn on the manager path when cross-tenant ids are dropped and an info on the non-manager path with the counts split into crossTenant / freeform / unauthorized so support can distinguish the three drop reasons. S3 (assignmentName / entityId threading pin). New unit test in fireReleaseNotifications asserts each per-target ctx receives the right entityType / entityId / assignmentName. Guards against a regression that would send every release notification with entityId:0, colliding on the debounce key. Extract. Moved bulkReleaseEvents / bulkUnreleaseEvents into a new event-status-bulk.server.ts so the file-size hard budget for event-status.server.ts (350 lines) still fits after the C2 split. * fix(events): aggregate bulk-action toasts and drop dead unrelease branch session.flash stores one value per key, so the three back-to-back flash('error', ...) calls in bulk-release/unrelease silently overwrote each other — only the last error bucket surfaced to the manager. Build a single joined error string per action and flash it once. Also narrow UnreleaseResult to { event } (the { error } arm was unreachable — unreleaseEvent has no policy path that returns one) and drop the corresponding dead branch in bulkUnreleaseEvents. * docs(events): cover the draft/released workflow Add the draft/released workflow to the product docs (events, notifications, dashboard, display board, calendar feed, roles & permissions, settings, feature overview) so end users understand where drafts show up (or don't) and how release / un-release behaves. Document the assignment-dispatch whitelist gate, 30-minute safety-net debounce, and event release authorization (canEditEvent) for contributors in the development docs. * fix(events): block day-off overlaps on released events again The draft workflow relaxed assignPart / assignServiceRole so a conflicting day-off would save with hasConflict=true instead of aborting — but the relaxation was unconditional, so managers could silently schedule a publisher on top of a known absence on an already-released event. That is exactly the surprise the pre-PR block existed to prevent. Restore the block only when event.status is 'released'. Drafts continue to save with hasConflict=true so the release-blocking policy can surface the conflict at publish time. Extract checkPartParticipant to keep assignPart under the complexity budget. * fix(dashboard): show my own day-off conflict above the responsible one A program manager can hold a part assignment themselves, so both dashboard conflict cards can appear at once. The responsible-conflict card (someone else's absence on a programme I manage) used to sit above the user's personal day-off clash — the wrong order for a manager who is also on a part they cannot attend. Promote the personal day-off clash to priority 1 with destructive/red styling (matching an overdue territory), and demote the responsible- conflict card to priority 2 (amber stays). * feat(dashboard): raise the urgent-strip cap from 3 to 5 Three slots was too tight now that the strip carries two conflict cards (the user's personal day-off clash plus the responsible-conflict card for program managers) on top of the pre-existing territory and unread- document rows. A busy manager routinely blew past three and lost the lower-priority items to the slice cutoff. * feat(dashboard): reword urgent-strip messages for clarity and warmth - My day-off conflict now spells out that I'm both assigned AND absent, so the resolution is obvious. - Responsible-conflict card says "conflit d'absence à résoudre" instead of the vague "conflit d'affectation". - Territory due-soon and unread-documents rows say where or what to do. - Imminent-part / imminent-service rows drop the terse "name — event" format for a personal, action-oriented sentence. French copy uses tutoiement to match the "Bonjour Marc" tone in the dashboard hero greeting. * feat(dashboard): show the assignment name on my day-off conflict row The event name is generic — every weekly meeting is called "Réunion de semaine" — so putting it in the message tells the reader nothing about which specific slot is clashing. The assignment name ("Discours public", "Son", "Lecture", …) is unique per event and points at the exact task the user needs to reassign or reschedule.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
?hasConflicts=truefilter the dashboard card deep-links to.Permission.ProgramManagerholders — shows up to three absentee names ("(+N autres)" beyond that), one card whatever the number of templates involved.checkDayOffConflictandrefreshConflictFlags— they mixedUserAccount.id(fromEvent.createdById) withMember.id(fromassigneeId/assistantId), so thehasConflictinvariant was only maintained by coincidence. Both now join throughEvent.createdBy.memberIdand filter assignments byMember.id. Route callers passcurrentUser.member?.idand skip the refresh for accounts with no linked member.refreshConflictFlagsto include untemplated events (previously excluded), while excluding Off events themselves.Test plan
pnpm test:unit— 1917 tests, all green.pnpm test:integration app/features/dashboard/server/dashboard.integration.test.ts— 20 tests, includes two new pins:getResponsibleConflictsdrops the entry whenhasConflictclears;ProgramManagersees conflicts on untemplated events.pnpm test:lint,pnpm test:typecheck,pnpm test:service-test-coverage,pnpm test:aggregate-boundaries,pnpm test:boundaries,pnpm test:file-sizes— all green./me/days-off./programs?hasConflicts=truefiltered.ProgramManager→ same card also surfaces untemplated events.