Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
1 change: 1 addition & 0 deletions app/database/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
19 changes: 15 additions & 4 deletions app/features/dashboard/server/dashboard.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ beforeAll(async () => {
endDate: new Date('2027-06-01T21:00:00Z'),
createdById: aliceAccountId,
congregationId,
status: 'released',
},
})

Expand Down Expand Up @@ -191,6 +192,7 @@ beforeAll(async () => {
endDate: new Date('2025-01-01T21:00:00Z'),
createdById: aliceAccountId,
congregationId,
status: 'released',
},
})
pastEventId = past.id
Expand Down Expand Up @@ -303,6 +305,7 @@ describe('getNextMeeting (integration)', () => {
endDate: new Date('2027-05-03T00:00:00Z'),
createdById: bobAccountId,
congregationId,
status: 'released',
},
})
return off.id
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
75 changes: 73 additions & 2 deletions app/features/dashboard/server/dashboard.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
},
}))
Expand All @@ -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')

Expand Down Expand Up @@ -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<string, unknown>
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<string, unknown>
expect(where.NOT).toEqual({ kind: { key: 'off' } })
expect(where).not.toHaveProperty('kind')
})
})

// --- getUpcomingAbsences ---
Expand Down Expand Up @@ -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' })
})
})
35 changes: 25 additions & 10 deletions app/features/dashboard/server/dashboard.server.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
})),
]
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } },
})
})
Expand All @@ -46,18 +47,21 @@ describe('getResponsibleConflicts', () => {
const serviceWhere = serviceCall?.where as Record<string, unknown>
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<string, unknown>
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')
})

Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -27,9 +28,11 @@ export async function getResponsibleConflicts(
): Promise<ResponsibleConflictsSummary> {
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({
Expand Down
Loading
Loading