feat(events): draft/released workflow for programme events#253
Merged
Conversation
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.
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.
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).
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.
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.
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).
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.
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.
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.
mindsers
force-pushed
the
feat/event-draft-release
branch
from
July 16, 2026 09:03
577b100 to
e8af552
Compare
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.
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).
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.
- 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.
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
Adds an explicit
statuscolumn onEvent(draft|released) so a programme manager can build a schedule in private and publish it in one deliberate step. Solves the recurring problem of publishers being notified about a still-in-progress schedule.While an event is
draft:/boarddeep-link viewer,assignednotification per current assignee, with the debounce shortened from 2h to 30 min as a safety net,hasConflict=trueand block release instead. Managers spot them via the amber Conflit badge from feat(events): surface absence/assignment conflicts across the app #250;assertCanReleaseenumerates the offending publishers in the error,Un-release is a first-class action: flips status back to
draft, hides the event again, and cancels pendingNotificationEventrows for the event's assignments (mails already sent stay sent — the 30-min safety net is what mitigates that).UX
Day-off events explicitly set
status: 'released'at creation so the existing conflict pipeline continues to see them; they're not part of the release workflow.Test plan
pnpm test:unit— 1952 tests, all green.pnpm test:integration— 259 tests, all green (test fixtures backfilled withstatus: 'released').pnpm test:lint,pnpm test:typecheck,pnpm test:service-test-coverage,pnpm test:aggregate-boundaries,pnpm test:boundaries,pnpm test:file-sizes— all green./board./board,NotificationEventrow inserted withdebounceUntil ≈ now + 30 min.NotificationEventrow iscancelled, event vanishes from/board.event.releasedentry per event.