Skip to content

feat(events): surface absence/assignment conflicts across the app#250

Merged
mindsers merged 4 commits into
mainfrom
feat/absence-conflict-awareness
Jul 16, 2026
Merged

feat(events): surface absence/assignment conflicts across the app#250
mindsers merged 4 commits into
mainfrom
feat/absence-conflict-awareness

Conversation

@mindsers

Copy link
Copy Markdown
Contributor

Summary

  • Blocking modal on the "mes absences" creation flow now lists each overlapping assignment with the responsible to contact by name (generic fallback when the template has no responsible or the event is untemplated).
  • Amber "⚠ Conflit" / "⚠ N conflits" badge on every upcoming event card in the programme list, plus a ?hasConflicts=true filter the dashboard card deep-links to.
  • New aggregated urgent-item on the dashboard for template responsibles and Permission.ProgramManager holders — shows up to three absentee names ("(+N autres)" beyond that), one card whatever the number of templates involved.
  • 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. Both now join through Event.createdBy.memberId and filter assignments by Member.id. Route callers pass currentUser.member?.id and skip the refresh for accounts with no linked member.
  • Broadens refreshConflictFlags to 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: getResponsibleConflicts drops the entry when hasConflict clears; ProgramManager sees 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.
  • Manual: create an absence overlapping a scheduled event → modal appears, lists the assignment(s) with the correct responsible name, single "D'accord, je vais le prévenir" button; dismiss → land on /me/days-off.
  • Manual: log in as the template's responsible → dashboard shows the aggregated card ("N conflits d'affectation : …"); click → /programs?hasConflicts=true filtered.
  • Manual: log in as ProgramManager → same card also surfaces untemplated events.
  • Manual: delete the absence → the badge on the event card and the dashboard card disappear (invariant self-heal).

mindsers added 3 commits July 16, 2026 01:30
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
mindsers force-pushed the feat/absence-conflict-awareness branch from 6a00c8f to e44ad6c Compare July 15, 2026 23:30
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.
@mindsers
mindsers merged commit fa2c6fc into main Jul 16, 2026
7 checks passed
@mindsers
mindsers deleted the feat/absence-conflict-awareness branch July 16, 2026 00:11
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant