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..403f8583 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( @@ -401,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) } @@ -432,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) } @@ -447,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) @@ -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..2deca9b0 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') @@ -220,6 +229,33 @@ describe('getNextMeeting', () => { expect(result?.userPartIds).toEqual([]) expect(result?.userServiceRoleIds).toEqual([]) }) + + // The dashboard is publisher-facing. Drafts must not surface — same + // rationale as the other dashboard queries in this file. + it('filters to status=released', async () => { + vi.mocked(db.event.findFirst).mockResolvedValue(null as never) + + await getNextMeeting(db, 42) + + const call = vi.mocked(db.event.findFirst).mock.calls[0][0] + const where = call?.where as Record + expect(where.status).toBe('released') + }) + + // Same Prisma inner-join trap as refreshConflictFlags: `kind: { key: { not + // 'off' } }` silently excludes events with a null kindId, which seeded + // templates produce. Must use NOT: { kind: { key } } so null-kind rows + // stay in the result. + it('uses NOT: { kind: { key } } so null-kind events are not silently dropped', async () => { + vi.mocked(db.event.findFirst).mockResolvedValue(null as never) + + await getNextMeeting(db, 42) + + const call = vi.mocked(db.event.findFirst).mock.calls[0][0] + const where = call?.where as Record + expect(where.NOT).toEqual({ kind: { key: 'off' } }) + expect(where).not.toHaveProperty('kind') + }) }) // --- getUpcomingAbsences --- @@ -262,3 +298,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..71b030a3 100644 --- a/app/features/dashboard/server/dashboard.server.ts +++ b/app/features/dashboard/server/dashboard.server.ts @@ -1,5 +1,5 @@ // Intentional cross-feature import: dashboard aggregates data from events and the board for the overview -import { EventKind } from '~/features/events' +import { EventKind, EventStatus } from '~/features/events' import { getNextDaysOffs } from '~/features/events/index.server' import { resolveEffectiveRoleIds } from '~/shared/auth/permissions.server' import { TWO_WEEKS_MS } from '~/shared/constants/limits' @@ -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: EventStatus.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: EventStatus.Released }, }, select: { id: true, @@ -256,11 +258,15 @@ 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: EventStatus.Released }, }, select: { id: true, - event: { select: { name: true, startDate: true } }, + name: true, + event: { select: { startDate: true } }, }, orderBy: { event: { startDate: 'asc' } }, take: 1, @@ -269,28 +275,32 @@ export async function getConflictingAssignments(db: TransactionClient, userId: n where: { hasConflict: true, assigneeId: userId, - event: { startDate: { gte: now } }, + event: { startDate: { gte: now }, status: EventStatus.Released }, }, 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, })), ] @@ -305,7 +315,12 @@ export async function getNextMeeting(db: TransactionClient, userId: number) { const event = await db.event.findFirst({ where: { startDate: { gte: now }, - kind: { key: { not: EventKind.Off } }, + // NOT: { kind: {...} } instead of kind: { key: { not } } — the second + // form inner-joins through kind and silently drops null-kind rows, + // which seeded templates produce. + NOT: { kind: { key: EventKind.Off } }, + // Publisher-facing dashboard — drafts must stay hidden. + status: EventStatus.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..26573c92 100644 --- a/app/features/dashboard/server/get-responsible-conflicts.server.ts +++ b/app/features/dashboard/server/get-responsible-conflicts.server.ts @@ -1,3 +1,4 @@ +import { EventStatus } from '~/features/events/model/event-status.type' import type { TransactionClient } from '~/shared/infra/db.server' import { fullName } from '~/shared/utils/display-name' @@ -27,9 +28,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: EventStatus.Released } + : { startDate: { gte: now }, status: EventStatus.Released, template: { responsibles: { some: { userId } } } } const [partRows, serviceRows] = await Promise.all([ db.programmePartAssignment.findMany({ diff --git a/app/features/dashboard/ui/build-urgent-items.test.ts b/app/features/dashboard/ui/build-urgent-items.test.ts index 26c383b2..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 --- @@ -234,30 +234,39 @@ 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). + // 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(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].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) }) @@ -274,14 +283,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') }) @@ -347,15 +360,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)', () => { @@ -371,7 +386,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], @@ -381,20 +396,36 @@ 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, 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, 'Discours public', 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..6681e5b1 100644 --- a/app/features/dashboard/ui/build-urgent-items.ts +++ b/app/features/dashboard/ui/build-urgent-items.ts @@ -97,19 +97,23 @@ 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 [] 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-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, }, ] } @@ -170,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/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..49aadfd1 100644 --- a/app/features/display-board/server/dynamic-documents.server.ts +++ b/app/features/display-board/server/dynamic-documents.server.ts @@ -4,6 +4,9 @@ import { type ProgrammeDynamicConfig, parseProgrammeConfig, } from '~/features/display-board/model/dynamic-document.type' +// Cross-feature import via the events barrel (deep-importing another +// feature's model directly is forbidden by the boundaries lint rule). +import { EventStatus } from '~/features/events' import type { TransactionClient } from '~/shared/infra/db.server' import { PublisherType } from '~/shared/types/publisher-type' @@ -120,12 +123,20 @@ 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: EventStatus.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: EventStatus.Released }, + }, orderBy: { updatedAt: 'desc' }, select: { updatedAt: true }, }), @@ -137,14 +148,19 @@ 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: EventStatus.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: EventStatus.Released }, }, orderBy: { updatedAt: 'desc' }, select: { updatedAt: true }, @@ -199,7 +215,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: EventStatus.Released }, orderBy: { startDate: 'asc' }, select: { startDate: true }, }) @@ -326,6 +342,7 @@ function fetchProgrammeByIds( congregationId, templateId: { in: templateIds }, startDate: { gte: fromDate }, + status: EventStatus.Released, }, include: { template: true, @@ -356,6 +373,7 @@ function fetchProgrammeByKey( congregationId, template: { key: templateKey }, startDate: { gte: fromDate }, + status: EventStatus.Released, }, include: { partAssignments: { diff --git a/app/features/events/index.ts b/app/features/events/index.ts index 37cdcaaf..cab721ed 100644 --- a/app/features/events/index.ts +++ b/app/features/events/index.ts @@ -2,6 +2,7 @@ export { dayLabel, dayLabelShort } from './model/day-label' export { EventKind } from './model/event-kind.type' +export { EventStatus } from './model/event-status.type' export { groupPartsBySlot } from './model/group-parts-by-slot' export { InlineDeleteDialog } from './ui/InlineDeleteDialog' export { PartEditSheet } from './ui/PartEditSheet' diff --git a/app/features/events/model/event-status.type.ts b/app/features/events/model/event-status.type.ts new file mode 100644 index 00000000..e6382a38 --- /dev/null +++ b/app/features/events/model/event-status.type.ts @@ -0,0 +1,14 @@ +// Publish workflow state on Event. Draft events are the manager's scratch +// space — invisible to the display board, notifications, publisher-facing +// dashboards, and conflict queries. Released events are public. See +// releaseEvent / unreleaseEvent for the transitions. +// +// Centralised here so the two literal strings stop drifting across dashboard, +// events, display-board, and notify-assignment. + +export const EventStatus = { + Draft: 'draft', + Released: 'released', +} as const + +export type EventStatus = (typeof EventStatus)[keyof typeof EventStatus] 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-delete.tsx b/app/features/events/routes/programs/bulk-delete.tsx index 12a74da2..c6557b2c 100644 --- a/app/features/events/routes/programs/bulk-delete.tsx +++ b/app/features/events/routes/programs/bulk-delete.tsx @@ -1,9 +1,10 @@ import { redirect } from 'react-router' -import { canManageAnyProgram, getResponsibleTemplateIds } from '~/features/events/server/programme-auth.server' +import { bulkEventIdsSchema } from '~/features/events/schemas/bulk-event-ids.schema' +import { canManageAnyProgram, filterToManageableEventIds } from '~/features/events/server/programme-auth.server' import { bulkDeleteEvents } from '~/features/events/server/programme-events.server' 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 { Permission } from '~/shared/types/permission' import type { Route } from './+types/bulk-delete' @@ -14,34 +15,26 @@ export function loader(_args: Route.LoaderArgs) { export async function action({ request, context }: Route.ActionArgs) { 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 } + const payload = bulkEventIdsSchema.safeParse(await request.json()) + if (!payload.success) { + logger.warn( + `Bulk delete rejected — invalid payload. User: ${currentUser.id}. Issues: ${payload.error.issues.map(i => i.message).join('; ')}`, + ) + return { ok: false, error: 'invalid_payload' as const } } + const { ids } = payload.data 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) - } - + const allowedIds = await filterToManageableEventIds(db, can, ids, currentUser.id, congregationId) if (allowedIds.length === 0) return { ok: true, deleted: 0 } - await bulkDeleteEvents(db, allowedIds, congregationId) - logger.info(`Bulk deleted ${allowedIds.length} events.`) - return { ok: true, deleted: allowedIds.length } + const { count } = await bulkDeleteEvents(db, allowedIds, congregationId, currentUser.id) + logger.info(`Bulk deleted ${count} events.`) + return { ok: true, deleted: count } }) } 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..6abd3dd5 --- /dev/null +++ b/app/features/events/routes/programs/bulk-release.tsx @@ -0,0 +1,73 @@ +import { data, redirect } from 'react-router' +import { commitSession, getSession } from '~/features/authentication/index.server' +import { bulkEventIdsSchema } from '~/features/events/schemas/bulk-event-ids.schema' +import { bulkReleaseEvents } from '~/features/events/server/event-status-bulk.server' +import { canManageAnyProgram, filterToManageableEventIds } 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 { joinMessages } from '~/shared/utils/join-messages' + +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 payload = bulkEventIdsSchema.safeParse(await request.json()) + if (!payload.success) { + logger.warn( + `Bulk release rejected — invalid payload. User: ${currentUser.id}. Issues: ${payload.error.issues.map(i => i.message).join('; ')}`, + ) + return { ok: false, error: 'invalid_payload' as const } + } + const { ids } = payload.data + + // Phase 1: authorise + filter in a tiny scoped tx. This part is bounded + // (single findMany, single check) and fits comfortably in the default + // Prisma budget. + const { congregationId } = currentUser + const allowedIds = await withScopeFromContext(context, async db => { + const can = (p: Permission) => permissions.has(p) + if (!(await canManageAnyProgram(db, can, currentUser.id, congregationId))) throw redirect('/programs') + return filterToManageableEventIds(db, can, ids, currentUser.id, congregationId) + }) + + // Phase 2: per-event scoped release. Each event opens its own withScope + // inside bulkReleaseEvents so a slow/failing event only rolls back itself, + // and partial progress is preserved on batch failure. + const { released, blocked, notFound, failed } = await bulkReleaseEvents(allowedIds, congregationId, currentUser.id, { + locale: cong.locale, + timezone: cong.timezone, + }) + logger.info( + `Bulk released ${released.length} events; ${blocked.length} blocked; ${notFound.length} not found; ${failed.length} failed.`, + ) + + // 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 })) + 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 new file mode 100644 index 00000000..d388b646 --- /dev/null +++ b/app/features/events/routes/programs/bulk-unrelease.tsx @@ -0,0 +1,58 @@ +import { data, redirect } from 'react-router' +import { commitSession, getSession } from '~/features/authentication/index.server' +import { bulkEventIdsSchema } from '~/features/events/schemas/bulk-event-ids.schema' +import { bulkUnreleaseEvents } from '~/features/events/server/event-status-bulk.server' +import { canManageAnyProgram, filterToManageableEventIds } 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 { joinMessages } from '~/shared/utils/join-messages' + +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 payload = bulkEventIdsSchema.safeParse(await request.json()) + if (!payload.success) { + logger.warn( + `Bulk unrelease rejected — invalid payload. User: ${currentUser.id}. Issues: ${payload.error.issues.map(i => i.message).join('; ')}`, + ) + return { ok: false, error: 'invalid_payload' as const } + } + const { ids } = payload.data + + // Phase 1: authorise + filter in a tiny scoped tx. + const { congregationId } = currentUser + const allowedIds = await withScopeFromContext(context, async db => { + const can = (p: Permission) => permissions.has(p) + if (!(await canManageAnyProgram(db, can, currentUser.id, congregationId))) throw redirect('/programs') + return filterToManageableEventIds(db, can, ids, currentUser.id, congregationId) + }) + + // Phase 2: per-event scoped unrelease. See bulk-release.tsx for the + // partial-progress rationale. + 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 })) + } + 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/routes/programs/events/release.tsx b/app/features/events/routes/programs/events/release.tsx new file mode 100644 index 00000000..e868292d --- /dev/null +++ b/app/features/events/routes/programs/events/release.tsx @@ -0,0 +1,59 @@ +import { data, redirect } from 'react-router' +import { commitSession, getSession } from '~/features/authentication/index.server' +import { fireReleaseNotifications, 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') + const { congregationId } = currentUser + + // Phase 1: auth + release (state flip + audit) in one scoped tx. + // releaseEvent returns notifyTargets — notifications fire in Phase 2 OUTSIDE + // this tx so a queue/Postgres error inside a notify cannot poison it. + const result = await withScopeFromContext(context, async db => { + 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') + } + return releaseEvent(db, eventId, congregationId, currentUser.id) + }) + 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) } }) + } + + // Phase 2: notifications, outside the release tx. + await fireReleaseNotifications(result.event, result.notifyTargets, congregationId, currentUser.id, { + locale: cong.locale, + timezone: cong.timezone, + }) + + 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..1a8b2ab5 100644 --- a/app/features/events/routes/programs/events/view.tsx +++ b/app/features/events/routes/programs/events/view.tsx @@ -1,6 +1,18 @@ -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 { EventStatus } from '~/features/events/model/event-status.type' import { getPartAssignmentAllowedRoleIds, getServiceRoleAssignmentAllowedRoleIds, @@ -217,12 +229,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..848619ab 100644 --- a/app/features/events/routes/programs/list.tsx +++ b/app/features/events/routes/programs/list.tsx @@ -3,14 +3,18 @@ import { CalendarOff, ChevronRight, FileDown, + FileText, Loader2, MoreHorizontal, + Send, Trash2, UserCog, } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import { Link, redirect, useFetcher } from 'react-router' +import { toast } from 'sonner' import { EventKind } from '~/features/events/model/event-kind.type' +import { EventStatus } from '~/features/events/model/event-status.type' import { computeFilters, getDefaultDateRange } from '~/features/events/server/event-filters.server' import { getResponsibleTemplateIds } from '~/features/events/server/programme-auth.server' import EventFilters from '~/features/events/ui/EventFilters' @@ -164,6 +168,15 @@ function ConflictBadge({ count }: { count: number }) { ) } +function DraftBadge() { + return ( + + + ) +} + function EventRow({ event, timezone, @@ -181,6 +194,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 === EventStatus.Draft const cardStyle = { borderLeftColor: event.kind?.color ?? 'transparent', borderLeftWidth: '4px' } const kindLabel = event.kind ? ` · ${event.kind.name}` : '' const timeLabel = time ? ` · ${time}` : '' @@ -215,6 +229,7 @@ function EventRow({
+ {isDraft && } {showConflictBadge && }
@@ -266,19 +281,45 @@ export default function ProgramListPage({ loaderData }: Route.ComponentProps) { const { upcomingEvents, roles, defaults, timezone } = loaderData const [selectedIds, setSelectedIds] = useState>(new Set()) const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false) - const bulkFetcher = useFetcher<{ ok: boolean }>() + const bulkFetcher = useFetcher<{ ok: boolean; error?: 'invalid_payload' }>() + const bulkStatusFetcher = useFetcher<{ ok: boolean; error?: 'invalid_payload' }>() 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 === EventStatus.Draft) + const anyReleasedSelected = selectedEvents.some(e => e.status === EventStatus.Released) useEffect(() => { - if (bulkFetcher.state === 'idle' && bulkFetcher.data?.ok === true) { + if (bulkFetcher.state !== 'idle') return + if (bulkFetcher.data?.ok === true) { setSelectedIds(new Set()) } + // Same client-side surface for the delete fetcher — the layout flash + // won't fire for a shape rejection. + if (bulkFetcher.data?.ok === false && bulkFetcher.data?.error === 'invalid_payload') { + toast.error(m.programs_bulk_invalid_payload()) + } }, [bulkFetcher.state, bulkFetcher.data]) + useEffect(() => { + if (bulkStatusFetcher.state !== 'idle') return + if (bulkStatusFetcher.data?.ok === true) { + setSelectedIds(new Set()) + } + // Server-side flash toasts (via commitSession) drive the success/blocked + // messages. `invalid_payload` is a shape rejection so no flash cookie is + // set — surface a client-side toast so the user isn't left staring at a + // spinner that stops. + if (bulkStatusFetcher.data?.ok === false && bulkStatusFetcher.data?.error === 'invalid_payload') { + toast.error(m.programs_bulk_invalid_payload()) + } + }, [bulkStatusFetcher.state, bulkStatusFetcher.data]) + function toggleSelection(id: number) { setSelectedIds(prev => { const next = new Set(prev) @@ -315,6 +356,21 @@ export default function ProgramListPage({ loaderData }: Route.ComponentProps) { setBulkDeleteOpen(false) } + function bulkRelease() { + const ids = selectedEvents.filter(e => e.status === EventStatus.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 === EventStatus.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 17afb91f..c4ef0233 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", @@ -1308,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", @@ -1347,6 +1352,20 @@ "programs_bulk_select_event": "Select event {name}", "programs_week_select_all_aria": "Select all events for week of {date}", "programs_bulk_actions_aria": "Bulk actions for selected events", + "programs_event_draft_badge": "Draft", + "programs_event_release_button": "Publish", + "programs_event_unrelease_button": "Move back to draft", + "programs_bulk_release": "Publish selection", + "programs_bulk_unrelease": "Move selection back to draft", + "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", "programs_empty_title": "No upcoming events", @@ -2423,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 «{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", "dashboard_next_meeting": "Next meeting", diff --git a/app/i18n/messages/fr.json b/app/i18n/messages/fr.json index 072c2d27..0d2036bf 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", @@ -1310,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", @@ -1349,6 +1354,20 @@ "programs_bulk_select_event": "Sélectionner l'évènement {name}", "programs_week_select_all_aria": "Sélectionner tous les évènements de la semaine du {date}", "programs_bulk_actions_aria": "Actions groupées sur les évènements sélectionnés", + "programs_event_draft_badge": "Brouillon", + "programs_event_release_button": "Publier", + "programs_event_unrelease_button": "Repasser en brouillon", + "programs_bulk_release": "Publier la sélection", + "programs_bulk_unrelease": "Repasser la sélection en brouillon", + "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", "programs_empty_title": "Aucun évènement à venir", @@ -2426,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é à « {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", "dashboard_next_meeting": "Prochaine réunion", diff --git a/app/shared/auth/route-context.server.ts b/app/shared/auth/route-context.server.ts index 8adf7156..1e4f8a9f 100644 --- a/app/shared/auth/route-context.server.ts +++ b/app/shared/auth/route-context.server.ts @@ -1,7 +1,7 @@ import { createContext, type RouterContext, redirect } from 'react-router' import type { SanitizedAccount } from '~/shared/auth/sanitize-account.server' import type { CongregationInfo } from '~/shared/domain/congregation.server' -import type { TransactionClient } from '~/shared/infra/db.server' +import type { TransactionClient, TransactionOptions } from '~/shared/infra/db.server' import { withScope } from '~/shared/infra/db.server' import type { Permission } from '~/shared/types/permission' @@ -17,10 +17,17 @@ interface RouteContext { /** * Convenience helper: reads congregationId from context and runs fn inside withScope. * Use in loaders/actions that need scoped DB access after middleware has run. + * + * `options` is forwarded to the underlying `db.$transaction` — use it to + * extend the 5s default `timeout` for batch actions (bulk release, imports). */ -export function withScopeFromContext(context: RouteContext, fn: (db: TransactionClient) => Promise): Promise { +export function withScopeFromContext( + context: RouteContext, + fn: (db: TransactionClient) => Promise, + options?: TransactionOptions, +): Promise { const user = context.get(currentAccountContext) - return withScope(user.congregationId, fn) + return withScope(user.congregationId, fn, options) } export function requirePermission(permissions: Set, permission: Permission): void { diff --git a/app/shared/domain/audit.server.ts b/app/shared/domain/audit.server.ts index 0f457dd7..6d967aa3 100644 --- a/app/shared/domain/audit.server.ts +++ b/app/shared/domain/audit.server.ts @@ -103,6 +103,11 @@ export const AuditAction = { ExternalSpeakerArchived: 'external_speaker.archived', ExternalSpeakerUnarchived: 'external_speaker.unarchived', + // Programme events + EventReleased: 'event.released', + EventUnreleased: 'event.unreleased', + EventDeleted: 'event.deleted', + // Platform admin PlatformCongregationUpdated: 'platform.congregation.updated', PlatformUsersListed: 'platform.users.listed', diff --git a/app/shared/infra/db.server.ts b/app/shared/infra/db.server.ts index 629ac5db..86450a9c 100644 --- a/app/shared/infra/db.server.ts +++ b/app/shared/infra/db.server.ts @@ -38,5 +38,5 @@ function withScope( type TransactionClient = Parameters[0]>[0] -export type { TransactionClient } +export type { TransactionClient, TransactionOptions } export { unscopedDb, withScope } 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) +} 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..de1ee5a1 100644 --- a/docs/product/dashboard.md +++ b/docs/product/dashboard.md @@ -32,13 +32,14 @@ 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 | |---|---|---|---| | 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 | +| 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 | @@ -64,7 +65,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*.