From fc063f3d640528934c27104f93f37e15e7cb65e1 Mon Sep 17 00:00:00 2001 From: mindsers Date: Thu, 16 Jul 2026 07:09:40 +0200 Subject: [PATCH 01/14] feat(events): draft/released workflow for programme events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../migration.sql | 9 + app/database/schema.prisma | 1 + .../server/dashboard.integration.test.ts | 11 + .../dashboard/server/dashboard.server.test.ts | 48 +++- .../dashboard/server/dashboard.server.ts | 13 +- .../get-responsible-conflicts.server.test.ts | 10 +- .../get-responsible-conflicts.server.ts | 6 +- .../dynamic-documents.integration.test.ts | 1 + .../server/dynamic-documents.server.test.ts | 70 ++++- .../server/dynamic-documents.server.ts | 15 +- app/features/events/programs.routes.ts | 4 + .../events/routes/programs/bulk-release.tsx | 78 +++++ .../events/routes/programs/bulk-unrelease.tsx | 55 ++++ .../events/routes/programs/events/release.tsx | 54 ++++ .../routes/programs/events/unrelease.tsx | 39 +++ .../events/routes/programs/events/view.tsx | 44 ++- app/features/events/routes/programs/list.tsx | 52 ++++ .../events/server/days-off.server.test.ts | 14 + app/features/events/server/days-off.server.ts | 3 + .../server/event-filters.server.test.ts | 27 ++ .../events/server/event-filters.server.ts | 10 + .../events/server/event-status.policy.test.ts | 96 +++++++ .../events/server/event-status.policy.ts | 55 ++++ .../events/server/event-status.server.test.ts | 266 ++++++++++++++++++ .../events/server/event-status.server.ts | 183 ++++++++++++ ...ist-user-conflicts-in-range.server.test.ts | 12 +- .../list-user-conflicts-in-range.server.ts | 7 +- .../events/server/notifications.server.tsx | 7 +- .../server/notify-assignment.server.test.ts | 21 +- .../events/server/notify-assignment.server.ts | 12 +- .../personal-assignments.integration.test.ts | 3 + .../personal-assignments.server.test.ts | 6 +- .../server/personal-assignments.server.ts | 5 +- .../programme-assignment.policy.test.ts | 19 -- .../server/programme-assignment.policy.ts | 13 - .../programme-assignments.server.test.ts | 51 +++- .../server/programme-assignments.server.ts | 47 ++-- app/i18n/messages/en.json | 10 + app/i18n/messages/fr.json | 10 + app/shared/domain/audit.server.ts | 4 + 40 files changed, 1287 insertions(+), 104 deletions(-) create mode 100644 app/database/migrations/20260716000000_add_event_status/migration.sql create mode 100644 app/features/events/routes/programs/bulk-release.tsx create mode 100644 app/features/events/routes/programs/bulk-unrelease.tsx create mode 100644 app/features/events/routes/programs/events/release.tsx create mode 100644 app/features/events/routes/programs/events/unrelease.tsx create mode 100644 app/features/events/server/event-status.policy.test.ts create mode 100644 app/features/events/server/event-status.policy.ts create mode 100644 app/features/events/server/event-status.server.test.ts create mode 100644 app/features/events/server/event-status.server.ts diff --git a/app/database/migrations/20260716000000_add_event_status/migration.sql b/app/database/migrations/20260716000000_add_event_status/migration.sql new file mode 100644 index 00000000..86f026ac --- /dev/null +++ b/app/database/migrations/20260716000000_add_event_status/migration.sql @@ -0,0 +1,9 @@ +-- Add a draft/released workflow on Event so programme managers can build a +-- schedule in private and publish it in one explicit step. The board, the +-- notification pipeline, and the dashboard conflict queries all ignore drafts. + +ALTER TABLE "Event" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'draft'; + +-- Every existing event is already live in production, so backfill them to the +-- released state. New events start as 'draft' via the column default. +UPDATE "Event" SET "status" = 'released'; diff --git a/app/database/schema.prisma b/app/database/schema.prisma index e1b8f52e..40954b4f 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -650,6 +650,7 @@ model Event { kindId Int? startDate DateTime endDate DateTime + status String @default("draft") template ProgrammeTemplate? @relation("templateEvents", fields: [templateId], references: [id]) templateId Int? diff --git a/app/features/dashboard/server/dashboard.integration.test.ts b/app/features/dashboard/server/dashboard.integration.test.ts index e5d8f472..962e37eb 100644 --- a/app/features/dashboard/server/dashboard.integration.test.ts +++ b/app/features/dashboard/server/dashboard.integration.test.ts @@ -147,6 +147,7 @@ beforeAll(async () => { endDate: new Date('2027-06-01T21:00:00Z'), createdById: aliceAccountId, congregationId, + status: 'released', }, }) @@ -191,6 +192,7 @@ beforeAll(async () => { endDate: new Date('2025-01-01T21:00:00Z'), createdById: aliceAccountId, congregationId, + status: 'released', }, }) pastEventId = past.id @@ -303,6 +305,7 @@ describe('getNextMeeting (integration)', () => { endDate: new Date('2027-05-03T00:00:00Z'), createdById: bobAccountId, congregationId, + status: 'released', }, }) return off.id @@ -345,6 +348,10 @@ describe('getConflictingAssignments (integration)', () => { endDate: opts.endDate ?? new Date(opts.startDate.getTime() + 2 * 60 * 60 * 1000), createdById: opts.createdById ?? aliceAccountId, congregationId: cong, + // Every publisher-facing conflict / dashboard query filters on + // status='released' now that the draft/released workflow is live. + // Test fixtures are always public. + status: 'released', }, }) const partIds = await Promise.all( @@ -520,6 +527,7 @@ describe('getConflictingAssignments (integration)', () => { endDate: new Date('2027-11-01T21:00:00Z'), createdById: otherAccount.id, congregationId: otherCong.id, + status: 'released', }, }) // Same numeric id range as Alice — guarantees that without RLS the other-cong row would match @@ -597,6 +605,7 @@ describe('getConflictingAssignments (integration)', () => { endDate: new Date('2027-12-01T21:00:00Z'), createdById: aliceAccountId, congregationId, + status: 'released', }, }) // Pre-set the flag to true to simulate a stale conflict left over from a now-deleted day-off. @@ -705,6 +714,7 @@ describe('getConflictingAssignments (integration)', () => { endDate: new Date('2028-01-05T21:00:00Z'), createdById: aliceAccountId, congregationId, + status: 'released', }, }) const part = await tx.programmePartAssignment.create({ @@ -770,6 +780,7 @@ describe('getConflictingAssignments (integration)', () => { endDate: new Date('2028-02-10T21:00:00Z'), createdById: aliceAccountId, congregationId, + status: 'released', }, }) const part = await tx.programmePartAssignment.create({ diff --git a/app/features/dashboard/server/dashboard.server.test.ts b/app/features/dashboard/server/dashboard.server.test.ts index ab4ff7d3..c28d7337 100644 --- a/app/features/dashboard/server/dashboard.server.test.ts +++ b/app/features/dashboard/server/dashboard.server.test.ts @@ -6,6 +6,8 @@ vi.mock('~/shared/infra/db.server', () => ({ boardDocument: { findMany: vi.fn(), count: vi.fn() }, boardDynamicDocumentSettings: { findMany: vi.fn(), count: vi.fn() }, event: { findFirst: vi.fn(), findMany: vi.fn() }, + programmePartAssignment: { findMany: vi.fn() }, + programmeServiceRoleAssignment: { findMany: vi.fn() }, role: { findMany: vi.fn() }, }, })) @@ -14,8 +16,15 @@ vi.mock('~/features/events/server/days-off.server', () => ({ getNextDaysOffs: vi.fn(), })) -const { getUserTerritories, getRecentDocuments, getUnreadDocumentCount, getNextMeeting, getUpcomingAbsences } = - await import('./dashboard.server') +const { + getUserTerritories, + getRecentDocuments, + getUnreadDocumentCount, + getNextMeeting, + getUpcomingAbsences, + getUpcomingAssignments, + getConflictingAssignments, +} = await import('./dashboard.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') const { getNextDaysOffs } = await import('~/features/events/server/days-off.server') @@ -262,3 +271,38 @@ describe('getUpcomingAbsences', () => { expect(result.shouldNudge).toBe(false) }) }) + +// --- getUpcomingAssignments: draft events hidden --- +// +// The publisher dashboard is a public view of the schedule; draft assignments +// must not preview here or a publisher sees a mid-edit programme. + +describe('getUpcomingAssignments', () => { + it('filters part and service-role assignments to released events', async () => { + vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([] as never) + + await getUpcomingAssignments(db, 42) + + const partCall = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0] + expect((partCall?.where as { event: unknown }).event).toMatchObject({ status: 'released' }) + + const serviceCall = vi.mocked(db.programmeServiceRoleAssignment.findMany).mock.calls[0][0] + expect((serviceCall?.where as { event: unknown }).event).toMatchObject({ status: 'released' }) + }) +}) + +describe('getConflictingAssignments', () => { + it('only surfaces conflicts on released events', async () => { + vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([] as never) + + await getConflictingAssignments(db, 42) + + const partCall = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0] + expect((partCall?.where as { event: unknown }).event).toMatchObject({ status: 'released' }) + + const serviceCall = vi.mocked(db.programmeServiceRoleAssignment.findMany).mock.calls[0][0] + expect((serviceCall?.where as { event: unknown }).event).toMatchObject({ status: 'released' }) + }) +}) diff --git a/app/features/dashboard/server/dashboard.server.ts b/app/features/dashboard/server/dashboard.server.ts index 3dac7b90..afa5c2af 100644 --- a/app/features/dashboard/server/dashboard.server.ts +++ b/app/features/dashboard/server/dashboard.server.ts @@ -172,7 +172,9 @@ export async function getUpcomingAssignments(db: TransactionClient, userId: numb db.programmePartAssignment.findMany({ where: { OR: [{ assigneeId: userId }, { assistantId: userId }], - event: { startDate: { gte: now } }, + // Drafts are the manager's scratch space — never previewed to + // publishers. + event: { startDate: { gte: now }, status: 'released' }, }, select: { id: true, @@ -193,7 +195,7 @@ export async function getUpcomingAssignments(db: TransactionClient, userId: numb db.programmeServiceRoleAssignment.findMany({ where: { assigneeId: userId, - event: { startDate: { gte: now } }, + event: { startDate: { gte: now }, status: 'released' }, }, select: { id: true, @@ -256,7 +258,10 @@ export async function getConflictingAssignments(db: TransactionClient, userId: n where: { hasConflict: true, OR: [{ assigneeId: userId }, { assistantId: userId }], - event: { startDate: { gte: now } }, + // Conflicts on a draft event are not urgent — the schedule isn't + // public yet. They only surface via the events-list amber badge for + // managers, and block the release step. + event: { startDate: { gte: now }, status: 'released' }, }, select: { id: true, @@ -269,7 +274,7 @@ export async function getConflictingAssignments(db: TransactionClient, userId: n where: { hasConflict: true, assigneeId: userId, - event: { startDate: { gte: now } }, + event: { startDate: { gte: now }, status: 'released' }, }, select: { id: true, diff --git a/app/features/dashboard/server/get-responsible-conflicts.server.test.ts b/app/features/dashboard/server/get-responsible-conflicts.server.test.ts index e0bda54c..106c07bd 100644 --- a/app/features/dashboard/server/get-responsible-conflicts.server.test.ts +++ b/app/features/dashboard/server/get-responsible-conflicts.server.test.ts @@ -34,6 +34,7 @@ describe('getResponsibleConflicts', () => { expect(partWhere.hasConflict).toBe(true) expect(partWhere.event).toEqual({ startDate: { gte: expect.any(Date) }, + status: 'released', template: { responsibles: { some: { userId } } }, }) }) @@ -46,18 +47,21 @@ describe('getResponsibleConflicts', () => { const serviceWhere = serviceCall?.where as Record expect(serviceWhere.event).toEqual({ startDate: { gte: expect.any(Date) }, + status: 'released', template: { responsibles: { some: { userId } } }, }) }) // ProgramManager sees everything — including untemplated events (which - // have no responsible relation at all). - it('drops the template filter for ProgramManager users', async () => { + // have no responsible relation at all) — but still only released ones. + // Draft-event conflicts are not urgent enough for the dashboard; managers + // see them on the events list amber badge and get blocked at release. + it('drops the template filter for ProgramManager users but keeps the released filter', async () => { await getResponsibleConflicts(db, 100, true) const partCall = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0] const partWhere = partCall?.where as Record - expect(partWhere.event).toEqual({ startDate: { gte: expect.any(Date) } }) + expect(partWhere.event).toEqual({ startDate: { gte: expect.any(Date) }, status: 'released' }) expect(partWhere.event).not.toHaveProperty('template') }) diff --git a/app/features/dashboard/server/get-responsible-conflicts.server.ts b/app/features/dashboard/server/get-responsible-conflicts.server.ts index 8fb0f8ba..425bf95a 100644 --- a/app/features/dashboard/server/get-responsible-conflicts.server.ts +++ b/app/features/dashboard/server/get-responsible-conflicts.server.ts @@ -27,9 +27,11 @@ export async function getResponsibleConflicts( ): Promise { const now = new Date() + // Drafts stay off the dashboard even for managers — the events-list amber + // badge and the release-blocking error are their surface for those. const eventFilter = isProgramManager - ? { startDate: { gte: now } } - : { startDate: { gte: now }, template: { responsibles: { some: { userId } } } } + ? { startDate: { gte: now }, status: 'released' } + : { startDate: { gte: now }, status: 'released', template: { responsibles: { some: { userId } } } } const [partRows, serviceRows] = await Promise.all([ db.programmePartAssignment.findMany({ diff --git a/app/features/display-board/server/dynamic-documents.integration.test.ts b/app/features/display-board/server/dynamic-documents.integration.test.ts index 66e9f057..3c24743d 100644 --- a/app/features/display-board/server/dynamic-documents.integration.test.ts +++ b/app/features/display-board/server/dynamic-documents.integration.test.ts @@ -97,6 +97,7 @@ beforeAll(async () => { endDate: new Date('2027-06-01T21:00:00Z'), createdById: aliceAccountIdInner, congregationId, + status: 'released', }, }) diff --git a/app/features/display-board/server/dynamic-documents.server.test.ts b/app/features/display-board/server/dynamic-documents.server.test.ts index 0bc8ad43..621795ec 100644 --- a/app/features/display-board/server/dynamic-documents.server.test.ts +++ b/app/features/display-board/server/dynamic-documents.server.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getContentVersion, getDynamicPreview, markDynamicDocumentViewed } from './dynamic-documents.server' +import { + getContentVersion, + getDynamicDocumentData, + getDynamicPreview, + markDynamicDocumentViewed, +} from './dynamic-documents.server' const mockDb = { publisherGroup: { @@ -13,6 +18,7 @@ const mockDb = { }, event: { findFirst: vi.fn(), + findMany: vi.fn(), }, programmePartAssignment: { findFirst: vi.fn(), @@ -81,6 +87,7 @@ describe('getDynamicPreview', () => { congregationId: 10, template: { key: 'midweek' }, startDate: { gte: expect.any(Date) }, + status: 'released', }, orderBy: { startDate: 'asc' }, select: { startDate: true }, @@ -167,6 +174,67 @@ describe('getContentVersion', () => { }) }) +// --- getDynamicDocumentData: draft events hidden --- +// +// The board is the public face of the schedule. Any query that surfaces +// programme events to it MUST filter out drafts, otherwise a manager mid-edit +// leaks half-baked assignments to every publisher walking past the screen. + +describe('getDynamicDocumentData programme draft filter', () => { + it('filters legacy single-template events to status=released', async () => { + mockDb.event.findMany.mockResolvedValue([]) + + await getDynamicDocumentData(mockDb as never, 'programme', 'midweek', 10, { showServices: false }) + + expect(mockDb.event.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: 'released' }), + }), + ) + }) + + it('filters multi-template events to status=released', async () => { + mockDb.event.findMany.mockResolvedValue([]) + const dynamicConfig = { templates: [{ templateId: 1, parts: true, services: false }] } + + await getDynamicDocumentData(mockDb as never, 'programme', null, 10, { dynamicConfig }) + + expect(mockDb.event.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: 'released' }), + }), + ) + }) +}) + +describe('getDynamicPreview programme draft filter', () => { + it('picks the next released event only', async () => { + mockDb.event.findFirst.mockResolvedValue(null) + + await getDynamicPreview(mockDb as never, 'programme', 'midweek', 10) + + expect(mockDb.event.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ status: 'released' }), + }), + ) + }) +}) + +describe('getContentVersion programme draft filter', () => { + it('reads latest event / assignment updatedAt from released events only', async () => { + mockDb.event.findFirst.mockResolvedValue(null) + mockDb.programmePartAssignment.findFirst.mockResolvedValue(null) + + await getContentVersion(mockDb as never, 'programme', 'midweek', 10) + + const eventCall = mockDb.event.findFirst.mock.calls[0][0] + expect(eventCall.where).toMatchObject({ status: 'released' }) + const assignmentCall = mockDb.programmePartAssignment.findFirst.mock.calls[0][0] + expect(assignmentCall.where.event).toMatchObject({ status: 'released' }) + }) +}) + // --- markDynamicDocumentViewed --- describe('markDynamicDocumentViewed', () => { diff --git a/app/features/display-board/server/dynamic-documents.server.ts b/app/features/display-board/server/dynamic-documents.server.ts index 64dcf96a..0f607760 100644 --- a/app/features/display-board/server/dynamic-documents.server.ts +++ b/app/features/display-board/server/dynamic-documents.server.ts @@ -120,12 +120,15 @@ export async function getContentVersion( const templateIds = config.templates.map(t => t.templateId) const [event, assignment] = await Promise.all([ db.event.findFirst({ - where: { congregationId, templateId: { in: templateIds }, startDate: { gte: fromDate } }, + where: { congregationId, templateId: { in: templateIds }, startDate: { gte: fromDate }, status: 'released' }, orderBy: { updatedAt: 'desc' }, select: { updatedAt: true }, }), db.programmePartAssignment.findFirst({ - where: { congregationId, event: { templateId: { in: templateIds }, startDate: { gte: fromDate } } }, + where: { + congregationId, + event: { templateId: { in: templateIds }, startDate: { gte: fromDate }, status: 'released' }, + }, orderBy: { updatedAt: 'desc' }, select: { updatedAt: true }, }), @@ -137,14 +140,14 @@ export async function getContentVersion( if (dynamicRef) { const [event, assignment] = await Promise.all([ db.event.findFirst({ - where: { congregationId, template: { key: dynamicRef }, startDate: { gte: fromDate } }, + where: { congregationId, template: { key: dynamicRef }, startDate: { gte: fromDate }, status: 'released' }, orderBy: { updatedAt: 'desc' }, select: { updatedAt: true }, }), db.programmePartAssignment.findFirst({ where: { congregationId, - event: { template: { key: dynamicRef }, startDate: { gte: fromDate } }, + event: { template: { key: dynamicRef }, startDate: { gte: fromDate }, status: 'released' }, }, orderBy: { updatedAt: 'desc' }, select: { updatedAt: true }, @@ -199,7 +202,7 @@ export async function getDynamicPreview( if (!templateFilter) return null const nextEvent = await db.event.findFirst({ - where: { congregationId, ...templateFilter, startDate: { gte: new Date() } }, + where: { congregationId, ...templateFilter, startDate: { gte: new Date() }, status: 'released' }, orderBy: { startDate: 'asc' }, select: { startDate: true }, }) @@ -326,6 +329,7 @@ function fetchProgrammeByIds( congregationId, templateId: { in: templateIds }, startDate: { gte: fromDate }, + status: 'released', }, include: { template: true, @@ -356,6 +360,7 @@ function fetchProgrammeByKey( congregationId, template: { key: templateKey }, startDate: { gte: fromDate }, + status: 'released', }, include: { partAssignments: { diff --git a/app/features/events/programs.routes.ts b/app/features/events/programs.routes.ts index 51b200d6..fae79bfc 100644 --- a/app/features/events/programs.routes.ts +++ b/app/features/events/programs.routes.ts @@ -5,12 +5,16 @@ export const programsRoutes = [ index('features/events/routes/programs/list.tsx'), route('new', 'features/events/routes/programs/new.tsx'), route('bulk-delete', 'features/events/routes/programs/bulk-delete.tsx'), + route('bulk-release', 'features/events/routes/programs/bulk-release.tsx'), + route('bulk-unrelease', 'features/events/routes/programs/bulk-unrelease.tsx'), route('days-off', 'features/events/routes/programs/days-off.tsx'), route('export-pdf', 'features/events/routes/programs/export-pdf.tsx'), route('export-pdf/download', 'features/events/routes/programs/export-pdf-download.tsx'), ...prefix('events/:eventId', [ index('features/events/routes/programs/events/view.tsx'), route('edit', 'features/events/routes/programs/events/edit.tsx'), + route('release', 'features/events/routes/programs/events/release.tsx'), + route('unrelease', 'features/events/routes/programs/events/unrelease.tsx'), route('assign-part', 'features/events/routes/programs/events/assign-part.tsx'), route('assign-service', 'features/events/routes/programs/events/assign-service.tsx'), route('remove-assignment', 'features/events/routes/programs/events/remove-assignment.tsx'), diff --git a/app/features/events/routes/programs/bulk-release.tsx b/app/features/events/routes/programs/bulk-release.tsx new file mode 100644 index 00000000..3c0b2d3b --- /dev/null +++ b/app/features/events/routes/programs/bulk-release.tsx @@ -0,0 +1,78 @@ +import { data, redirect } from 'react-router' +import { commitSession, getSession } from '~/features/authentication/index.server' +import { releaseEvent } from '~/features/events/server/event-status.server' +import { canManageAnyProgram, getResponsibleTemplateIds } from '~/features/events/server/programme-auth.server' +import * as m from '~/i18n/paraglide/messages' +import { + congregationContext, + currentAccountContext, + permissionsContext, + withScopeFromContext, +} from '~/shared/auth/route-context.server' +import logger from '~/shared/infra/logger.server' +import { Permission } from '~/shared/types/permission' + +import type { Route } from './+types/bulk-release' + +export function loader(_args: Route.LoaderArgs) { + throw redirect('/programs') +} + +export async function action({ request, context }: Route.ActionArgs) { + const session = await getSession(request.headers.get('Cookie')) + const permissions = context.get(permissionsContext) + const currentUser = context.get(currentAccountContext) + const cong = context.get(congregationContext) + const isProgramManager = permissions.has(Permission.ProgramManager) + + const { ids } = (await request.json()) as { ids: number[] } + if (!Array.isArray(ids) || ids.length === 0) return { ok: false } + + return withScopeFromContext(context, async db => { + const { congregationId } = currentUser + const can = (p: Permission) => permissions.has(p) + if (!(await canManageAnyProgram(db, can, currentUser.id, congregationId))) throw redirect('/programs') + + let allowedIds = ids + if (!isProgramManager) { + const responsibleTemplateIds = await getResponsibleTemplateIds(db, currentUser.id, congregationId) + const responsibleSet = new Set(responsibleTemplateIds) + const events = await db.event.findMany({ + where: { id: { in: ids }, congregationId }, + select: { id: true, templateId: true }, + }) + allowedIds = events.filter(e => e.templateId != null && responsibleSet.has(e.templateId)).map(e => e.id) + } + + if (allowedIds.length === 0) return { ok: true, released: 0, blocked: [] as { id: number; error: string }[] } + + // Iterate per event so a single conflict blocks only its own release + // rather than aborting the whole batch. releaseEvent returns `{ error }` + // for conflicts (no throw), which keeps this transaction alive. + const released: number[] = [] + const blocked: { id: number; error: string }[] = [] + for (const id of allowedIds) { + const result = await releaseEvent(db, id, congregationId, currentUser.id, { + locale: cong.locale, + timezone: cong.timezone, + }) + if (result == null) continue + if ('error' in result) blocked.push({ id, error: result.error }) + else released.push(id) + } + logger.info(`Bulk released ${released.length} events; ${blocked.length} blocked.`) + + if (released.length > 0) { + session.flash('success', m.programs_release_success_bulk({ count: released.length })) + } + if (blocked.length > 0) { + // Surface the first blocker verbatim so the manager sees exactly which + // conflict they need to resolve. + session.flash('error', m.programs_release_blocked_bulk({ count: blocked.length, reason: blocked[0].error })) + } + return data( + { ok: true, released: released.length, blocked }, + { headers: { 'Set-Cookie': await commitSession(session) } }, + ) + }) +} diff --git a/app/features/events/routes/programs/bulk-unrelease.tsx b/app/features/events/routes/programs/bulk-unrelease.tsx new file mode 100644 index 00000000..9d8d4724 --- /dev/null +++ b/app/features/events/routes/programs/bulk-unrelease.tsx @@ -0,0 +1,55 @@ +import { data, redirect } from 'react-router' +import { commitSession, getSession } from '~/features/authentication/index.server' +import { unreleaseEvent } from '~/features/events/server/event-status.server' +import { canManageAnyProgram, getResponsibleTemplateIds } from '~/features/events/server/programme-auth.server' +import * as m from '~/i18n/paraglide/messages' +import { currentAccountContext, permissionsContext, withScopeFromContext } from '~/shared/auth/route-context.server' +import logger from '~/shared/infra/logger.server' +import { Permission } from '~/shared/types/permission' + +import type { Route } from './+types/bulk-unrelease' + +export function loader(_args: Route.LoaderArgs) { + throw redirect('/programs') +} + +export async function action({ request, context }: Route.ActionArgs) { + const session = await getSession(request.headers.get('Cookie')) + const permissions = context.get(permissionsContext) + const currentUser = context.get(currentAccountContext) + const isProgramManager = permissions.has(Permission.ProgramManager) + + const { ids } = (await request.json()) as { ids: number[] } + if (!Array.isArray(ids) || ids.length === 0) return { ok: false } + + return withScopeFromContext(context, async db => { + const { congregationId } = currentUser + const can = (p: Permission) => permissions.has(p) + if (!(await canManageAnyProgram(db, can, currentUser.id, congregationId))) throw redirect('/programs') + + let allowedIds = ids + if (!isProgramManager) { + const responsibleTemplateIds = await getResponsibleTemplateIds(db, currentUser.id, congregationId) + const responsibleSet = new Set(responsibleTemplateIds) + const events = await db.event.findMany({ + where: { id: { in: ids }, congregationId }, + select: { id: true, templateId: true }, + }) + allowedIds = events.filter(e => e.templateId != null && responsibleSet.has(e.templateId)).map(e => e.id) + } + + if (allowedIds.length === 0) return { ok: true, unreleased: 0 } + + let unreleased = 0 + for (const id of allowedIds) { + const result = await unreleaseEvent(db, id, congregationId, currentUser.id) + if (result != null && 'event' in result) unreleased++ + } + logger.info(`Bulk unreleased ${unreleased} events.`) + + if (unreleased > 0) { + session.flash('success', m.programs_unrelease_success_bulk({ count: unreleased })) + } + return data({ ok: true, unreleased }, { headers: { 'Set-Cookie': await commitSession(session) } }) + }) +} diff --git a/app/features/events/routes/programs/events/release.tsx b/app/features/events/routes/programs/events/release.tsx new file mode 100644 index 00000000..1065a762 --- /dev/null +++ b/app/features/events/routes/programs/events/release.tsx @@ -0,0 +1,54 @@ +import { data, redirect } from 'react-router' +import { commitSession, getSession } from '~/features/authentication/index.server' +import { releaseEvent } from '~/features/events/server/event-status.server' +import { canEditEvent } from '~/features/events/server/programme-auth.server' +import * as m from '~/i18n/paraglide/messages' +import { + congregationContext, + currentAccountContext, + permissionsContext, + withScopeFromContext, +} from '~/shared/auth/route-context.server' +import logger from '~/shared/infra/logger.server' +import type { Permission } from '~/shared/types/permission' +import { requireParamId } from '~/shared/utils/params.server' + +import type { Route } from './+types/release' + +export function loader(_args: Route.LoaderArgs) { + throw redirect('/programs') +} + +export async function action({ request, params, context }: Route.ActionArgs) { + const session = await getSession(request.headers.get('Cookie')) + const permissions = context.get(permissionsContext) + const currentUser = context.get(currentAccountContext) + const cong = context.get(congregationContext) + const eventId = requireParamId(params.eventId, '/programs') + + return withScopeFromContext(context, async db => { + const { congregationId } = currentUser + const can = (p: Permission) => permissions.has(p) + const event = await db.event.findFirst({ where: { id: eventId, congregationId }, select: { templateId: true } }) + if (!event) throw redirect('/programs') + if (!(await canEditEvent(db, can, currentUser.id, event.templateId ?? null, congregationId))) { + throw redirect('/programs') + } + + const result = await releaseEvent(db, eventId, congregationId, currentUser.id, { + locale: cong.locale, + timezone: cong.timezone, + }) + if (result == null) throw redirect('/programs') + + if ('error' in result) { + session.flash('error', result.error) + logger.warn(`Event release blocked. User: ${currentUser.id}. Event: ${eventId}.`) + return data({ ok: false }, { headers: { 'Set-Cookie': await commitSession(session) } }) + } + + session.flash('success', m.programs_release_success()) + logger.info(`Event released. User: ${currentUser.id}. Event: ${eventId}.`) + return data({ ok: true }, { headers: { 'Set-Cookie': await commitSession(session) } }) + }) +} diff --git a/app/features/events/routes/programs/events/unrelease.tsx b/app/features/events/routes/programs/events/unrelease.tsx new file mode 100644 index 00000000..78f91c91 --- /dev/null +++ b/app/features/events/routes/programs/events/unrelease.tsx @@ -0,0 +1,39 @@ +import { data, redirect } from 'react-router' +import { commitSession, getSession } from '~/features/authentication/index.server' +import { unreleaseEvent } from '~/features/events/server/event-status.server' +import { canEditEvent } from '~/features/events/server/programme-auth.server' +import * as m from '~/i18n/paraglide/messages' +import { currentAccountContext, permissionsContext, withScopeFromContext } from '~/shared/auth/route-context.server' +import logger from '~/shared/infra/logger.server' +import type { Permission } from '~/shared/types/permission' +import { requireParamId } from '~/shared/utils/params.server' + +import type { Route } from './+types/unrelease' + +export function loader(_args: Route.LoaderArgs) { + throw redirect('/programs') +} + +export async function action({ request, params, context }: Route.ActionArgs) { + const session = await getSession(request.headers.get('Cookie')) + const permissions = context.get(permissionsContext) + const currentUser = context.get(currentAccountContext) + const eventId = requireParamId(params.eventId, '/programs') + + return withScopeFromContext(context, async db => { + const { congregationId } = currentUser + const can = (p: Permission) => permissions.has(p) + const event = await db.event.findFirst({ where: { id: eventId, congregationId }, select: { templateId: true } }) + if (!event) throw redirect('/programs') + if (!(await canEditEvent(db, can, currentUser.id, event.templateId ?? null, congregationId))) { + throw redirect('/programs') + } + + const result = await unreleaseEvent(db, eventId, congregationId, currentUser.id) + if (result == null) throw redirect('/programs') + + session.flash('success', m.programs_unrelease_success()) + logger.info(`Event unreleased. User: ${currentUser.id}. Event: ${eventId}.`) + return data({ ok: true }, { headers: { 'Set-Cookie': await commitSession(session) } }) + }) +} diff --git a/app/features/events/routes/programs/events/view.tsx b/app/features/events/routes/programs/events/view.tsx index d9f77536..059fb7bf 100644 --- a/app/features/events/routes/programs/events/view.tsx +++ b/app/features/events/routes/programs/events/view.tsx @@ -1,6 +1,17 @@ -import { AlertTriangle, Clock, MoreHorizontal, Pencil, Trash2, UserPlus, X } from 'lucide-react' +import { + AlertTriangle, + Clock, + FileText, + Loader2, + MoreHorizontal, + Pencil, + Send, + Trash2, + UserPlus, + X, +} from 'lucide-react' import { useState } from 'react' -import { Link, redirect } from 'react-router' +import { Link, redirect, useFetcher } from 'react-router' import { getPartAssignmentAllowedRoleIds, getServiceRoleAssignmentAllowedRoleIds, @@ -217,12 +228,21 @@ export default function EventViewPage({ loaderData }: Route.ComponentProps) {
+ + {m.programs_event_draft_badge()} + + ) + } subtitle={`${dateStr} — ${startTime} - ${endTime}`} breadcrumbs={[{ label: m.sidebar_programs(), to: '/programs' }, { label: event.name }]} backTo="/programs" actions={ canEdit && (
+ + ) +} + function AssigneeCell({ assignee, externalSpeaker, diff --git a/app/features/events/routes/programs/list.tsx b/app/features/events/routes/programs/list.tsx index cd009ccd..669bceea 100644 --- a/app/features/events/routes/programs/list.tsx +++ b/app/features/events/routes/programs/list.tsx @@ -3,8 +3,10 @@ import { CalendarOff, ChevronRight, FileDown, + FileText, Loader2, MoreHorizontal, + Send, Trash2, UserCog, } from 'lucide-react' @@ -164,6 +166,15 @@ function ConflictBadge({ count }: { count: number }) { ) } +function DraftBadge() { + return ( + + + ) +} + function EventRow({ event, timezone, @@ -181,6 +192,7 @@ function EventRow({ const time = eventTimeOrEmpty(new Date(event.startDate), timezone) const isUpcoming = new Date(event.startDate) >= new Date() const showConflictBadge = isUpcoming && event.conflictCount > 0 + const isDraft = event.status === 'draft' const cardStyle = { borderLeftColor: event.kind?.color ?? 'transparent', borderLeftWidth: '4px' } const kindLabel = event.kind ? ` · ${event.kind.name}` : '' const timeLabel = time ? ` · ${time}` : '' @@ -215,6 +227,7 @@ function EventRow({
+ {isDraft && } {showConflictBadge && }
@@ -267,11 +280,17 @@ export default function ProgramListPage({ loaderData }: Route.ComponentProps) { const [selectedIds, setSelectedIds] = useState>(new Set()) const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false) const bulkFetcher = useFetcher<{ ok: boolean }>() + const bulkStatusFetcher = useFetcher<{ ok: boolean }>() const weekGroups = groupByWeek(upcomingEvents) const editableIds = upcomingEvents.filter(e => e.canEdit).map(e => e.id) const hasEditableEvents = editableIds.length > 0 const isDeleting = bulkFetcher.state !== 'idle' + const isChangingStatus = bulkStatusFetcher.state !== 'idle' + + const selectedEvents = upcomingEvents.filter(e => selectedIds.has(e.id)) + const anyDraftSelected = selectedEvents.some(e => e.status === 'draft') + const anyReleasedSelected = selectedEvents.some(e => e.status === 'released') useEffect(() => { if (bulkFetcher.state === 'idle' && bulkFetcher.data?.ok === true) { @@ -279,6 +298,12 @@ export default function ProgramListPage({ loaderData }: Route.ComponentProps) { } }, [bulkFetcher.state, bulkFetcher.data]) + useEffect(() => { + if (bulkStatusFetcher.state === 'idle' && bulkStatusFetcher.data?.ok === true) { + setSelectedIds(new Set()) + } + }, [bulkStatusFetcher.state, bulkStatusFetcher.data]) + function toggleSelection(id: number) { setSelectedIds(prev => { const next = new Set(prev) @@ -315,6 +340,21 @@ export default function ProgramListPage({ loaderData }: Route.ComponentProps) { setBulkDeleteOpen(false) } + function bulkRelease() { + const ids = selectedEvents.filter(e => e.status === 'draft').map(e => e.id) + if (ids.length === 0) return + bulkStatusFetcher.submit({ ids }, { method: 'POST', action: '/programs/bulk-release', encType: 'application/json' }) + } + + function bulkUnrelease() { + const ids = selectedEvents.filter(e => e.status === 'released').map(e => e.id) + if (ids.length === 0) return + bulkStatusFetcher.submit( + { ids }, + { method: 'POST', action: '/programs/bulk-unrelease', encType: 'application/json' }, + ) + } + return (
setSelectedIds(new Set())}> {m.programs_bulk_deselect()} + {anyDraftSelected && ( + + )} + {anyReleasedSelected && ( + + )}
diff --git a/app/i18n/messages/en.json b/app/i18n/messages/en.json index 12e6022d..e10cd42f 100644 --- a/app/i18n/messages/en.json +++ b/app/i18n/messages/en.json @@ -73,6 +73,11 @@ "audit_log_action_publisher_activity_updated": "Activity updated", "audit_log_action_publisher_activity_deleted": "Activity deleted", "audit_log_action_congregation_settings_updated": "Settings updated", + "audit_log_action_event_released": "Event published", + "audit_log_action_event_unreleased": "Event moved back to draft", + "audit_log_action_event_deleted": "Event deleted", + "audit_log_entity_event": "Event", + "audit_log_group_events": "Programmes", "audit_log_entity_user": "User", "audit_log_entity_congregation": "Congregation", "audit_log_entity_board_document": "Document", @@ -1355,8 +1360,11 @@ "programs_release_success": "Event published.", "programs_release_success_bulk": "{count} event(s) published.", "programs_release_blocked_bulk": "{count} event(s) with absence conflict(s) could not be published.", + "programs_release_failed_bulk": "{count} event(s) could not be published due to a technical error.", + "programs_bulk_not_found": "{count} event(s) not found. Refresh the page.", "programs_unrelease_success": "Event moved back to draft.", "programs_unrelease_success_bulk": "{count} event(s) moved back to draft.", + "programs_unrelease_failed_bulk": "{count} event(s) could not be moved back to draft due to a technical error.", "programs_bulk_invalid_payload": "The selection is invalid. Refresh the page and try again.", "programs_export_pdf_button": "Export as PDF", "programs_days_off_button": "Absences", diff --git a/app/i18n/messages/fr.json b/app/i18n/messages/fr.json index eb9ca188..74eaeb47 100644 --- a/app/i18n/messages/fr.json +++ b/app/i18n/messages/fr.json @@ -73,6 +73,11 @@ "audit_log_action_publisher_activity_updated": "Activité modifiée", "audit_log_action_publisher_activity_deleted": "Activité supprimée", "audit_log_action_congregation_settings_updated": "Paramètres modifiés", + "audit_log_action_event_released": "Évènement publié", + "audit_log_action_event_unreleased": "Évènement repassé en brouillon", + "audit_log_action_event_deleted": "Évènement supprimé", + "audit_log_entity_event": "Évènement", + "audit_log_group_events": "Programmes", "audit_log_entity_user": "Utilisateur", "audit_log_entity_congregation": "Assemblée", "audit_log_entity_board_document": "Document", @@ -1357,8 +1362,11 @@ "programs_release_success": "Évènement publié.", "programs_release_success_bulk": "{count} évènement(s) publié(s).", "programs_release_blocked_bulk": "{count} évènement(s) avec conflit(s) d'absence n'ont pas pu être publié(s).", + "programs_release_failed_bulk": "{count} évènement(s) n'ont pas pu être publié(s) en raison d'une erreur technique.", + "programs_bulk_not_found": "{count} évènement(s) introuvable(s). Actualisez la page.", "programs_unrelease_success": "Évènement repassé en brouillon.", "programs_unrelease_success_bulk": "{count} évènement(s) repassé(s) en brouillon.", + "programs_unrelease_failed_bulk": "{count} évènement(s) n'ont pas pu être repassé(s) en brouillon en raison d'une erreur technique.", "programs_bulk_invalid_payload": "La sélection est invalide. Actualisez la page et réessayez.", "programs_export_pdf_button": "Exporter en PDF", "programs_days_off_button": "Absences", From 9fd3caaf457b7c6280c8add7a321827f660ae54f Mon Sep 17 00:00:00 2001 From: mindsers Date: Thu, 16 Jul 2026 10:17:16 +0200 Subject: [PATCH 08/14] fix(events): aggregate bulk-action toasts and drop dead unrelease branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../events/routes/programs/bulk-release.tsx | 16 +- .../events/routes/programs/bulk-unrelease.tsx | 9 +- .../server/event-status-bulk.server.test.ts | 160 ++++++++++++++++++ .../events/server/event-status-bulk.server.ts | 1 - .../events/server/event-status.server.test.ts | 118 ------------- .../events/server/event-status.server.ts | 6 +- app/shared/utils/join-messages.test.ts | 28 +++ app/shared/utils/join-messages.ts | 4 + 8 files changed, 214 insertions(+), 128 deletions(-) create mode 100644 app/features/events/server/event-status-bulk.server.test.ts create mode 100644 app/shared/utils/join-messages.test.ts create mode 100644 app/shared/utils/join-messages.ts diff --git a/app/features/events/routes/programs/bulk-release.tsx b/app/features/events/routes/programs/bulk-release.tsx index 1f57555e..6abd3dd5 100644 --- a/app/features/events/routes/programs/bulk-release.tsx +++ b/app/features/events/routes/programs/bulk-release.tsx @@ -12,6 +12,7 @@ import { } from '~/shared/auth/route-context.server' import logger from '~/shared/infra/logger.server' import type { Permission } from '~/shared/types/permission' +import { joinMessages } from '~/shared/utils/join-messages' import type { Route } from './+types/bulk-release' @@ -55,13 +56,16 @@ export async function action({ request, context }: Route.ActionArgs) { `Bulk released ${released.length} events; ${blocked.length} blocked; ${notFound.length} not found; ${failed.length} failed.`, ) - // Successes first — the manager sees positive confirmation of what worked. + // session.flash stores one string per key, so multiple flash('error', ...) + // calls silently overwrite each other. Aggregate the buckets into a single + // toast per severity to preserve every non-empty message. if (released.length > 0) session.flash('success', m.programs_release_success_bulk({ count: released.length })) - // A failed-only response (Phase 1 filtered everything to notFound / failed) - // would leave the manager with no feedback; every bucket has its own toast. - if (blocked.length > 0) session.flash('error', m.programs_release_blocked_bulk({ count: blocked.length })) - if (failed.length > 0) session.flash('error', m.programs_release_failed_bulk({ count: failed.length })) - if (notFound.length > 0) session.flash('error', m.programs_bulk_not_found({ count: notFound.length })) + const errorMessage = joinMessages([ + blocked.length > 0 && m.programs_release_blocked_bulk({ count: blocked.length }), + failed.length > 0 && m.programs_release_failed_bulk({ count: failed.length }), + notFound.length > 0 && m.programs_bulk_not_found({ count: notFound.length }), + ]) + if (errorMessage) session.flash('error', errorMessage) return data( { ok: true, released: released.length, blocked, notFound: notFound.length, failed: failed.length }, { headers: { 'Set-Cookie': await commitSession(session) } }, diff --git a/app/features/events/routes/programs/bulk-unrelease.tsx b/app/features/events/routes/programs/bulk-unrelease.tsx index 467fd37d..d388b646 100644 --- a/app/features/events/routes/programs/bulk-unrelease.tsx +++ b/app/features/events/routes/programs/bulk-unrelease.tsx @@ -7,6 +7,7 @@ import * as m from '~/i18n/paraglide/messages' import { currentAccountContext, permissionsContext, withScopeFromContext } from '~/shared/auth/route-context.server' import logger from '~/shared/infra/logger.server' import type { Permission } from '~/shared/types/permission' +import { joinMessages } from '~/shared/utils/join-messages' import type { Route } from './+types/bulk-unrelease' @@ -41,11 +42,15 @@ export async function action({ request, context }: Route.ActionArgs) { const { unreleased, notFound, failed } = await bulkUnreleaseEvents(allowedIds, congregationId, currentUser.id) logger.info(`Bulk unreleased ${unreleased.length} events; ${notFound.length} not found; ${failed.length} failed.`) + // See bulk-release.tsx for the flash-aggregation rationale. if (unreleased.length > 0) { session.flash('success', m.programs_unrelease_success_bulk({ count: unreleased.length })) } - if (failed.length > 0) session.flash('error', m.programs_unrelease_failed_bulk({ count: failed.length })) - if (notFound.length > 0) session.flash('error', m.programs_bulk_not_found({ count: notFound.length })) + const errorMessage = joinMessages([ + failed.length > 0 && m.programs_unrelease_failed_bulk({ count: failed.length }), + notFound.length > 0 && m.programs_bulk_not_found({ count: notFound.length }), + ]) + if (errorMessage) session.flash('error', errorMessage) return data( { ok: true, unreleased: unreleased.length, notFound: notFound.length, failed: failed.length }, { headers: { 'Set-Cookie': await commitSession(session) } }, diff --git a/app/features/events/server/event-status-bulk.server.test.ts b/app/features/events/server/event-status-bulk.server.test.ts new file mode 100644 index 00000000..0a66ee53 --- /dev/null +++ b/app/features/events/server/event-status-bulk.server.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('~/shared/infra/db.server', () => { + const unscopedDb = { + event: { findFirst: vi.fn(), update: vi.fn() }, + notificationEvent: { updateMany: vi.fn() }, + userAccount: { findFirst: vi.fn() }, + } + return { + unscopedDb, + // Per-event bulk paths use withScope; the mock just invokes the callback + // with the shared mock client so tests can program per-id behaviour via + // event.findFirst.mockImplementation. + withScope: vi.fn(async (_congregationId: number, fn: (tx: unknown) => unknown) => fn(unscopedDb)), + } +}) + +vi.mock('~/shared/domain/audit.server', async importOriginal => { + const actual = await importOriginal() + return { ...actual, audit: vi.fn(), auditInTransaction: vi.fn() } +}) + +vi.mock('./notify-assignment.server', async importOriginal => { + const actual = await importOriginal() + return { ...actual, notifyAssignment: vi.fn() } +}) + +const { bulkReleaseEvents, bulkUnreleaseEvents } = await import('./event-status-bulk.server') +const { unscopedDb: db } = await import('~/shared/infra/db.server') +const { notifyAssignment } = await import('./notify-assignment.server') + +const nctx = { locale: 'fr-FR', timezone: 'Europe/Paris' } +const draftEvent = { + id: 42, + name: 'Réunion', + status: 'draft', + startDate: new Date(2026, 3, 14), + templateId: 7, + partAssignments: [] as unknown[], + serviceRoleAssignments: [] as unknown[], +} +const releasedEvent = { ...draftEvent, status: 'released' } + +beforeEach(() => { + vi.resetAllMocks() +}) + +describe('bulkReleaseEvents (per-event scope + post-tx notifications)', () => { + it('classifies each event into released / blocked / notFound / failed', async () => { + // 10 → not found; 20 → conflict-blocked; 30 → clean release. + // biome-ignore lint/suspicious/noExplicitAny: mock signature needs to match Prisma's generated overloads + vi.mocked(db.event.findFirst).mockImplementation(((args: any) => { + const id = args?.where?.id as number + if (id === 10) return Promise.resolve(null) + if (id === 20) { + return Promise.resolve({ + ...draftEvent, + id: 20, + partAssignments: [{ id: 100, name: 'Part', hasConflict: true, assigneeId: 5, assistantId: null }], + serviceRoleAssignments: [], + }) + } + return Promise.resolve({ ...draftEvent, id: 30 }) + }) as never) + vi.mocked(db.event.update).mockResolvedValue({ ...releasedEvent, id: 30 } as never) + + const result = await bulkReleaseEvents([10, 20, 30], 1, 5, nctx) + + expect(result.released).toEqual([30]) + expect(result.blocked.map((b: { id: number }) => b.id)).toEqual([20]) + expect(result.notFound).toEqual([10]) + expect(result.failed).toEqual([]) + }) + + // A Prisma error inside `withScope` (pool exhaustion, connection reset, tx + // timeout) previously escaped the loop and 500'd the whole batch, silently + // dropping partial progress. Now each event's release is caught and lands + // in the `failed` bucket; the loop continues. + it('lands per-event withScope failures in the `failed` bucket without aborting the batch', async () => { + const { withScope } = await import('~/shared/infra/db.server') + vi.mocked(withScope).mockImplementation(((_cid: number, fn: (tx: unknown) => unknown) => fn(db)) as never) + vi.mocked(db.event.findFirst).mockResolvedValue({ ...draftEvent } as never) + vi.mocked(db.event.update).mockResolvedValue(releasedEvent as never) + vi.mocked(withScope) + .mockImplementationOnce(((_cid: number, fn: (tx: unknown) => unknown) => fn(db)) as never) + .mockImplementationOnce(() => Promise.reject(new Error('pool exhausted'))) + .mockImplementation(((_cid: number, fn: (tx: unknown) => unknown) => fn(db)) as never) + + const result = await bulkReleaseEvents([10, 20, 30], 1, 5, nctx) + + expect(result.released).toEqual([10, 30]) + expect(result.failed.map((f: { id: number }) => f.id)).toEqual([20]) + }) + + // fireReleaseNotifications runs OUTSIDE the release tx. This test verifies + // that after a successful release, notifyAssignment is invoked for every + // computed target. + it('fires notifyAssignment for each target of every released event', async () => { + vi.mocked(db.event.findFirst).mockResolvedValue({ + ...draftEvent, + partAssignments: [{ id: 100, name: 'Perle', hasConflict: false, assigneeId: 5, assistantId: null }], + serviceRoleAssignments: [], + } as never) + vi.mocked(db.event.update).mockResolvedValue(releasedEvent as never) + + await bulkReleaseEvents([42], 1, 5, nctx) + + expect(notifyAssignment).toHaveBeenCalledTimes(1) + expect(vi.mocked(notifyAssignment).mock.calls[0][2]).toMatchObject({ memberId: 5, role: 'speaker' }) + }) + + it('returns empty buckets when the input list is empty', async () => { + const result = await bulkReleaseEvents([], 1, 5, nctx) + expect(result).toEqual({ released: [], blocked: [], notFound: [], failed: [] }) + expect(db.event.findFirst).not.toHaveBeenCalled() + }) +}) + +describe('bulkUnreleaseEvents (per-event scope)', () => { + it('classifies each event into unreleased / notFound / failed', async () => { + // biome-ignore lint/suspicious/noExplicitAny: mock signature needs to match Prisma's generated overloads + vi.mocked(db.event.findFirst).mockImplementation(((args: any) => { + const id = args?.where?.id as number + if (id === 10) return Promise.resolve(null) + return Promise.resolve({ ...releasedEvent, id, partAssignments: [], serviceRoleAssignments: [] }) + }) as never) + vi.mocked(db.event.update).mockResolvedValue(draftEvent as never) + + const result = await bulkUnreleaseEvents([10, 20, 30], 1, 5) + + expect(result.unreleased).toEqual([20, 30]) + expect(result.notFound).toEqual([10]) + expect(result.failed).toEqual([]) + }) + + it('lands per-event withScope failures in the `failed` bucket without aborting the batch', async () => { + const { withScope } = await import('~/shared/infra/db.server') + vi.mocked(db.event.findFirst).mockResolvedValue({ + ...releasedEvent, + partAssignments: [], + serviceRoleAssignments: [], + } as never) + vi.mocked(db.event.update).mockResolvedValue(draftEvent as never) + vi.mocked(withScope) + .mockImplementationOnce(((_cid: number, fn: (tx: unknown) => unknown) => fn(db)) as never) + .mockImplementationOnce(() => Promise.reject(new Error('pool exhausted'))) + .mockImplementation(((_cid: number, fn: (tx: unknown) => unknown) => fn(db)) as never) + + const result = await bulkUnreleaseEvents([10, 20, 30], 1, 5) + + expect(result.unreleased).toEqual([10, 30]) + expect(result.failed.map((f: { id: number }) => f.id)).toEqual([20]) + }) + + it('returns empty buckets when the input list is empty', async () => { + const result = await bulkUnreleaseEvents([], 1, 5) + expect(result).toEqual({ unreleased: [], notFound: [], failed: [] }) + expect(db.event.findFirst).not.toHaveBeenCalled() + }) +}) diff --git a/app/features/events/server/event-status-bulk.server.ts b/app/features/events/server/event-status-bulk.server.ts index a49ef907..56e9b2c2 100644 --- a/app/features/events/server/event-status-bulk.server.ts +++ b/app/features/events/server/event-status-bulk.server.ts @@ -92,7 +92,6 @@ export async function bulkUnreleaseEvents( continue } if (result == null) notFound.push(id) - else if ('error' in result) failed.push({ id, error: result.error }) else unreleased.push(id) } diff --git a/app/features/events/server/event-status.server.test.ts b/app/features/events/server/event-status.server.test.ts index 0ea23e6b..3a84bb06 100644 --- a/app/features/events/server/event-status.server.test.ts +++ b/app/features/events/server/event-status.server.test.ts @@ -30,7 +30,6 @@ vi.mock('./notify-assignment.server', async importOriginal => { }) const { releaseEvent, unreleaseEvent, fireReleaseNotifications } = await import('./event-status.server') -const { bulkReleaseEvents, bulkUnreleaseEvents } = await import('./event-status-bulk.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') const { audit, auditInTransaction } = await import('~/shared/domain/audit.server') const { notifyAssignment } = await import('./notify-assignment.server') @@ -373,120 +372,3 @@ describe('unreleaseEvent', () => { expect(audit).not.toHaveBeenCalled() }) }) - -describe('bulkReleaseEvents (per-event scope + post-tx notifications)', () => { - it('classifies each event into released / blocked / notFound / failed', async () => { - // 10 → not found; 20 → conflict-blocked; 30 → clean release. - // biome-ignore lint/suspicious/noExplicitAny: mock signature needs to match Prisma's generated overloads - vi.mocked(db.event.findFirst).mockImplementation(((args: any) => { - const id = args?.where?.id as number - if (id === 10) return Promise.resolve(null) - if (id === 20) { - return Promise.resolve({ - ...draftEvent, - id: 20, - partAssignments: [{ id: 100, name: 'Part', hasConflict: true, assigneeId: 5, assistantId: null }], - serviceRoleAssignments: [], - }) - } - return Promise.resolve({ ...draftEvent, id: 30 }) - }) as never) - vi.mocked(db.event.update).mockResolvedValue({ ...releasedEvent, id: 30 } as never) - - const result = await bulkReleaseEvents([10, 20, 30], 1, 5, nctx) - - expect(result.released).toEqual([30]) - expect(result.blocked.map((b: { id: number }) => b.id)).toEqual([20]) - expect(result.notFound).toEqual([10]) - expect(result.failed).toEqual([]) - }) - - // A Prisma error inside `withScope` (pool exhaustion, connection reset, - // tx timeout) previously escaped the loop and 500'd the whole batch, - // silently dropping partial progress. Now each event's release is caught - // and lands in the `failed` bucket; the loop continues. - it('lands per-event withScope failures in the `failed` bucket without aborting the batch', async () => { - const { withScope } = await import('~/shared/infra/db.server') - // First event succeeds; second event's withScope throws (Prisma error); - // third event succeeds again. - vi.mocked(withScope).mockImplementation(((_cid: number, fn: (tx: unknown) => unknown) => fn(db)) as never) - vi.mocked(db.event.findFirst).mockResolvedValue({ ...draftEvent } as never) - vi.mocked(db.event.update).mockResolvedValue(releasedEvent as never) - // Poison the second per-event scope call. - vi.mocked(withScope) - .mockImplementationOnce((async (_cid: number, fn: (tx: unknown) => unknown) => fn(db)) as never) - .mockImplementationOnce(() => Promise.reject(new Error('pool exhausted'))) - .mockImplementation((async (_cid: number, fn: (tx: unknown) => unknown) => fn(db)) as never) - - const result = await bulkReleaseEvents([10, 20, 30], 1, 5, nctx) - - expect(result.released).toEqual([10, 30]) - expect(result.failed.map((f: { id: number }) => f.id)).toEqual([20]) - }) - - // fireReleaseNotifications runs OUTSIDE the release tx. This test verifies - // that after a successful release, notifyAssignment is invoked for every - // computed target. - it('fires notifyAssignment for each target of every released event', async () => { - vi.mocked(db.event.findFirst).mockResolvedValue({ - ...draftEvent, - partAssignments: [{ id: 100, name: 'Perle', hasConflict: false, assigneeId: 5, assistantId: null }], - serviceRoleAssignments: [], - } as never) - vi.mocked(db.event.update).mockResolvedValue(releasedEvent as never) - - await bulkReleaseEvents([42], 1, 5, nctx) - - expect(notifyAssignment).toHaveBeenCalledTimes(1) - expect(vi.mocked(notifyAssignment).mock.calls[0][2]).toMatchObject({ memberId: 5, role: 'speaker' }) - }) - - it('returns empty buckets when the input list is empty', async () => { - const result = await bulkReleaseEvents([], 1, 5, nctx) - expect(result).toEqual({ released: [], blocked: [], notFound: [], failed: [] }) - expect(db.event.findFirst).not.toHaveBeenCalled() - }) -}) - -describe('bulkUnreleaseEvents (per-event scope)', () => { - it('classifies each event into unreleased / notFound / failed', async () => { - // biome-ignore lint/suspicious/noExplicitAny: mock signature needs to match Prisma's generated overloads - vi.mocked(db.event.findFirst).mockImplementation(((args: any) => { - const id = args?.where?.id as number - if (id === 10) return Promise.resolve(null) - return Promise.resolve({ ...releasedEvent, id, partAssignments: [], serviceRoleAssignments: [] }) - }) as never) - vi.mocked(db.event.update).mockResolvedValue(draftEvent as never) - - const result = await bulkUnreleaseEvents([10, 20, 30], 1, 5) - - expect(result.unreleased).toEqual([20, 30]) - expect(result.notFound).toEqual([10]) - expect(result.failed).toEqual([]) - }) - - it('lands per-event withScope failures in the `failed` bucket without aborting the batch', async () => { - const { withScope } = await import('~/shared/infra/db.server') - vi.mocked(db.event.findFirst).mockResolvedValue({ - ...releasedEvent, - partAssignments: [], - serviceRoleAssignments: [], - } as never) - vi.mocked(db.event.update).mockResolvedValue(draftEvent as never) - vi.mocked(withScope) - .mockImplementationOnce((async (_cid: number, fn: (tx: unknown) => unknown) => fn(db)) as never) - .mockImplementationOnce(() => Promise.reject(new Error('pool exhausted'))) - .mockImplementation((async (_cid: number, fn: (tx: unknown) => unknown) => fn(db)) as never) - - const result = await bulkUnreleaseEvents([10, 20, 30], 1, 5) - - expect(result.unreleased).toEqual([10, 30]) - expect(result.failed.map((f: { id: number }) => f.id)).toEqual([20]) - }) - - it('returns empty buckets when the input list is empty', async () => { - const result = await bulkUnreleaseEvents([], 1, 5) - expect(result).toEqual({ unreleased: [], notFound: [], failed: [] }) - expect(db.event.findFirst).not.toHaveBeenCalled() - }) -}) diff --git a/app/features/events/server/event-status.server.ts b/app/features/events/server/event-status.server.ts index b9cccb22..ebbe431c 100644 --- a/app/features/events/server/event-status.server.ts +++ b/app/features/events/server/event-status.server.ts @@ -35,7 +35,11 @@ type EventWithStatus = { } export type ReleaseResult = { event: EventWithStatus; notifyTargets: NotifyTarget[] } | { error: string } -export type UnreleaseResult = { event: EventWithStatus } | { error: string } +// No policy currently blocks an un-release — the only ways it can not-succeed +// are "event doesn't exist" (returns null) or a raw Prisma throw (caught by +// the bulk caller). Keep this shape narrow; widen it the day an unrelease +// invariant is introduced. +export type UnreleaseResult = { event: EventWithStatus } const eventWithAssignmentsInclude = { partAssignments: { diff --git a/app/shared/utils/join-messages.test.ts b/app/shared/utils/join-messages.test.ts new file mode 100644 index 00000000..6470d9c8 --- /dev/null +++ b/app/shared/utils/join-messages.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { joinMessages } from './join-messages' + +describe('joinMessages', () => { + it('returns null when no parts are provided', () => { + expect(joinMessages([])).toBeNull() + }) + + it('returns null when every part is empty / null / undefined / false', () => { + expect(joinMessages([null, undefined, false, ''])).toBeNull() + }) + + it('returns the single string when only one truthy part is given', () => { + expect(joinMessages([null, 'only one', false])).toBe('only one') + }) + + it('joins multiple truthy parts with newlines by default', () => { + expect(joinMessages(['a', 'b', 'c'])).toBe('a\nb\nc') + }) + + it('drops falsy entries and preserves the order of the truthy ones', () => { + expect(joinMessages(['first', null, false, 'second', undefined, 'third'])).toBe('first\nsecond\nthird') + }) + + it('accepts a custom separator', () => { + expect(joinMessages(['a', 'b'], ' · ')).toBe('a · b') + }) +}) diff --git a/app/shared/utils/join-messages.ts b/app/shared/utils/join-messages.ts new file mode 100644 index 00000000..c74b7e78 --- /dev/null +++ b/app/shared/utils/join-messages.ts @@ -0,0 +1,4 @@ +export function joinMessages(parts: (string | null | undefined | false)[], separator = '\n'): string | null { + const filtered = parts.filter((part): part is string => Boolean(part)) + return filtered.length === 0 ? null : filtered.join(separator) +} From e8af552dd9557521a28dd6e4ae72eb80a9d6043a Mon Sep 17 00:00:00 2001 From: mindsers Date: Thu, 16 Jul 2026 10:56:43 +0200 Subject: [PATCH 09/14] 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. --- docs/development/notifications.md | 10 ++++++++ docs/development/permissions-and-roles.md | 12 +++++++++ docs/product/calendar-feed.md | 2 ++ docs/product/dashboard.md | 4 +-- docs/product/display-board.md | 2 +- docs/product/events.md | 30 +++++++++++++++++++++-- docs/product/feature-overview.md | 3 ++- docs/product/notifications.md | 8 ++++++ docs/product/roles-and-permissions.md | 4 +-- docs/product/settings.md | 2 +- 10 files changed, 68 insertions(+), 9 deletions(-) diff --git a/docs/development/notifications.md b/docs/development/notifications.md index ee327705..f3c592ef 100644 --- a/docs/development/notifications.md +++ b/docs/development/notifications.md @@ -128,6 +128,16 @@ await notify(db, { The function is fire-and-forget — failures are logged but never block the calling operation. +## Programme assignment dispatch gate + +`programme.assignment.{assigned,unassigned}` are **whitelist-gated on `Event.status === 'released'`** in `notify-assignment.server.ts` (`dispatchAssignmentDiffs`). Assignments on a draft event never call `notify()` — no `NotificationEvent` row is created, no debounce is armed, nothing shows up in the queue. + +When a manager releases an event, `fireReleaseNotifications` (`app/features/events/server/event-status.server.ts`) iterates every current part / service-role assignee and calls `notifyAssignment` for each — the *same* code path that runs during a live edit, so the debounce, cancellation, and preference filter all apply uniformly. Un-releasing the event calls `unreleaseEvent`, which marks every `pending` `NotificationEvent` row targeting the event's assignments as `cancelled`. + +The gate is a **whitelist, not a blacklist**: the code checks `status === Released`, not `status !== Draft`. If we ever introduce a third status (e.g. `archived`), the safe default is "don't notify". Un-release wins over release only by not running — assignments made on a draft are silent by construction, so the release-time re-fire is the single source of "you were assigned" emails. + +The debounce window is intentionally short at **30 minutes** (`app/features/events/server/notifications.server.tsx`): it is a safety net for accidental releases, not a batching mechanism. Drafting removed the need for the older 2h window. + ## Debounce & Flush When `debounceMinutes > 0`, `notify()` creates a `NotificationEvent` row in PostgreSQL with `status: 'pending'` and `debounceUntil` set to `now + debounceMinutes`. diff --git a/docs/development/permissions-and-roles.md b/docs/development/permissions-and-roles.md index 345e481b..988bb0b6 100644 --- a/docs/development/permissions-and-roles.md +++ b/docs/development/permissions-and-roles.md @@ -88,6 +88,18 @@ Created and edited through `app/shared/domain/roles.server.ts`: The corresponding routes live in `app/features/congregation/routes/roles/`. +## Event release / un-release authorization + +Release and un-release actions do **not** get their own permission. They share the event-edit gate `canEditEvent` in `app/features/events/server/programme-auth.server.ts`: + +- **Program Manager** — can release/un-release any event +- **Template responsible** — the user set as `ProgrammeTemplate.responsible` can release/un-release events generated from that template (mirrors the existing edit + delete carve-out) +- **Admin** — everything (expanded from `Permission.Admin`) + +The route handlers (`programs/events/release.tsx`, `unrelease.tsx`, `bulk-release.tsx`, `bulk-unrelease.tsx`) run authorization in Phase 1 of a two-phase pattern: a small scoped tx checks `canManageAnyProgram` and (for bulk) filters submitted IDs through `filterToManageableEventIds`. Phase 2 runs the mutation per-event via `withScope` and — for release only — fires the notifications *outside* the release tx (see [notifications.md](notifications.md#programme-assignment-dispatch-gate)). + +Bulk routes are hardened against cross-tenant probes: `filterToManageableEventIds` runs a `congregationId`-scoped `findMany` even for managers, so IDs from another tenant are silently dropped and logged as `warn` for triage. Non-manager drops (freeform events, unauthorized templates, cross-tenant IDs) are logged at `info` with counters split by reason. + ## Adding a new permission 1. **Enum entry.** Add the new value to `Permission` in `app/shared/types/permission.ts`. Use `kebab-case` for the value (it's the DB key). diff --git a/docs/product/calendar-feed.md b/docs/product/calendar-feed.md index d09772af..0ced067d 100644 --- a/docs/product/calendar-feed.md +++ b/docs/product/calendar-feed.md @@ -8,6 +8,8 @@ Every member can subscribe to their own programme assignments and absences from - Service role assignments where the member is the assignee - Days off the member has recorded +Only assignments on [released](events.md#draft-and-released-events) events appear in the feed. An assignment on a draft event stays invisible until the programme manager publishes the event — at which point the calendar app picks it up on its next refresh. + The feed contains all future events plus events from the last three months. Anything older is dropped to keep calendar apps responsive. ## Subscribing diff --git a/docs/product/dashboard.md b/docs/product/dashboard.md index ffa36cb8..288ce57b 100644 --- a/docs/product/dashboard.md +++ b/docs/product/dashboard.md @@ -38,7 +38,7 @@ A conditional section that surfaces time-sensitive items from across features. I |---|---|---|---| | 0 | Imminent part assignment | User has a programme part and the meeting is within 3 days | The board | | 1 | Overdue territory | Territory due date is in the past | The territory page | -| 2 | Day-off conflict | An upcoming absence overlaps the next meeting where the user has assignments | The absences page | +| 2 | Day-off conflict | An upcoming absence overlaps the next meeting where the user has assignments — only released events count; draft-event conflicts are not urgent and surface at release time on the programme list | The absences page | | 3 | Imminent service role | User has a service role and the meeting is within 3 days | The board | | 4 | Due-soon territory | Territory due date is within 2 weeks | The territory page | | 5 | Unread documents | At least 1 visible document not yet viewed | The board | @@ -64,7 +64,7 @@ If the member has no assigned territories, an empty state is shown with guidance ## Next meeting -Shows the next scheduled meeting with the member's assignments highlighted. The card header displays the meeting name and date (e.g., *Midweek meeting — Wednesday 25 April*). +Shows the next scheduled meeting with the member's assignments highlighted. The card header displays the meeting name and date (e.g., *Midweek meeting — Wednesday 25 April*). Only [released](events.md#draft-and-released-events) events appear here — meetings still in draft are not shown. If the member has assignments for that meeting, they are listed with role badges: diff --git a/docs/product/display-board.md b/docs/product/display-board.md index dff0f0f3..e5f1baab 100644 --- a/docs/product/display-board.md +++ b/docs/product/display-board.md @@ -128,7 +128,7 @@ Available types (appear only when the related feature has data): - **Field service groups** — Live list of publisher groups with responsible, deputy, and members - **Pioneers** — List of publishers registered as regular pioneers, special pioneers, or missionaries -- **Programmes** — Configurable live schedule documents. Each programme document selects which template parts and service roles to show, and how to group them (by date or by template). Multiple programme documents can be created with different configurations. The view shows events from the start of the current month with a clean layout: colored section bars, dot leaders between part names and assignees, and per-template content filtering. +- **Programmes** — Configurable live schedule documents. Each programme document selects which template parts and service roles to show, and how to group them (by date or by template). Multiple programme documents can be created with different configurations. The view shows events from the start of the current month with a clean layout: colored section bars, dot leaders between part names and assignees, and per-template content filtering. Only [released](events.md#draft-and-released-events) events appear — drafts stay hidden until the programme manager publishes them. Dynamic documents support the same visibility, highlighting, ordering, and section placement controls as PDF documents. They appear alongside PDFs in the same sections on the board. On the board view, each dynamic document card shows a **preview summary** (group count, pioneer count, or next event date) to provide context at a glance. diff --git a/docs/product/events.md b/docs/product/events.md index b452bf99..49e99e5a 100644 --- a/docs/product/events.md +++ b/docs/product/events.md @@ -42,6 +42,32 @@ Events are accessible at **Programmes** in the sidebar. The list is grouped by w Bulk deletion is available: select multiple events using the per-week or global checkboxes, then confirm deletion via the bulk action bar. +### Draft and released events + +Every new programme event is created as a **draft** so programme organizers can build a schedule privately before announcing it. Draft events carry a **Brouillon** badge on the programme list and behave like an in-progress document: only people with programme edit access can see them. + +While an event is draft: + +- It does **not** appear on the display board or in publishers' personal calendar feeds +- It is **not** listed on the dashboard "Next meeting" card or in day-off conflict warnings +- It does **not** trigger any assignment email — publishers assigned to a draft event are notified only when the event is released +- Absences overlapping the draft are still detected and flagged with a **Conflit** badge on the programme list, so managers can resolve them before publishing + +**Releasing** an event flips it to public: the badge is dropped, the event appears everywhere it should, and assignment notifications are sent to every current assignee. Notifications are held for 30 minutes as a safety net — a manager who releases by mistake can un-release inside that window and pending emails are cancelled. + +Release is **blocked** when the event has unresolved absence conflicts. A toast lists how many parts and services are blocking; resolve the conflicts (reassign, or update the absence) and try again. + +**Un-releasing** an event returns it to draft: it disappears from the board, calendar feeds, and dashboard cards, and any not-yet-sent assignment email is cancelled. Publishers who already received the notification before un-release keep it — Unitae doesn't send a follow-up "never mind" email. + +Days-off events are **not part of this workflow**. Publisher absences always take effect immediately (otherwise conflict detection would miss them). + +Release and un-release are available: + +- **Per event** — from the event detail page's primary action, or from the row action menu on the programme list +- **In bulk** — from the sticky action bar on the programme list, once at least one draft (or one released, for un-release) is selected + +Every release, un-release, and deletion is recorded in the audit log with the actor and event ID. + ### Event Structure Editing Each event's structure (parts and service roles) can be edited independently from its template: @@ -147,10 +173,10 @@ If no responsible is set, only users with the Program Manager or Admin permissio |---|---| | Any authenticated user | View and manage their own days off; generate, copy, regenerate, and revoke their personal calendar feed | | Program Viewer | View events, programmes, and template list | -| Program Manager | Create, edit, and delete events. Assign publishers. Manage templates | +| Program Manager | Create, edit, release, un-release, and delete events. Assign publishers. Manage templates | | External Speaker Viewer | Open the external speaker registry and pick from it | | External Speaker Manager | Add, edit, archive, and unarchive external speakers | -| Template responsible | Edit events and assign publishers for their template only | +| Template responsible | Edit, release, un-release, and delete events for their template only. Assign publishers | | Admin | Everything, including creating new templates | See [Roles and Permissions](roles-and-permissions.md) for the full list of permissions across all features. diff --git a/docs/product/feature-overview.md b/docs/product/feature-overview.md index 88fb43b1..3f8bbfb6 100644 --- a/docs/product/feature-overview.md +++ b/docs/product/feature-overview.md @@ -71,8 +71,9 @@ Manage congregation meeting programmes, event scheduling, and publisher assignme - **Programme templates** — Define meeting structures (parts + service roles) with recurring weekdays. Ships with midweek, weekend, and memorial defaults - **Parallel parts (tracks)** — Mark template parts to run simultaneously in different rooms/groups (e.g., "Main hall" vs "Children"). Parts with the same order and different tracks render side-by-side in the board viewer and PDF export - **Event generation** — Auto-generate events from templates, or create one-time events from templates or freeform. Events are listed from the start of the current month onward +- **Draft and released events** — New events start as drafts so programme organizers can build the schedule privately. Drafts stay off the display board, calendar feeds, and dashboards; releasing publishes the event and sends assignment emails (with a 30-minute safety net for accidental releases). Un-releasing hides the event again and cancels not-yet-sent emails - **Publisher assignments** — Assign speakers, readers, and service roles with a dynamic info card showing availability, conflicts, and rotation history -- **Conflict detection** — Days-off conflicts block assignments in real time and retroactively flag existing ones +- **Conflict detection** — Days-off conflicts flag assignments in real time and retroactively; unresolved conflicts block the release of a draft event until fixed - **Per-template responsibility** — Delegate programme management to specific elders without granting the full Program Manager permission - **Days off** — Members record their upcoming absences so programme organizers can plan accordingly - **External speakers** — Maintain a registry of guest speakers (with congregation, phone, notes) and pick from it when assigning a programme part — kept separate from regular publishers diff --git a/docs/product/notifications.md b/docs/product/notifications.md index 6e76d350..755941ac 100644 --- a/docs/product/notifications.md +++ b/docs/product/notifications.md @@ -11,9 +11,17 @@ Unitae sends email notifications to keep congregation members informed about eve | **Document deletion** | A document is removed from the board | Members with Board Validator | Instant (cancels pending "new document" or "updated" notifications) | | **Document expiring soon** | Board documents are approaching their visibility end date | Members with Board Validator | Instant | | **Open data sync completed** | The open-data import finishes | The member who triggered the sync | Instant | +| **Programme assignment** | A publisher is assigned to a programme part or service role on a released event | The assigned publisher | 30 minutes | +| **Programme un-assignment** | A publisher is removed from a programme part or service role on a released event | The un-assigned publisher | Instant (cancels a pending "assignment" if not yet sent) | All types respect user notification preferences and can be individually toggled off. +### Draft events don't notify + +Programme events start as [drafts](events.md#draft-and-released-events). While draft, adding, editing, or removing an assignment sends **no email** — the schedule is still being built. When the programme manager releases the event, one assignment notification fires for each current assignee. + +The 30-minute debounce on assignment notifications is a **safety net** for accidental releases: a manager who releases the wrong event has 30 minutes to un-release before any email leaves. Un-releasing cancels the pending emails. Publishers who already received a notification (release + 30 min) keep it — Unitae doesn't send a follow-up "never mind" email. + ## How Debouncing Works Some notification types have a debounce window. When a triggering event occurs, the notification is not sent immediately — it is held for the configured delay. If multiple similar events happen within that window (e.g., several documents uploaded in quick succession), they are grouped into a single digest email instead of flooding the recipient's inbox. diff --git a/docs/product/roles-and-permissions.md b/docs/product/roles-and-permissions.md index e2c4150e..db974dda 100644 --- a/docs/product/roles-and-permissions.md +++ b/docs/product/roles-and-permissions.md @@ -71,7 +71,7 @@ Permissions are grouped by area of the app. Most areas come in two flavours: **V ### Programme & events - **Program Viewer** — open the events list, programmes, and templates -- **Program Manager** — create and edit events, assign publishers, manage programme templates +- **Program Manager** — create, edit, delete, release, and un-release events; assign publishers; manage programme templates - **External Speaker Viewer** — open the external speaker registry - **External Speaker Manager** — add, edit, archive speakers in the registry @@ -98,7 +98,7 @@ So to give someone access to a feature, you don't grant the permission directly ## Programme template responsible -Independently of roles, each programme template can have a **template responsible** assigned. That person can edit and assign publishers for the events that come from that template, without needing the broader Program Manager permission. Useful for delegating one programme (e.g. midweek meeting) without handing over the rest. +Independently of roles, each programme template can have a **template responsible** assigned. That person can edit, delete, release, and un-release the events that come from that template, and assign publishers to them — without needing the broader Program Manager permission. Useful for delegating one programme (e.g. midweek meeting) without handing over the rest. ## Default access diff --git a/docs/product/settings.md b/docs/product/settings.md index 9618fad2..841743de 100644 --- a/docs/product/settings.md +++ b/docs/product/settings.md @@ -65,7 +65,7 @@ Permission required: *Admin*. ## Audit log -Every meaningful change in the congregation — sign-ins, role assignments, document uploads, territory edits, exports, and so on — is recorded with the actor, the time, and the affected entity. The audit log viewer is the place to look up "who did what, when". +Every meaningful change in the congregation — sign-ins, role assignments, document uploads, territory edits, event releases and un-releases, exports, and so on — is recorded with the actor, the time, and the affected entity. The audit log viewer is the place to look up "who did what, when". The action filter groups entries by feature: territories, publishers, board, events (release / un-release / delete), settings, and so on. Permission required: *Admin*. From dc00009094868754891da5c0e1c0cc96486534a2 Mon Sep 17 00:00:00 2001 From: mindsers Date: Thu, 16 Jul 2026 11:12:00 +0200 Subject: [PATCH 10/14] fix(events): block day-off overlaps on released events again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../server/programme-assignment.policy.ts | 13 +++ .../programme-assignments.server.test.ts | 82 ++++++++++++++++++ .../server/programme-assignments.server.ts | 83 ++++++++++++++----- 3 files changed, 156 insertions(+), 22 deletions(-) diff --git a/app/features/events/server/programme-assignment.policy.ts b/app/features/events/server/programme-assignment.policy.ts index f8c3e364..9a9139cc 100644 --- a/app/features/events/server/programme-assignment.policy.ts +++ b/app/features/events/server/programme-assignment.policy.ts @@ -20,6 +20,9 @@ export const PROGRAMME_ASSIGNMENT_ERRORS = { ineligibleSpeaker: "L'orateur sélectionné ne fait pas partie des rôles autorisés pour cette partie.", ineligibleReader: 'Le deuxième orateur sélectionné ne fait pas partie des rôles autorisés pour cette partie.', ineligibleServant: 'Le proclamateur sélectionné ne fait pas partie des rôles autorisés pour ce service.', + dayOffSpeaker: 'Ce proclamateur a une absence durant cette date.', + dayOffReader: 'Le deuxième orateur a une absence durant cette date.', + dayOffServant: 'Ce proclamateur a une absence durant cette date.', } as const export type ProgrammeRoleKind = 'speaker' | 'reader' | 'servant' @@ -56,6 +59,16 @@ const INELIGIBLE_MESSAGE: Record = { servant: PROGRAMME_ASSIGNMENT_ERRORS.ineligibleServant, } +// Day-off overlap message per role. Parallel to INELIGIBLE_MESSAGE. Used +// by the writers to build a Rejection when releasing an event would silently +// schedule a publisher on top of a known absence — on drafts we save with +// hasConflict=true instead (the release-blocking policy surfaces it later). +export const DAY_OFF_MESSAGE: Record = { + speaker: PROGRAMME_ASSIGNMENT_ERRORS.dayOffSpeaker, + reader: PROGRAMME_ASSIGNMENT_ERRORS.dayOffReader, + servant: PROGRAMME_ASSIGNMENT_ERRORS.dayOffServant, +} + export function checkEligibleForRole( eligibleUserIds: number[], assigneeId: number, diff --git a/app/features/events/server/programme-assignments.server.test.ts b/app/features/events/server/programme-assignments.server.test.ts index 4afde451..b129a227 100644 --- a/app/features/events/server/programme-assignments.server.test.ts +++ b/app/features/events/server/programme-assignments.server.test.ts @@ -28,6 +28,11 @@ const { assignPart, assignServiceRole, unassignPart, unassignServiceRole, checkD const { unscopedDb: db } = await import('~/shared/infra/db.server') const allowedRoles = await import('~/features/events/server/allowed-roles.server') +// Matches the shared "absence" copy in DAY_OFF_MESSAGE without pinning the +// exact French string, so a wording tweak in the policy file doesn't force a +// test churn (the policy test already pins the exact strings). +const DAY_OFF_MESSAGE_PATTERN = /absence/i + beforeEach(() => { vi.resetAllMocks() vi.mocked(allowedRoles.getPartAssignmentAllowedRoleIds).mockResolvedValue([]) @@ -145,6 +150,54 @@ describe('assignPart', () => { expect(updateCall?.data).toMatchObject({ hasConflict: false }) }) + // Regression pin: draft events accept conflicting assignments (the schedule + // is still being built); released events must NOT — a manager scheduling + // over a known absence on a public event is silent scheduling breakage. + it('BLOCKS assignPart on a RELEASED event when the speaker has a day-off conflict', async () => { + vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + id: 1, + event: { status: 'released', startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, + } as never) + vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) // day-off found + + const result = await assignPart(db, 1, 5, null, null, 'Topic', 1) + + expect(result).toEqual({ error: expect.stringMatching(DAY_OFF_MESSAGE_PATTERN) }) + expect(db.programmePartAssignment.update).not.toHaveBeenCalled() + }) + + it('BLOCKS assignPart on a RELEASED event when the reader has a day-off conflict', async () => { + vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + id: 1, + event: { status: 'released', startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, + } as never) + vi.mocked(allowedRoles.resolveEligibleUserIds).mockResolvedValue([5, 7]) + // Speaker: no day-off. Reader: day-off found. + vi.mocked(db.event.findFirst) + .mockResolvedValueOnce(null as never) + .mockResolvedValueOnce({ id: 99 } as never) + + const result = await assignPart(db, 1, 5, 7, null, 'Topic', 1) + + expect(result).toEqual({ error: expect.stringMatching(DAY_OFF_MESSAGE_PATTERN) }) + expect(db.programmePartAssignment.update).not.toHaveBeenCalled() + }) + + it('still saves with hasConflict=true on a DRAFT event when a day-off conflict exists', async () => { + vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + id: 1, + event: { status: 'draft', startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, + } as never) + vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) + vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + + const result = await assignPart(db, 1, 5, null, null, 'Topic', 1) + + expect(result).toHaveProperty('assignment') + const updateCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + expect(updateCall?.data).toMatchObject({ hasConflict: true }) + }) + // Consumers (route + notification path) need to diff the old assignee vs // the new one to decide who to notify. Returning the previous IDs alongside // the new assignment keeps the diff logic out of the route. @@ -306,6 +359,35 @@ describe('assignServiceRole', () => { expect(updateCall?.data).toMatchObject({ hasConflict: false }) }) + // Regression pin — same rule as assignPart: released events must block. + it('BLOCKS assignServiceRole on a RELEASED event when the assignee has a day-off conflict', async () => { + vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + id: 1, + event: { status: 'released', startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, + } as never) + vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) + + const result = await assignServiceRole(db, 1, 5, 1) + + expect(result).toEqual({ error: expect.stringMatching(DAY_OFF_MESSAGE_PATTERN) }) + expect(db.programmeServiceRoleAssignment.update).not.toHaveBeenCalled() + }) + + it('still saves with hasConflict=true on a DRAFT event when a day-off conflict exists', async () => { + vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + id: 1, + event: { status: 'draft', startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, + } as never) + vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) + vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + + const result = await assignServiceRole(db, 1, 5, 1) + + expect(result).toHaveProperty('assignment') + const updateCall = vi.mocked(db.programmeServiceRoleAssignment.update).mock.calls[0][0] + expect(updateCall?.data).toMatchObject({ hasConflict: true }) + }) + it('returns the previous assigneeId on success', async () => { vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ id: 1, diff --git a/app/features/events/server/programme-assignments.server.ts b/app/features/events/server/programme-assignments.server.ts index 586f59ae..6bc7d124 100644 --- a/app/features/events/server/programme-assignments.server.ts +++ b/app/features/events/server/programme-assignments.server.ts @@ -1,4 +1,5 @@ import { EventKind } from '~/features/events/model/event-kind.type' +import { EventStatus } from '~/features/events/model/event-status.type' import { getPartAssignmentAllowedRoleIds, getServiceRoleAssignmentAllowedRoleIds, @@ -8,6 +9,7 @@ import { checkEligibleForRole, checkExternalSpeakerValid, checkParticipantsDistinct, + DAY_OFF_MESSAGE, PROGRAMME_ASSIGNMENT_ERRORS, } from '~/features/events/server/programme-assignment.policy' import type { TransactionClient } from '~/shared/infra/db.server' @@ -54,6 +56,36 @@ export function getEventProgramme(db: TransactionClient, eventId: number, congre }) } +// Runs the per-participant checks used by assignPart for both speaker and +// reader. Returns a Rejection on hard failure (ineligible role, or day-off +// on a released event) or a hasConflict flag the caller ORs together for the +// eventual `data.hasConflict` write. +async function checkPartParticipant( + db: TransactionClient, + args: { + assignmentId: number + participantId: number | null + roleKind: 'speaker' | 'reader' + event: { startDate: Date; endDate: Date } + congregationId: number + isReleased: boolean + }, +): Promise<{ error: string } | { hasConflict: boolean }> { + const { assignmentId, participantId, roleKind, event, congregationId, isReleased } = args + if (participantId == null) return { hasConflict: false } + + const allowed = await getPartAssignmentAllowedRoleIds(db, assignmentId, roleKind, congregationId) + const eligible = await resolveEligibleUserIds(db, allowed, congregationId) + const ineligible = checkEligibleForRole(eligible, participantId, roleKind) + if (ineligible) return ineligible + + if (await checkDayOffConflict(db, participantId, event.startDate, event.endDate, congregationId)) { + if (isReleased) return { error: DAY_OFF_MESSAGE[roleKind] } + return { hasConflict: true } + } + return { hasConflict: false } +} + export async function assignPart( db: TransactionClient, assignmentId: number, @@ -98,29 +130,34 @@ export async function assignPart( const notDistinct = checkParticipantsDistinct(assigneeId, assistantId) if (notDistinct) return notDistinct - let hasConflict = false - - if (assigneeId != null) { - const allowed = await getPartAssignmentAllowedRoleIds(db, assignmentId, 'speaker', congregationId) - const eligible = await resolveEligibleUserIds(db, allowed, congregationId) - const ineligibleSpeaker = checkEligibleForRole(eligible, assigneeId, 'speaker') - if (ineligibleSpeaker) return ineligibleSpeaker - // Day-off overlaps used to abort here; they now flow through as - // hasConflict=true and are surfaced by the release-blocking policy. - if (await checkDayOffConflict(db, assigneeId, existing.event.startDate, existing.event.endDate, congregationId)) { - hasConflict = true - } - } + // On a released event we still block day-off overlaps outright — silently + // scheduling a publisher on top of a known absence on a public event is + // exactly the kind of surprise we want to avoid. On a draft the manager is + // building the schedule, so we save with hasConflict=true and let the + // release-blocking policy surface it at publish time. + const isReleased = existing.event.status === EventStatus.Released + + const speakerCheck = await checkPartParticipant(db, { + assignmentId, + participantId: assigneeId, + roleKind: 'speaker', + event: existing.event, + congregationId, + isReleased, + }) + if ('error' in speakerCheck) return speakerCheck + + const readerCheck = await checkPartParticipant(db, { + assignmentId, + participantId: assistantId, + roleKind: 'reader', + event: existing.event, + congregationId, + isReleased, + }) + if ('error' in readerCheck) return readerCheck - if (assistantId != null) { - const allowed = await getPartAssignmentAllowedRoleIds(db, assignmentId, 'reader', congregationId) - const eligible = await resolveEligibleUserIds(db, allowed, congregationId) - const ineligibleReader = checkEligibleForRole(eligible, assistantId, 'reader') - if (ineligibleReader) return ineligibleReader - if (await checkDayOffConflict(db, assistantId, existing.event.startDate, existing.event.endDate, congregationId)) { - hasConflict = true - } - } + const hasConflict = speakerCheck.hasConflict || readerCheck.hasConflict const assignment = await db.programmePartAssignment.update({ where: { @@ -147,6 +184,7 @@ export async function assignServiceRole( const previousAssigneeId = existing.assigneeId + const isReleased = existing.event.status === EventStatus.Released let hasConflict = false if (assigneeId != null) { @@ -155,6 +193,7 @@ export async function assignServiceRole( const ineligible = checkEligibleForRole(eligible, assigneeId, 'servant') if (ineligible) return ineligible if (await checkDayOffConflict(db, assigneeId, existing.event.startDate, existing.event.endDate, congregationId)) { + if (isReleased) return { error: DAY_OFF_MESSAGE.servant } hasConflict = true } } From 843c0440f97283cf0c1dfd03237c9d2f7591ffe9 Mon Sep 17 00:00:00 2001 From: mindsers Date: Thu, 16 Jul 2026 11:17:37 +0200 Subject: [PATCH 11/14] fix(dashboard): show my own day-off conflict above the responsible one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../dashboard/ui/build-urgent-items.test.ts | 41 +++++++++++++++---- .../dashboard/ui/build-urgent-items.ts | 14 +++++-- docs/product/dashboard.md | 3 +- 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/app/features/dashboard/ui/build-urgent-items.test.ts b/app/features/dashboard/ui/build-urgent-items.test.ts index 26c383b2..a270f059 100644 --- a/app/features/dashboard/ui/build-urgent-items.test.ts +++ b/app/features/dashboard/ui/build-urgent-items.test.ts @@ -234,12 +234,17 @@ describe('urgentDayoffConflictItems', () => { expect(urgentDayoffConflictItems(null)).toEqual([]) }) - it('returns conflict item with priority 2 for a part assignment', () => { + // A conflict on MY own assignment is red / priority 1 — it means my + // personal calendar clashes with something I owe to the congregation, and + // sits next to the overdue-territory tier (also red / priority 1). + it('returns conflict item with priority 1 and destructive styling for a part assignment', () => { const eventStart = new Date(2026, 3, 25, 19, 0) const conflict = makeConflict(7, 'Réunion de semaine', eventStart, 'part') const items = urgentDayoffConflictItems(conflict) expect(items).toHaveLength(1) - expect(items[0].priority).toBe(2) + expect(items[0].priority).toBe(1) + expect(items[0].borderClass).toContain('destructive') + expect(items[0].iconClass).toContain('destructive') expect(items[0].to).toBe('/me/days-off') expect(items[0].label).toContain('Réunion de semaine') expect(items[0].key).toBe('dayoff-conflict-part-7') @@ -274,14 +279,18 @@ describe('urgentResponsibleConflictItems', () => { expect(urgentResponsibleConflictItems({ count: 0, absenteeNames: [], totalAbsenteesCount: 0 })).toEqual([]) }) - it('returns one item with priority 1 and a deep-link to the filtered programme list', () => { + // The manager's "someone I schedule has an absence" card is amber / + // priority 2 — one tier below the manager's OWN dayoff conflict so a + // program manager who is also on a part sees their personal clash first. + it('returns one item with priority 2 and a deep-link to the filtered programme list', () => { const items = urgentResponsibleConflictItems({ count: 2, absenteeNames: ['Marie D.', 'Jean P.'], totalAbsenteesCount: 2, }) expect(items).toHaveLength(1) - expect(items[0].priority).toBe(1) + expect(items[0].priority).toBe(2) + expect(items[0].borderClass).toContain('amber') expect(items[0].to).toBe('/programs?hasConflicts=true') expect(items[0].key).toBe('responsible-conflicts') }) @@ -371,7 +380,7 @@ describe('buildUrgentItems', () => { expect(items[1].key).toContain('territory-overdue-') }) - it('prioritizes day-off conflict (2) over service role (3)', () => { + it('prioritizes day-off conflict (1) over service role (3)', () => { const meetingDate = new Date(2026, 3, 25, 19, 0) const meeting = makeNextMeeting(meetingDate, { userPartIds: [10], @@ -384,17 +393,33 @@ describe('buildUrgentItems', () => { const conflict = makeConflict(7, 'Réunion de semaine', meetingDate) const items = buildUrgentItems(null, null, meeting, conflict, null) const priorities = items.map(i => i.priority) - expect(priorities).toEqual([0, 2, 3]) + expect(priorities).toEqual([0, 1, 3]) }) - it('includes the responsible-conflict card at priority 1 when the summary has conflicts', () => { + it('includes the responsible-conflict card at priority 2 when the summary has conflicts', () => { const items = buildUrgentItems(null, null, null, null, { count: 3, absenteeNames: ['Marie D.', 'Jean P.'], totalAbsenteesCount: 2, }) expect(items).toHaveLength(1) - expect(items[0].priority).toBe(1) + expect(items[0].priority).toBe(2) expect(items[0].key).toBe('responsible-conflicts') }) + + // A program manager with both their own overlapping absence AND someone + // else's absence to resolve should see their PERSONAL clash first — the + // responsible-conflict card is one step below in urgency. + it('shows my dayoff conflict before the responsible-conflict card when both exist', () => { + const meetingDate = new Date(2026, 3, 25, 19, 0) + const myConflict = makeConflict(7, 'Réunion de semaine', meetingDate) + const items = buildUrgentItems(null, null, null, myConflict, { + count: 2, + absenteeNames: ['Marie D.', 'Jean P.'], + totalAbsenteesCount: 2, + }) + expect(items).toHaveLength(2) + expect(items[0].key).toBe('dayoff-conflict-part-7') + expect(items[1].key).toBe('responsible-conflicts') + }) }) diff --git a/app/features/dashboard/ui/build-urgent-items.ts b/app/features/dashboard/ui/build-urgent-items.ts index b4a898df..31ac4537 100644 --- a/app/features/dashboard/ui/build-urgent-items.ts +++ b/app/features/dashboard/ui/build-urgent-items.ts @@ -97,6 +97,10 @@ export function urgentServiceRoleItems(nextMeeting: NextMeeting): UrgentItem[] { ] } +// A conflict on MY OWN assignment is red / priority 1 (same tier as an +// overdue territory): my personal calendar clashes with something I owe +// the congregation. Sits above the responsible-conflict card so a program +// manager who is themselves scheduled sees the personal clash first. export function urgentDayoffConflictItems(conflict: DayoffConflict): UrgentItem[] { if (!conflict) return [] @@ -106,10 +110,10 @@ export function urgentDayoffConflictItems(conflict: DayoffConflict): UrgentItem[ label: m.dashboard_urgent_dayoff_conflict({ eventName: conflict.eventName }), to: '/me/days-off', icon: CalendarOff, - borderClass: 'border-l-amber-500 bg-amber-500/5', - iconClass: 'text-amber-600 dark:text-amber-400', + borderClass: 'border-l-destructive bg-destructive/5', + iconClass: 'text-destructive', relativeDate: conflict.eventStartDate, - priority: 2, + priority: 1, }, ] } @@ -149,7 +153,9 @@ export function urgentResponsibleConflictItems(summary: ResponsibleConflictsSumm icon: AlertTriangle, borderClass: 'border-l-amber-500 bg-amber-500/5', iconClass: 'text-amber-600 dark:text-amber-400', - priority: 1, + // One tier below the user's own dayoff conflict — a manager scheduled + // on a part they cannot attend sees their personal clash first. + priority: 2, }, ] } diff --git a/docs/product/dashboard.md b/docs/product/dashboard.md index 288ce57b..ba56c33d 100644 --- a/docs/product/dashboard.md +++ b/docs/product/dashboard.md @@ -38,7 +38,8 @@ A conditional section that surfaces time-sensitive items from across features. I |---|---|---|---| | 0 | Imminent part assignment | User has a programme part and the meeting is within 3 days | The board | | 1 | Overdue territory | Territory due date is in the past | The territory page | -| 2 | Day-off conflict | An upcoming absence overlaps the next meeting where the user has assignments — only released events count; draft-event conflicts are not urgent and surface at release time on the programme list | The absences page | +| 1 | Day-off conflict on my own assignment | The user has an upcoming absence overlapping an event where *they* are assigned. Shown red, at the same tier as an overdue territory — a personal clash the user needs to resolve first. Only released events count; draft-event conflicts surface at release time on the programme list | The absences page | +| 2 | Responsible-conflict card | For programme managers / template responsibles: at least one publisher scheduled on a programme they manage has an overlapping absence. Amber. Sits one tier below the user's own day-off clash so a manager scheduled on a part sees their personal conflict first | The programme list filtered on conflicts | | 3 | Imminent service role | User has a service role and the meeting is within 3 days | The board | | 4 | Due-soon territory | Territory due date is within 2 weeks | The territory page | | 5 | Unread documents | At least 1 visible document not yet viewed | The board | From 1f72ea16c9e2a76b2099b95a6b01d3a3e001febf Mon Sep 17 00:00:00 2001 From: mindsers Date: Thu, 16 Jul 2026 11:25:34 +0200 Subject: [PATCH 12/14] 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. --- app/features/dashboard/ui/build-urgent-items.test.ts | 10 ++++++---- app/features/dashboard/ui/build-urgent-items.ts | 2 +- docs/product/dashboard.md | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/features/dashboard/ui/build-urgent-items.test.ts b/app/features/dashboard/ui/build-urgent-items.test.ts index a270f059..868c4245 100644 --- a/app/features/dashboard/ui/build-urgent-items.test.ts +++ b/app/features/dashboard/ui/build-urgent-items.test.ts @@ -356,15 +356,17 @@ describe('buildUrgentItems', () => { expect(items[0].priority).toBeLessThan(items[1].priority) }) - it('caps at 3 items maximum', () => { + it('caps at 5 items maximum', () => { const territories = [ makeTerritory(1, 'T-1', 'overdue', new Date(2026, 3, 20)), makeTerritory(2, 'T-2', 'overdue', new Date(2026, 3, 19)), - makeTerritory(3, 'T-3', 'due-soon', new Date(2026, 4, 1)), - makeTerritory(4, 'T-4', 'due-soon', new Date(2026, 4, 2)), + makeTerritory(3, 'T-3', 'overdue', new Date(2026, 3, 18)), + makeTerritory(4, 'T-4', 'due-soon', new Date(2026, 4, 1)), + makeTerritory(5, 'T-5', 'due-soon', new Date(2026, 4, 2)), + makeTerritory(6, 'T-6', 'due-soon', new Date(2026, 4, 3)), ] const items = buildUrgentItems(territories, 10, null, null, null) - expect(items).toHaveLength(3) + expect(items).toHaveLength(5) }) it('prioritizes part assignment (0) over overdue territory (1)', () => { diff --git a/app/features/dashboard/ui/build-urgent-items.ts b/app/features/dashboard/ui/build-urgent-items.ts index 31ac4537..7d395051 100644 --- a/app/features/dashboard/ui/build-urgent-items.ts +++ b/app/features/dashboard/ui/build-urgent-items.ts @@ -176,5 +176,5 @@ export function buildUrgentItems( ...urgentDocumentsItem(unreadDocumentCount), ] items.sort((a, b) => a.priority - b.priority) - return items.slice(0, 3) + return items.slice(0, 5) } diff --git a/docs/product/dashboard.md b/docs/product/dashboard.md index ba56c33d..de1ee5a1 100644 --- a/docs/product/dashboard.md +++ b/docs/product/dashboard.md @@ -32,7 +32,7 @@ Each step is checked off as soon as the congregation has at least one of that it ## Urgent Strip -A conditional section that surfaces time-sensitive items from across features. It only appears when at least one item qualifies — when everything is fine, the section is hidden entirely. Up to 3 items are shown, sorted by priority: +A conditional section that surfaces time-sensitive items from across features. It only appears when at least one item qualifies — when everything is fine, the section is hidden entirely. Up to 5 items are shown, sorted by priority: | Priority | Type | Condition | Link | |---|---|---|---| From 8dd077dbc5d0de5f39c898d9112c1d83e1e358cc Mon Sep 17 00:00:00 2001 From: mindsers Date: Thu, 16 Jul 2026 11:39:01 +0200 Subject: [PATCH 13/14] feat(dashboard): reword urgent-strip messages for clarity and warmth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- app/i18n/messages/en.json | 14 +++++++------- app/i18n/messages/fr.json | 14 +++++++------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/i18n/messages/en.json b/app/i18n/messages/en.json index e10cd42f..ba2c1160 100644 --- a/app/i18n/messages/en.json +++ b/app/i18n/messages/en.json @@ -1313,8 +1313,8 @@ "days_off_conflict_modal_row_named": "{assignment} on {date} — notify {responsible}", "days_off_conflict_modal_row_fallback": "{assignment} on {date} — notify the programme responsible", "days_off_conflict_modal_button": "Got it, I'll notify them", - "dashboard_urgent_responsible_conflict_singular": "1 assignment conflict: {names}", - "dashboard_urgent_responsible_conflict_plural": "{count} assignment conflicts: {names}", + "dashboard_urgent_responsible_conflict_singular": "Absence conflict to resolve: {names}", + "dashboard_urgent_responsible_conflict_plural": "{count} absence conflicts to resolve: {names}", "dashboard_urgent_responsible_conflict_extras": " (+{count} more)", "programs_event_conflict_badge_singular": "Conflict", "programs_event_conflict_badge_plural": "{count} conflicts", @@ -2442,11 +2442,11 @@ "dashboard_greeting_hello": "Hello,", "dashboard_urgent_section_title": "Needs attention", "dashboard_urgent_territory_overdue": "Territory {number} — overdue", - "dashboard_urgent_territory_due_soon": "Territory {number} — due soon", - "dashboard_urgent_assignment_soon": "{name} — {eventName}", - "dashboard_urgent_service_role_soon": "{name} — {eventName}", - "dashboard_urgent_dayoff_conflict": "Absence conflicts with {eventName}", - "dashboard_urgent_unread_documents": "{count} unread documents", + "dashboard_urgent_territory_due_soon": "Territory {number} — return soon", + "dashboard_urgent_assignment_soon": "You're assigned to «{name}» — {eventName}", + "dashboard_urgent_service_role_soon": "You'll soon cover «{name}» — {eventName}", + "dashboard_urgent_dayoff_conflict": "You're assigned to {eventName} during an absence", + "dashboard_urgent_unread_documents": "{count} unread documents on the board", "dashboard_quick_action_plan_absence": "Plan an absence", "dashboard_quick_action_assign_territory": "Assign territory", "dashboard_next_meeting": "Next meeting", diff --git a/app/i18n/messages/fr.json b/app/i18n/messages/fr.json index 74eaeb47..86b06538 100644 --- a/app/i18n/messages/fr.json +++ b/app/i18n/messages/fr.json @@ -1315,8 +1315,8 @@ "days_off_conflict_modal_row_named": "{assignment} le {date} — prévenir {responsible}", "days_off_conflict_modal_row_fallback": "{assignment} le {date} — prévenir le responsable du programme", "days_off_conflict_modal_button": "J'ai compris, je les préviens", - "dashboard_urgent_responsible_conflict_singular": "1 conflit d'affectation : {names}", - "dashboard_urgent_responsible_conflict_plural": "{count} conflits d'affectation : {names}", + "dashboard_urgent_responsible_conflict_singular": "Conflit d'absence à résoudre : {names}", + "dashboard_urgent_responsible_conflict_plural": "{count} conflits d'absence à résoudre : {names}", "dashboard_urgent_responsible_conflict_extras": " (+{count} autres)", "programs_event_conflict_badge_singular": "Conflit", "programs_event_conflict_badge_plural": "{count} conflits", @@ -2445,11 +2445,11 @@ "dashboard_greeting_hello": "Bonjour,", "dashboard_urgent_section_title": "À traiter", "dashboard_urgent_territory_overdue": "Territoire {number} — en retard", - "dashboard_urgent_territory_due_soon": "Territoire {number} — échéance proche", - "dashboard_urgent_assignment_soon": "{name} — {eventName}", - "dashboard_urgent_service_role_soon": "{name} — {eventName}", - "dashboard_urgent_dayoff_conflict": "Absence en conflit avec {eventName}", - "dashboard_urgent_unread_documents": "{count} documents non lus", + "dashboard_urgent_territory_due_soon": "Territoire {number} — à rendre bientôt", + "dashboard_urgent_assignment_soon": "Tu es assigné à « {name} » — {eventName}", + "dashboard_urgent_service_role_soon": "Tu dois bientôt assurer « {name} » — {eventName}", + "dashboard_urgent_dayoff_conflict": "Tu es assigné à {eventName} pendant une absence", + "dashboard_urgent_unread_documents": "{count} documents non lus au tableau", "dashboard_quick_action_plan_absence": "Saisir une absence", "dashboard_quick_action_assign_territory": "Attribuer un territoire", "dashboard_next_meeting": "Prochaine réunion", From a15ce5c2b2f0be4597fb6f0a7f14f0ba765af422 Mon Sep 17 00:00:00 2001 From: mindsers Date: Thu, 16 Jul 2026 11:43:00 +0200 Subject: [PATCH 14/14] feat(dashboard): show the assignment name on my day-off conflict row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../server/dashboard.integration.test.ts | 8 +++---- .../dashboard/server/dashboard.server.ts | 13 ++++++---- .../dashboard/ui/build-urgent-items.test.ts | 24 +++++++++++-------- .../dashboard/ui/build-urgent-items.ts | 2 +- app/i18n/messages/en.json | 2 +- app/i18n/messages/fr.json | 2 +- 6 files changed, 30 insertions(+), 21 deletions(-) diff --git a/app/features/dashboard/server/dashboard.integration.test.ts b/app/features/dashboard/server/dashboard.integration.test.ts index 962e37eb..403f8583 100644 --- a/app/features/dashboard/server/dashboard.integration.test.ts +++ b/app/features/dashboard/server/dashboard.integration.test.ts @@ -408,7 +408,7 @@ describe('getConflictingAssignments (integration)', () => { expect(result).not.toBeNull() expect(result?.kind).toBe('part') expect(result?.id).toBe(seeded.partIds[0]) - expect(result?.eventName).toBe('Assignee Conflict') + expect(result?.name).toBe('Discours') } finally { await cleanupEvent(seeded.eventId) } @@ -439,7 +439,7 @@ describe('getConflictingAssignments (integration)', () => { const result = await withScope(congregationId, tx => getConflictingAssignments(tx, aliceId)) expect(result?.kind).toBe('service-role') expect(result?.id).toBe(seeded.serviceRoleIds[0]) - expect(result?.eventName).toBe('Service Role Conflict') + expect(result?.name).toBe('Son') } finally { await cleanupEvent(seeded.eventId) } @@ -454,12 +454,12 @@ describe('getConflictingAssignments (integration)', () => { const earlier = await seedEvent({ name: 'Earlier Service Role', startDate: new Date('2027-09-01T19:00:00Z'), - serviceRoles: [{ assigneeId: aliceId, hasConflict: true }], + serviceRoles: [{ assigneeId: aliceId, hasConflict: true, name: 'Early Sound' }], }) try { const result = await withScope(congregationId, tx => getConflictingAssignments(tx, aliceId)) expect(result?.kind).toBe('service-role') - expect(result?.eventName).toBe('Earlier Service Role') + expect(result?.name).toBe('Early Sound') expect(result?.id).toBe(earlier.serviceRoleIds[0]) } finally { await cleanupEvent(earlier.eventId) diff --git a/app/features/dashboard/server/dashboard.server.ts b/app/features/dashboard/server/dashboard.server.ts index cfcb9744..71b030a3 100644 --- a/app/features/dashboard/server/dashboard.server.ts +++ b/app/features/dashboard/server/dashboard.server.ts @@ -265,7 +265,8 @@ export async function getConflictingAssignments(db: TransactionClient, userId: n }, select: { id: true, - event: { select: { name: true, startDate: true } }, + name: true, + event: { select: { startDate: true } }, }, orderBy: { event: { startDate: 'asc' } }, take: 1, @@ -278,24 +279,28 @@ export async function getConflictingAssignments(db: TransactionClient, userId: n }, select: { id: true, - event: { select: { name: true, startDate: true } }, + name: true, + event: { select: { startDate: true } }, }, orderBy: { event: { startDate: 'asc' } }, take: 1, }), ]) + // We surface the assignment's own name ("Discours public", "Son", …), not + // the parent event's name ("Réunion de semaine" — repeats every week and + // doesn't identify which part is actually clashing with the absence). const candidates = [ ...partConflicts.map(c => ({ kind: 'part' as const, id: c.id, - eventName: c.event.name, + name: c.name, eventStartDate: c.event.startDate, })), ...serviceConflicts.map(c => ({ kind: 'service-role' as const, id: c.id, - eventName: c.event.name, + name: c.name, eventStartDate: c.event.startDate, })), ] diff --git a/app/features/dashboard/ui/build-urgent-items.test.ts b/app/features/dashboard/ui/build-urgent-items.test.ts index 868c4245..a977d5f5 100644 --- a/app/features/dashboard/ui/build-urgent-items.test.ts +++ b/app/features/dashboard/ui/build-urgent-items.test.ts @@ -8,7 +8,7 @@ vi.mock('~/i18n/paraglide/messages', () => ({ `${name} — ${eventName}`, dashboard_urgent_service_role_soon: ({ name, eventName }: { name: string; eventName: string }) => `${name} — ${eventName}`, - dashboard_urgent_dayoff_conflict: ({ eventName }: { eventName: string }) => `Conflict with ${eventName}`, + dashboard_urgent_dayoff_conflict: ({ name }: { name: string }) => `Conflict with ${name}`, dashboard_urgent_unread_documents: ({ count }: { count: string }) => `${count} unread documents`, dashboard_urgent_responsible_conflict_singular: ({ names }: { names: string }) => `1 responsible conflict: ${names}`, dashboard_urgent_responsible_conflict_plural: ({ count, names }: { count: string; names: string }) => @@ -77,8 +77,8 @@ function makeNextMeeting( } } -function makeConflict(id: number, eventName: string, eventStartDate: Date, kind: 'part' | 'service-role' = 'part') { - return { kind, id, eventName, eventStartDate } +function makeConflict(id: number, name: string, eventStartDate: Date, kind: 'part' | 'service-role' = 'part') { + return { kind, id, name, eventStartDate } } // --- urgentTerritoriesItems --- @@ -237,32 +237,36 @@ describe('urgentDayoffConflictItems', () => { // A conflict on MY own assignment is red / priority 1 — it means my // personal calendar clashes with something I owe to the congregation, and // sits next to the overdue-territory tier (also red / priority 1). + // The label surfaces the assignment name (e.g. "Discours public"), NOT + // the event name — the event name is generic and repeats every week + // ("Réunion de semaine"), whereas the assignment name uniquely identifies + // which part/role is clashing with the user's absence. it('returns conflict item with priority 1 and destructive styling for a part assignment', () => { const eventStart = new Date(2026, 3, 25, 19, 0) - const conflict = makeConflict(7, 'Réunion de semaine', eventStart, 'part') + const conflict = makeConflict(7, 'Discours public', eventStart, 'part') const items = urgentDayoffConflictItems(conflict) expect(items).toHaveLength(1) expect(items[0].priority).toBe(1) expect(items[0].borderClass).toContain('destructive') expect(items[0].iconClass).toContain('destructive') expect(items[0].to).toBe('/me/days-off') - expect(items[0].label).toContain('Réunion de semaine') + expect(items[0].label).toContain('Discours public') expect(items[0].key).toBe('dayoff-conflict-part-7') // The relative date must reflect the conflicting event, not the next meeting expect(items[0].relativeDate).toBe(eventStart) }) it('returns conflict item for a service role assignment', () => { - const conflict = makeConflict(5, 'Réunion publique', new Date(2026, 5, 1, 9, 30), 'service-role') + const conflict = makeConflict(5, 'Son', new Date(2026, 5, 1, 9, 30), 'service-role') const items = urgentDayoffConflictItems(conflict) expect(items).toHaveLength(1) expect(items[0].key).toBe('dayoff-conflict-service-role-5') - expect(items[0].label).toContain('Réunion publique') + expect(items[0].label).toContain('Son') }) it('surfaces conflicts well beyond the next meeting horizon', () => { // Two months out — old behaviour ignored anything past the next meeting - const conflict = makeConflict(99, 'Assemblée régionale', new Date(2026, 5, 24, 9, 0)) + const conflict = makeConflict(99, 'Discours public', new Date(2026, 5, 24, 9, 0)) const items = urgentDayoffConflictItems(conflict) expect(items).toHaveLength(1) }) @@ -392,7 +396,7 @@ describe('buildUrgentItems', () => { ], serviceRoleAssignments: [{ id: 5, name: 'Son', assignee: null }], }) - const conflict = makeConflict(7, 'Réunion de semaine', meetingDate) + const conflict = makeConflict(7, 'Discours public', meetingDate) const items = buildUrgentItems(null, null, meeting, conflict, null) const priorities = items.map(i => i.priority) expect(priorities).toEqual([0, 1, 3]) @@ -414,7 +418,7 @@ describe('buildUrgentItems', () => { // responsible-conflict card is one step below in urgency. it('shows my dayoff conflict before the responsible-conflict card when both exist', () => { const meetingDate = new Date(2026, 3, 25, 19, 0) - const myConflict = makeConflict(7, 'Réunion de semaine', meetingDate) + const myConflict = makeConflict(7, 'Discours public', meetingDate) const items = buildUrgentItems(null, null, null, myConflict, { count: 2, absenteeNames: ['Marie D.', 'Jean P.'], diff --git a/app/features/dashboard/ui/build-urgent-items.ts b/app/features/dashboard/ui/build-urgent-items.ts index 7d395051..6681e5b1 100644 --- a/app/features/dashboard/ui/build-urgent-items.ts +++ b/app/features/dashboard/ui/build-urgent-items.ts @@ -107,7 +107,7 @@ export function urgentDayoffConflictItems(conflict: DayoffConflict): UrgentItem[ return [ { key: `dayoff-conflict-${conflict.kind}-${conflict.id}`, - label: m.dashboard_urgent_dayoff_conflict({ eventName: conflict.eventName }), + label: m.dashboard_urgent_dayoff_conflict({ name: conflict.name }), to: '/me/days-off', icon: CalendarOff, borderClass: 'border-l-destructive bg-destructive/5', diff --git a/app/i18n/messages/en.json b/app/i18n/messages/en.json index ba2c1160..c4ef0233 100644 --- a/app/i18n/messages/en.json +++ b/app/i18n/messages/en.json @@ -2445,7 +2445,7 @@ "dashboard_urgent_territory_due_soon": "Territory {number} — return soon", "dashboard_urgent_assignment_soon": "You're assigned to «{name}» — {eventName}", "dashboard_urgent_service_role_soon": "You'll soon cover «{name}» — {eventName}", - "dashboard_urgent_dayoff_conflict": "You're assigned to {eventName} during an absence", + "dashboard_urgent_dayoff_conflict": "You're assigned to «{name}» during an absence", "dashboard_urgent_unread_documents": "{count} unread documents on the board", "dashboard_quick_action_plan_absence": "Plan an absence", "dashboard_quick_action_assign_territory": "Assign territory", diff --git a/app/i18n/messages/fr.json b/app/i18n/messages/fr.json index 86b06538..0d2036bf 100644 --- a/app/i18n/messages/fr.json +++ b/app/i18n/messages/fr.json @@ -2448,7 +2448,7 @@ "dashboard_urgent_territory_due_soon": "Territoire {number} — à rendre bientôt", "dashboard_urgent_assignment_soon": "Tu es assigné à « {name} » — {eventName}", "dashboard_urgent_service_role_soon": "Tu dois bientôt assurer « {name} » — {eventName}", - "dashboard_urgent_dayoff_conflict": "Tu es assigné à {eventName} pendant une absence", + "dashboard_urgent_dayoff_conflict": "Tu es assigné à « {name} » pendant une absence", "dashboard_urgent_unread_documents": "{count} documents non lus au tableau", "dashboard_quick_action_plan_absence": "Saisir une absence", "dashboard_quick_action_assign_territory": "Attribuer un territoire",