Skip to content

feat(events): draft/released workflow for programme events#253

Merged
mindsers merged 14 commits into
mainfrom
feat/event-draft-release
Jul 16, 2026
Merged

feat(events): draft/released workflow for programme events#253
mindsers merged 14 commits into
mainfrom
feat/event-draft-release

Conversation

@mindsers

Copy link
Copy Markdown
Contributor

Summary

Adds an explicit status column on Event (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:

  • it stays off the public display board and its /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 feat(events): surface absence/assignment conflicts across the app #250; assertCanRelease enumerates the offending publishers in the error,
  • publisher-facing and manager-facing conflict / upcoming queries filter it out. Draft conflicts are non-urgent — they only become actionable at release time.

Un-release is a first-class action: flips status back to draft, hides the event again, and cancels pending NotificationEvent rows for the event's assignments (mails already sent stay sent — the 30-min safety net is what mitigates that).

UX

  • Brouillon badge on the events-list rows and the event detail header.
  • Publier / Repasser en brouillon actions on the event detail page.
  • Bulk Publier / Repasser en brouillon in the sticky selection bar on the events list.
  • Success / error toasts via session flash.

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 with status: '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.
  • Manual: create a recurring programme → every generated event shows the Brouillon badge and is absent from /board.
  • Manual: assign a publisher who has an overlapping absence → save succeeds, row shows the amber Conflit badge.
  • Manual: try to release → release blocked with an error naming the conflicting assignment.
  • Manual: resolve the conflict, release → badge flips, event appears on /board, NotificationEvent row inserted with debounceUntil ≈ now + 30 min.
  • Manual: un-release within 30 min → the pending NotificationEvent row is cancelled, event vanishes from /board.
  • Manual: bulk-select drafts → Publier releases them all with a success toast; audit log has one event.released entry per event.
  • Manual: as a publisher — draft events do not appear on the personal calendar, dashboard upcoming, dashboard conflicts, or the ICS feed. The DayOffConflictModal does not warn about draft-event conflicts.

mindsers added 9 commits July 16, 2026 10:59
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
mindsers force-pushed the feat/event-draft-release branch from 577b100 to e8af552 Compare July 16, 2026 09:03
mindsers added 5 commits July 16, 2026 11:12
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.
@mindsers
mindsers merged commit 14b2619 into main Jul 16, 2026
7 checks passed
@mindsers
mindsers deleted the feat/event-draft-release branch July 16, 2026 09:49
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