- )
- })}
+ {events.map(event => (
+ toggleSelection(event.id)}
+ />
+ ))}
)
})}
diff --git a/app/features/events/server/days-off.server.test.ts b/app/features/events/server/days-off.server.test.ts
index f22a5317..43b06190 100644
--- a/app/features/events/server/days-off.server.test.ts
+++ b/app/features/events/server/days-off.server.test.ts
@@ -9,7 +9,7 @@ vi.mock('~/shared/infra/db.server', () => ({
},
}))
-const { createDayOff } = await import('./days-off.server')
+const { createDayOff, deleteDayOff } = await import('./days-off.server')
const { unscopedDb: db } = await import('~/shared/infra/db.server')
beforeEach(() => {
@@ -19,51 +19,103 @@ beforeEach(() => {
})
describe('createDayOff', () => {
- it('retourne null quand startDate est null', async () => {
- const result = await createDayOff(db, 1, null, new Date(2025, 3, 10), 1)
+ it('returns null when startDate is null', async () => {
+ const result = await createDayOff(db, 1, 1, null, new Date(2025, 3, 10), 1)
expect(result).toBeNull()
})
- it('retourne null quand endDate est null', async () => {
- const result = await createDayOff(db, 1, new Date(2025, 3, 8), null, 1)
+ it('returns null when endDate is null', async () => {
+ const result = await createDayOff(db, 1, 1, new Date(2025, 3, 8), null, 1)
expect(result).toBeNull()
})
- it('retourne null quand startDate est undefined', async () => {
- const result = await createDayOff(db, 1, undefined, new Date(2025, 3, 10), 1)
+ it('returns null when startDate is undefined', async () => {
+ const result = await createDayOff(db, 1, 1, undefined, new Date(2025, 3, 10), 1)
expect(result).toBeNull()
})
- it('retourne null quand startDate > endDate', async () => {
- const result = await createDayOff(db, 1, new Date(2025, 3, 15), new Date(2025, 3, 10), 1)
+ it('returns null when startDate > endDate', async () => {
+ const result = await createDayOff(db, 1, 1, new Date(2025, 3, 15), new Date(2025, 3, 10), 1)
expect(result).toBeNull()
})
- it('crée un événement quand les dates sont valides', async () => {
+ it('creates an event when dates are valid', async () => {
const fakeEvent = { id: 1, name: 'Absence' }
vi.mocked(db.eventKind.findFirst).mockResolvedValue({ id: 5, key: 'off' } as never)
vi.mocked(db.event.create).mockResolvedValue(fakeEvent as never)
- const result = await createDayOff(db, 1, new Date(2025, 3, 8), new Date(2025, 3, 10), 1)
+ const result = await createDayOff(db, 1, 1, new Date(2025, 3, 8), new Date(2025, 3, 10), 1)
expect(result).toEqual(fakeEvent)
})
- it('crée un événement même quand startDate == endDate', async () => {
+ it('creates an event when startDate == endDate', async () => {
const sameDate = new Date(2025, 3, 8)
const fakeEvent = { id: 2, name: 'Absence' }
vi.mocked(db.eventKind.findFirst).mockResolvedValue({ id: 5, key: 'off' } as never)
vi.mocked(db.event.create).mockResolvedValue(fakeEvent as never)
- const result = await createDayOff(db, 1, sameDate, sameDate, 1)
+ const result = await createDayOff(db, 1, 1, sameDate, sameDate, 1)
expect(result).toEqual(fakeEvent)
})
- it("crée l'événement même sans eventKind trouvé", async () => {
+ it('creates the event even when no eventKind is found', async () => {
const fakeEvent = { id: 3, name: 'Absence' }
vi.mocked(db.eventKind.findFirst).mockResolvedValue(null as never)
vi.mocked(db.event.create).mockResolvedValue(fakeEvent as never)
- const result = await createDayOff(db, 1, new Date(2025, 3, 8), new Date(2025, 3, 10), 1)
+ const result = await createDayOff(db, 1, 1, new Date(2025, 3, 8), new Date(2025, 3, 10), 1)
expect(result).toEqual(fakeEvent)
})
+
+ // With a null memberId (account without a linked Member — e.g. circuit
+ // overseer or admin), we skip the conflict-flag refresh entirely: there
+ // can be no assignments to flag.
+ it('skips refreshConflictFlags when memberId is null', async () => {
+ const fakeEvent = { id: 4, name: 'Absence' }
+ vi.mocked(db.eventKind.findFirst).mockResolvedValue({ id: 5, key: 'off' } as never)
+ vi.mocked(db.event.create).mockResolvedValue(fakeEvent as never)
+
+ await createDayOff(db, 1, null, new Date(2025, 3, 8), new Date(2025, 3, 10), 1)
+
+ expect(db.event.findMany).not.toHaveBeenCalled()
+ })
+})
+
+describe('deleteDayOff', () => {
+ it('deletes the event and returns it', async () => {
+ const fakeEvent = { id: 42, startDate: new Date(2025, 3, 8), endDate: new Date(2025, 3, 10) }
+ vi.mocked(db.event.delete).mockResolvedValue(fakeEvent as never)
+
+ const result = await deleteDayOff(db, 42, 1, 1)
+
+ expect(result).toEqual(fakeEvent)
+ expect(db.event.delete).toHaveBeenCalledWith({
+ where: { id_congregationId: { id: 42, congregationId: 1 } },
+ })
+ })
+
+ it('refreshes conflict flags over the deleted range when memberId is provided', async () => {
+ const startDate = new Date(2025, 3, 8)
+ const endDate = new Date(2025, 3, 10)
+ vi.mocked(db.event.delete).mockResolvedValue({ id: 42, startDate, endDate } as never)
+
+ await deleteDayOff(db, 42, 1, 1)
+
+ expect(db.event.findMany).toHaveBeenCalled()
+ })
+
+ // Mirror of createDayOff's null-memberId guard — an account without a
+ // linked Member cannot own any conflict-carrying assignments, so refresh
+ // is skipped rather than issuing a no-op query.
+ it('skips refreshConflictFlags when memberId is null', async () => {
+ vi.mocked(db.event.delete).mockResolvedValue({
+ id: 42,
+ startDate: new Date(2025, 3, 8),
+ endDate: new Date(2025, 3, 10),
+ } as never)
+
+ await deleteDayOff(db, 42, null, 1)
+
+ expect(db.event.findMany).not.toHaveBeenCalled()
+ })
})
diff --git a/app/features/events/server/days-off.server.ts b/app/features/events/server/days-off.server.ts
index ae00ff29..7872aaf4 100644
--- a/app/features/events/server/days-off.server.ts
+++ b/app/features/events/server/days-off.server.ts
@@ -19,9 +19,14 @@ export function getNextDaysOffs(db: TransactionClient, userId: number, congregat
})
}
+// `accountId` writes Event.createdBy. `memberId` (nullable — admin / circuit
+// overseer accounts with no linked member) is what refreshConflictFlags needs
+// to reconcile assignments; when it's null there can be no assignments to
+// conflict, so we skip the refresh entirely.
export async function createDayOff(
db: TransactionClient,
- userId: number,
+ accountId: number,
+ memberId: number | null,
startDate: Date | null | undefined,
endDate: Date | null | undefined,
congregationId: number,
@@ -41,27 +46,34 @@ export async function createDayOff(
...(eventKind ? { kind: { connect: { id: eventKind.id } } } : {}),
startDate,
endDate,
- createdBy: { connect: { id: userId } },
+ createdBy: { connect: { id: accountId } },
name: m.seed_event_kind_absence(),
congregation: { connect: { id: congregationId } },
},
})
- // Update conflict flags on programme assignments overlapping this new day-off
- await refreshConflictFlags(db, userId, startDate, endDate, congregationId)
+ if (memberId != null) {
+ await refreshConflictFlags(db, memberId, startDate, endDate, congregationId)
+ }
return event
}
-export async function deleteDayOff(db: TransactionClient, eventId: number, userId: number, congregationId: number) {
+export async function deleteDayOff(
+ db: TransactionClient,
+ eventId: number,
+ memberId: number | null,
+ congregationId: number,
+) {
const event = await db.event.delete({
where: {
id_congregationId: { id: eventId, congregationId },
},
})
- // Refresh conflict flags — the absence is gone, so conflicts may be resolved
- await refreshConflictFlags(db, userId, event.startDate, event.endDate, congregationId)
+ if (memberId != null) {
+ await refreshConflictFlags(db, memberId, event.startDate, event.endDate, congregationId)
+ }
return event
}
diff --git a/app/features/events/server/event-filters.server.test.ts b/app/features/events/server/event-filters.server.test.ts
index e492aa19..2f9f0927 100644
--- a/app/features/events/server/event-filters.server.test.ts
+++ b/app/features/events/server/event-filters.server.test.ts
@@ -95,4 +95,57 @@ describe('computeFilters', () => {
expect(result.createdById).toBeUndefined()
})
+
+ it('filters by hasConflicts when param is true', () => {
+ const params = new URLSearchParams({ hasConflicts: 'true' })
+ const result = computeFilters(params)
+
+ expect(result.AND).toEqual([
+ {
+ OR: [
+ { partAssignments: { some: { hasConflict: true } } },
+ { serviceRoleAssignments: { some: { hasConflict: true } } },
+ ],
+ },
+ ])
+ })
+
+ it('does not filter by hasConflicts when param is absent', () => {
+ const params = new URLSearchParams()
+ const result = computeFilters(params)
+
+ expect(result.AND).toBeUndefined()
+ })
+
+ it('does not filter by hasConflicts when param is false', () => {
+ const params = new URLSearchParams({ hasConflicts: 'false' })
+ const result = computeFilters(params)
+
+ expect(result.AND).toBeUndefined()
+ })
+
+ // Combined-param pin: hasConflicts must compose cleanly with date + publisher
+ // filters. A regression that spread over `startDate` / `endDate` (or drops
+ // `createdById`) would slip through the single-param tests above.
+ it('preserves date and publisher filters when hasConflicts is applied', () => {
+ const params = new URLSearchParams({
+ from: '2025-06-01',
+ to: '2025-06-30',
+ publisher: '42',
+ hasConflicts: 'true',
+ })
+ const result = computeFilters(params)
+
+ expect(result.startDate).toEqual({ lte: new Date('2025-06-30') })
+ expect(result.endDate).toEqual({ gte: new Date('2025-06-01') })
+ expect(result.createdById).toBe(42)
+ expect(result.AND).toEqual([
+ {
+ OR: [
+ { partAssignments: { some: { hasConflict: true } } },
+ { serviceRoleAssignments: { some: { hasConflict: true } } },
+ ],
+ },
+ ])
+ })
})
diff --git a/app/features/events/server/event-filters.server.ts b/app/features/events/server/event-filters.server.ts
index 6473e5ab..f9401bad 100644
--- a/app/features/events/server/event-filters.server.ts
+++ b/app/features/events/server/event-filters.server.ts
@@ -16,6 +16,7 @@ export function computeFilters(params: URLSearchParams): Prisma.EventWhereInput
filters = applyDateRangeFilter(filters, params)
filters = applyPublisherFilter(filters, params)
+ filters = applyHasConflictsFilter(filters, params)
return filters
}
@@ -48,3 +49,27 @@ function applyPublisherFilter(filters: Prisma.EventWhereInput, params: URLSearch
return filters
}
+
+// `?hasConflicts=true` restricts the list to events that have at least one
+// assignment flagged as a day-off conflict. Nested under `AND` so a
+// caller (or a future filter) can freely set its own top-level `OR`
+// without either clause silently overwriting the other.
+function applyHasConflictsFilter(filters: Prisma.EventWhereInput, params: URLSearchParams): Prisma.EventWhereInput {
+ if (params.get('hasConflicts') !== 'true') {
+ return filters
+ }
+
+ const existingAnd = Array.isArray(filters.AND) ? filters.AND : filters.AND ? [filters.AND] : []
+ return {
+ ...filters,
+ AND: [
+ ...existingAnd,
+ {
+ OR: [
+ { partAssignments: { some: { hasConflict: true } } },
+ { serviceRoleAssignments: { some: { hasConflict: true } } },
+ ],
+ },
+ ],
+ }
+}
diff --git a/app/features/events/server/list-user-conflicts-in-range.server.test.ts b/app/features/events/server/list-user-conflicts-in-range.server.test.ts
new file mode 100644
index 00000000..723f56ca
--- /dev/null
+++ b/app/features/events/server/list-user-conflicts-in-range.server.test.ts
@@ -0,0 +1,186 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('~/shared/infra/db.server', () => ({
+ unscopedDb: {
+ programmePartAssignment: { findMany: vi.fn() },
+ programmeServiceRoleAssignment: { findMany: vi.fn() },
+ },
+}))
+
+const { listUserConflictsInRange } = await import('./list-user-conflicts-in-range.server')
+const { unscopedDb: db } = await import('~/shared/infra/db.server')
+
+beforeEach(() => {
+ vi.resetAllMocks()
+ vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([] as never)
+ vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([] as never)
+})
+
+describe('listUserConflictsInRange', () => {
+ it('returns an empty array when the member has no conflicting assignments', async () => {
+ const result = await listUserConflictsInRange(db, 42, new Date(2026, 6, 1), new Date(2026, 6, 3))
+ expect(result).toEqual([])
+ })
+
+ // Filters must be by memberId (assigneeId / assistantId reference Member.id),
+ // hasConflict: true, and event range overlap. Anything else risks either
+ // showing stale conflicts or missing legitimate ones.
+ it('filters part assignments by memberId, hasConflict, and event overlap', async () => {
+ const memberId = 5000
+ const start = new Date(2026, 6, 1)
+ const end = new Date(2026, 6, 3)
+
+ await listUserConflictsInRange(db, memberId, start, end)
+
+ const call = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0]
+ const where = call?.where as Record
+ expect(where.hasConflict).toBe(true)
+ expect(where.OR).toEqual([{ assigneeId: memberId }, { assistantId: memberId }])
+ expect(where.event).toEqual({ startDate: { lte: end }, endDate: { gte: start } })
+ })
+
+ it('filters service-role assignments by memberId as assignee only', async () => {
+ const memberId = 5000
+ await listUserConflictsInRange(db, memberId, new Date(2026, 6, 1), new Date(2026, 6, 3))
+
+ const call = vi.mocked(db.programmeServiceRoleAssignment.findMany).mock.calls[0][0]
+ const where = call?.where as Record
+ expect(where.hasConflict).toBe(true)
+ expect(where.assigneeId).toBe(memberId)
+ })
+
+ it('resolves the responsible name via accountDisplayName when a template responsible exists', async () => {
+ vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([
+ {
+ name: 'Discours public',
+ event: {
+ startDate: new Date(2026, 6, 5),
+ template: {
+ responsibles: [{ user: { firstname: 'Jean', lastname: 'Dupont', member: null } }],
+ },
+ },
+ },
+ ] as never)
+
+ const result = await listUserConflictsInRange(db, 5000, new Date(2026, 6, 1), new Date(2026, 6, 10))
+
+ expect(result).toEqual([
+ { eventDate: new Date(2026, 6, 5), assignmentName: 'Discours public', responsibleName: 'Jean Dupont' },
+ ])
+ })
+
+ // Prefers the linked Member's name over the account fallback fields, per
+ // accountDisplayName semantics — matches what shows up everywhere else in
+ // the app.
+ it("uses the responsible's linked Member name when present", async () => {
+ vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([
+ {
+ name: 'Micros',
+ event: {
+ startDate: new Date(2026, 6, 5),
+ template: {
+ responsibles: [
+ {
+ user: {
+ firstname: 'account-first',
+ lastname: 'account-last',
+ member: { firstname: 'Pierre', lastname: 'Martin' },
+ },
+ },
+ ],
+ },
+ },
+ },
+ ] as never)
+
+ const result = await listUserConflictsInRange(db, 5000, new Date(2026, 6, 1), new Date(2026, 6, 10))
+
+ expect(result[0].responsibleName).toBe('Pierre Martin')
+ })
+
+ it('returns responsibleName as null for untemplated events', async () => {
+ vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([
+ {
+ name: 'Custom part',
+ event: {
+ startDate: new Date(2026, 6, 5),
+ template: null,
+ },
+ },
+ ] as never)
+
+ const result = await listUserConflictsInRange(db, 5000, new Date(2026, 6, 1), new Date(2026, 6, 10))
+
+ expect(result[0].responsibleName).toBeNull()
+ })
+
+ // When a templated event has no responsible assigned yet, the UI still
+ // shows the generic fallback wording — the query surfaces null the same
+ // way as for untemplated events.
+ it('returns responsibleName as null when the template has no responsible assigned', async () => {
+ vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([
+ {
+ name: 'Prière',
+ event: {
+ startDate: new Date(2026, 6, 5),
+ template: { responsibles: [] },
+ },
+ },
+ ] as never)
+
+ const result = await listUserConflictsInRange(db, 5000, new Date(2026, 6, 1), new Date(2026, 6, 10))
+
+ expect(result[0].responsibleName).toBeNull()
+ })
+
+ // A template can carry multiple responsibles; each named person must
+ // surface so the absentee knows who to reach. Sorted alphabetically so
+ // the modal reads the same across renders.
+ it('joins every responsible name when a template has more than one, sorted alphabetically', async () => {
+ vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([
+ {
+ name: 'Discours public',
+ event: {
+ startDate: new Date(2026, 6, 5),
+ template: {
+ responsibles: [
+ { user: { firstname: 'Zoé', lastname: 'Petit', member: null } },
+ { user: { firstname: 'Alain', lastname: 'Roux', member: null } },
+ ],
+ },
+ },
+ },
+ ] as never)
+
+ const result = await listUserConflictsInRange(db, 5000, new Date(2026, 6, 1), new Date(2026, 6, 10))
+
+ expect(result[0].responsibleName).toBe('Alain Roux, Zoé Petit')
+ })
+
+ it('merges part + service conflicts and sorts by eventDate ascending', async () => {
+ vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([
+ {
+ name: 'Later part',
+ event: { startDate: new Date(2026, 6, 10), template: null },
+ },
+ {
+ name: 'Earlier part',
+ event: { startDate: new Date(2026, 6, 2), template: null },
+ },
+ ] as never)
+ vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([
+ {
+ name: 'Middle service',
+ event: { startDate: new Date(2026, 6, 5), template: null },
+ },
+ ] as never)
+
+ const result = await listUserConflictsInRange(db, 5000, new Date(2026, 6, 1), new Date(2026, 6, 15))
+
+ expect(result.map((r: { assignmentName: string }) => r.assignmentName)).toEqual([
+ 'Earlier part',
+ 'Middle service',
+ 'Later part',
+ ])
+ })
+})
diff --git a/app/features/events/server/list-user-conflicts-in-range.server.ts b/app/features/events/server/list-user-conflicts-in-range.server.ts
new file mode 100644
index 00000000..5cd2901e
--- /dev/null
+++ b/app/features/events/server/list-user-conflicts-in-range.server.ts
@@ -0,0 +1,90 @@
+import type { Prisma } from '~/database/generated/client'
+import type { TransactionClient } from '~/shared/infra/db.server'
+import { accountDisplayName } from '~/shared/utils/display-name'
+
+export interface UserConflictInRange {
+ eventDate: Date
+ assignmentName: string
+ responsibleName: string | null
+}
+
+// Shared between the two `findMany` calls so the query shape stays in sync.
+const eventWithResponsiblesSelect = {
+ startDate: true,
+ template: {
+ select: {
+ responsibles: {
+ select: {
+ user: {
+ select: {
+ firstname: true,
+ lastname: true,
+ member: { select: { firstname: true, lastname: true } },
+ },
+ },
+ },
+ },
+ },
+ },
+} satisfies Prisma.EventSelect
+
+type EventWithResponsibles = Prisma.EventGetPayload<{ select: typeof eventWithResponsiblesSelect }>
+
+// `responsibleName` resolves through `accountDisplayName` so a template
+// responsible's linked Member name wins over the account fallback fields.
+// Untemplated events (or templates with no responsible assigned) return
+// `null` so the UI can show a generic fallback line.
+export async function listUserConflictsInRange(
+ db: TransactionClient,
+ memberId: number,
+ startDate: Date,
+ endDate: Date,
+): Promise {
+ const [partConflicts, serviceConflicts] = await Promise.all([
+ db.programmePartAssignment.findMany({
+ where: {
+ hasConflict: true,
+ OR: [{ assigneeId: memberId }, { assistantId: memberId }],
+ event: { startDate: { lte: endDate }, endDate: { gte: startDate } },
+ },
+ select: {
+ name: true,
+ event: { select: eventWithResponsiblesSelect },
+ },
+ }),
+ db.programmeServiceRoleAssignment.findMany({
+ where: {
+ hasConflict: true,
+ assigneeId: memberId,
+ event: { startDate: { lte: endDate }, endDate: { gte: startDate } },
+ },
+ select: {
+ name: true,
+ event: { select: eventWithResponsiblesSelect },
+ },
+ }),
+ ])
+
+ const merged: UserConflictInRange[] = [...partConflicts, ...serviceConflicts].map(a => ({
+ eventDate: a.event.startDate,
+ assignmentName: a.name,
+ responsibleName: resolveResponsibleName(a.event.template),
+ }))
+
+ merged.sort((a, b) => a.eventDate.getTime() - b.eventDate.getTime())
+ return merged
+}
+
+// A template can have any number of responsibles. Every named responsible
+// is surfaced so the absentee knows who to reach, deterministically ordered
+// by display name so the modal reads the same across renders.
+function resolveResponsibleName(template: EventWithResponsibles['template']): string | null {
+ const responsibles = template?.responsibles ?? []
+ if (responsibles.length === 0) return null
+
+ const names = responsibles.map(r => accountDisplayName(r.user)).filter(name => name.length > 0)
+ if (names.length === 0) return null
+
+ names.sort((a, b) => a.localeCompare(b))
+ return names.join(', ')
+}
diff --git a/app/features/events/server/programme-assignments.server.test.ts b/app/features/events/server/programme-assignments.server.test.ts
index fe3d5edc..f211c6e2 100644
--- a/app/features/events/server/programme-assignments.server.test.ts
+++ b/app/features/events/server/programme-assignments.server.test.ts
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { EventKind } from '~/features/events/model/event-kind.type'
vi.mock('~/shared/infra/db.server', () => ({
unscopedDb: {
@@ -42,6 +43,24 @@ describe('checkDayOffConflict', () => {
const result = await checkDayOffConflict(db, 1, new Date(2026, 3, 14), new Date(2026, 3, 14), 1)
expect(result).toBe(false)
})
+
+ // Regression pin — the ID passed in is a Member.id (all call sites resolve
+ // participants via Member); day-off events store the creator's UserAccount.id
+ // in Event.createdById. Filtering by `createdBy: { memberId }` is the only
+ // shape that correctly joins the two. A prior version filtered by
+ // `createdById: memberId` which silently returned no results whenever the
+ // member's linked account.id differed from the member.id.
+ it('joins day-offs through Event.createdBy.memberId (not createdById)', async () => {
+ vi.mocked(db.event.findFirst).mockResolvedValue(null as never)
+ const memberId = 5000
+
+ await checkDayOffConflict(db, memberId, new Date(2026, 3, 14), new Date(2026, 3, 14), 1)
+
+ const firstCall = vi.mocked(db.event.findFirst).mock.calls[0][0]
+ const where = firstCall?.where as Record
+ expect(where.createdBy).toEqual({ memberId })
+ expect(where).not.toHaveProperty('createdById')
+ })
})
describe('assignPart', () => {
@@ -375,18 +394,36 @@ describe('unassignServiceRole', () => {
})
describe('refreshConflictFlags', () => {
- it('updates conflict flags for overlapping events', async () => {
+ it('writes hasConflict:true when the member has an overlapping day-off', async () => {
vi.mocked(db.event.findMany).mockResolvedValue([
{ id: 1, startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) },
] as never)
- vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never)
+ vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) // day-off found
+ vi.mocked(db.programmePartAssignment.updateMany).mockResolvedValue({ count: 1 } as never)
+ vi.mocked(db.programmeServiceRoleAssignment.updateMany).mockResolvedValue({ count: 0 } as never)
+
+ await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 15), 1)
+
+ const partCall = vi.mocked(db.programmePartAssignment.updateMany).mock.calls[0][0]
+ const serviceCall = vi.mocked(db.programmeServiceRoleAssignment.updateMany).mock.calls[0][0]
+ expect(partCall?.data).toEqual({ hasConflict: true })
+ expect(serviceCall?.data).toEqual({ hasConflict: true })
+ })
+
+ it('writes hasConflict:false when the member no longer has an overlapping day-off', async () => {
+ vi.mocked(db.event.findMany).mockResolvedValue([
+ { id: 1, startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) },
+ ] as never)
+ vi.mocked(db.event.findFirst).mockResolvedValue(null as never) // no day-off
vi.mocked(db.programmePartAssignment.updateMany).mockResolvedValue({ count: 1 } as never)
vi.mocked(db.programmeServiceRoleAssignment.updateMany).mockResolvedValue({ count: 0 } as never)
await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 15), 1)
- expect(db.programmePartAssignment.updateMany).toHaveBeenCalled()
- expect(db.programmeServiceRoleAssignment.updateMany).toHaveBeenCalled()
+ const partCall = vi.mocked(db.programmePartAssignment.updateMany).mock.calls[0][0]
+ const serviceCall = vi.mocked(db.programmeServiceRoleAssignment.updateMany).mock.calls[0][0]
+ expect(partCall?.data).toEqual({ hasConflict: false })
+ expect(serviceCall?.data).toEqual({ hasConflict: false })
})
it('does nothing when no overlapping events', async () => {
@@ -396,4 +433,77 @@ describe('refreshConflictFlags', () => {
expect(db.programmePartAssignment.updateMany).not.toHaveBeenCalled()
})
+
+ // Every overlapping event must be reconciled independently. A regression
+ // that early-returned after the first iteration would leave later events
+ // stuck on stale flags.
+ it('iterates every overlapping event, updating each independently', async () => {
+ vi.mocked(db.event.findMany).mockResolvedValue([
+ { id: 1, startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) },
+ { id: 2, startDate: new Date(2026, 3, 15), endDate: new Date(2026, 3, 15) },
+ { id: 3, startDate: new Date(2026, 3, 16), endDate: new Date(2026, 3, 16) },
+ ] as never)
+ vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never)
+ vi.mocked(db.programmePartAssignment.updateMany).mockResolvedValue({ count: 1 } as never)
+ vi.mocked(db.programmeServiceRoleAssignment.updateMany).mockResolvedValue({ count: 0 } as never)
+
+ await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 17), 1)
+
+ expect(vi.mocked(db.programmePartAssignment.updateMany).mock.calls).toHaveLength(3)
+ expect(vi.mocked(db.programmeServiceRoleAssignment.updateMany).mock.calls).toHaveLength(3)
+
+ const eventIdsUpdated = vi
+ .mocked(db.programmePartAssignment.updateMany)
+ .mock.calls.map(([args]) => (args?.where as { eventId: number }).eventId)
+ expect(eventIdsUpdated).toEqual([1, 2, 3])
+ })
+
+ // Regression pin — participants are Members (`assigneeId`, `assistantId`
+ // reference Member.id). Filtering with a UserAccount.id would silently miss
+ // every assignment whose Member.id differs from the assignee's account.id.
+ it('filters part and service assignments by memberId', async () => {
+ vi.mocked(db.event.findMany).mockResolvedValue([
+ { id: 1, startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) },
+ ] as never)
+ vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never)
+ vi.mocked(db.programmePartAssignment.updateMany).mockResolvedValue({ count: 1 } as never)
+ vi.mocked(db.programmeServiceRoleAssignment.updateMany).mockResolvedValue({ count: 0 } as never)
+
+ const memberId = 5000
+ await refreshConflictFlags(db, memberId, new Date(2026, 3, 13), new Date(2026, 3, 15), 1)
+
+ const partCall = vi.mocked(db.programmePartAssignment.updateMany).mock.calls[0][0]
+ const partWhere = partCall?.where as Record
+ expect(partWhere.OR).toEqual([{ assigneeId: memberId }, { assistantId: memberId }])
+
+ const serviceCall = vi.mocked(db.programmeServiceRoleAssignment.updateMany).mock.calls[0][0]
+ const serviceWhere = serviceCall?.where as Record
+ expect(serviceWhere.assigneeId).toBe(memberId)
+ })
+
+ // Regression pin — untemplated events also carry assignments (added
+ // manually) and must participate in the hasConflict invariant. A prior
+ // `templateId: { not: null }` filter excluded them.
+ it('includes untemplated events (no templateId filter)', async () => {
+ vi.mocked(db.event.findMany).mockResolvedValue([] as never)
+
+ await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 15), 1)
+
+ const call = vi.mocked(db.event.findMany).mock.calls[0][0]
+ const where = call?.where as Record
+ expect(where).not.toHaveProperty('templateId')
+ })
+
+ // The Off events themselves are just date ranges — they have no part or
+ // service assignments. Iterating over them is wasted work and semantically
+ // odd (an Off event isn't a programme event that can conflict with itself).
+ it('excludes Off events from the overlapping-events lookup', async () => {
+ vi.mocked(db.event.findMany).mockResolvedValue([] as never)
+
+ await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 15), 1)
+
+ const call = vi.mocked(db.event.findMany).mock.calls[0][0]
+ const where = call?.where as Record
+ expect(where.kind).toEqual({ key: { not: EventKind.Off } })
+ })
})
diff --git a/app/features/events/server/programme-assignments.server.ts b/app/features/events/server/programme-assignments.server.ts
index 3cc4e199..7d548b45 100644
--- a/app/features/events/server/programme-assignments.server.ts
+++ b/app/features/events/server/programme-assignments.server.ts
@@ -218,9 +218,13 @@ export async function unassignServiceRole(db: TransactionClient, assignmentId: n
return { assignment, previousAssigneeId: existing.assigneeId }
}
+// The `memberId` here is Member.id. Day-off events store the creator's
+// UserAccount.id in Event.createdById, so we join through
+// Event.createdBy.memberId to resolve the absence back to the same Member
+// that carries assignments (assigneeId / assistantId reference Member).
export async function checkDayOffConflict(
db: TransactionClient,
- userId: number,
+ memberId: number,
startDate: Date,
endDate: Date,
congregationId: number,
@@ -228,7 +232,7 @@ export async function checkDayOffConflict(
const conflictingDayOff = await db.event.findFirst({
where: {
congregationId,
- createdById: userId,
+ createdBy: { memberId },
kind: { key: EventKind.Off },
startDate: { lte: endDate },
endDate: { gte: startDate },
@@ -240,16 +244,18 @@ export async function checkDayOffConflict(
export async function refreshConflictFlags(
db: TransactionClient,
- userId: number,
+ memberId: number,
startDate: Date,
endDate: Date,
congregationId: number,
) {
- // Find all programme events overlapping with the given date range
+ // Find all programme events (templated OR not) overlapping the range.
+ // Off events themselves have no assignments and are excluded to avoid
+ // pointless iteration.
const overlappingEvents = await db.event.findMany({
where: {
congregationId,
- templateId: { not: null },
+ kind: { key: { not: EventKind.Off } },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
@@ -257,23 +263,21 @@ export async function refreshConflictFlags(
})
for (const event of overlappingEvents) {
- const hasConflict = await checkDayOffConflict(db, userId, event.startDate, event.endDate, congregationId)
+ const hasConflict = await checkDayOffConflict(db, memberId, event.startDate, event.endDate, congregationId)
- // Update part assignments where this user is assigned
await db.programmePartAssignment.updateMany({
where: {
eventId: event.id,
congregationId,
- OR: [{ assigneeId: userId }, { assistantId: userId }],
+ OR: [{ assigneeId: memberId }, { assistantId: memberId }],
},
data: { hasConflict },
})
- // Update service role assignments where this user is assigned
await db.programmeServiceRoleAssignment.updateMany({
where: {
eventId: event.id,
- assigneeId: userId,
+ assigneeId: memberId,
congregationId,
},
data: { hasConflict },
diff --git a/app/features/events/ui/DayOffConflictModal.tsx b/app/features/events/ui/DayOffConflictModal.tsx
new file mode 100644
index 00000000..0ddfd08c
--- /dev/null
+++ b/app/features/events/ui/DayOffConflictModal.tsx
@@ -0,0 +1,69 @@
+import { AlertTriangle } from 'lucide-react'
+import type { UserConflictInRange } from '~/features/events/server/list-user-conflicts-in-range.server'
+import * as m from '~/i18n/paraglide/messages'
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogTitle,
+} from '~/shared/ui/alert-dialog'
+import { formatEventDate } from '~/shared/utils/event-time'
+
+interface DayOffConflictModalProps {
+ conflicts: UserConflictInRange[]
+ timezone: string
+ open: boolean
+ onClose: () => void
+}
+
+const ROW_DATE_OPTIONS: Intl.DateTimeFormatOptions = { weekday: 'short', day: 'numeric', month: 'short' }
+
+export function DayOffConflictModal({ conflicts, timezone, open, onClose }: DayOffConflictModalProps) {
+ const title =
+ conflicts.length === 1
+ ? m.days_off_conflict_modal_title_singular()
+ : m.days_off_conflict_modal_title_plural({ count: conflicts.length })
+
+ return (
+ (!next ? onClose() : undefined)}>
+
+
+
+
+
+ {title}
+ {m.days_off_conflict_modal_intro()}
+
+
+
+ {conflicts.map((c, idx) => {
+ const date = formatEventDate(c.eventDate, timezone, 'fr-FR', ROW_DATE_OPTIONS)
+ const text =
+ c.responsibleName != null
+ ? m.days_off_conflict_modal_row_named({
+ assignment: c.assignmentName,
+ date,
+ responsible: c.responsibleName,
+ })
+ : m.days_off_conflict_modal_row_fallback({ assignment: c.assignmentName, date })
+ return (
+ // Conflicts have no stable id — server returns an aggregate; index is fine
+ // because we never reorder within one modal instance.
+ // biome-ignore lint/suspicious/noArrayIndexKey: aggregate row has no stable id
+
+
+ {text}
+
+ )
+ })}
+
+
+
+ {m.days_off_conflict_modal_button()}
+
+
+
+ )
+}
diff --git a/app/i18n/messages/en.json b/app/i18n/messages/en.json
index 7724d2b7..de4bd94b 100644
--- a/app/i18n/messages/en.json
+++ b/app/i18n/messages/en.json
@@ -1302,6 +1302,18 @@
"days_off_new_end_date": "End date",
"days_off_new_invalid_dates": "Unable to add this absence. The dates are invalid.",
"days_off_new_success": "Absence added successfully.",
+ "days_off_conflict_modal_title_singular": "Warning: this absence conflicts with a scheduled assignment",
+ "days_off_conflict_modal_title_plural": "Warning: this absence conflicts with {count} scheduled assignments",
+ "days_off_conflict_modal_intro": "Your absence is saved. It does overlap assignments that are already scheduled, though — please reach out to the responsibles below as soon as you can so a replacement can be arranged:",
+ "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_extras": " (+{count} more)",
+ "programs_event_conflict_badge_singular": "Conflict",
+ "programs_event_conflict_badge_plural": "{count} conflicts",
+ "programs_event_conflict_badge_aria": "Assignment conflict: {count} absent participant(s)",
"days_off_admin_meta_title": "Absences - Unitae",
"days_off_admin_page_title": "Absences",
"days_off_admin_page_subtitle": "List of all absences for the selected period.",
diff --git a/app/i18n/messages/fr.json b/app/i18n/messages/fr.json
index 4a02debf..bcd77b18 100644
--- a/app/i18n/messages/fr.json
+++ b/app/i18n/messages/fr.json
@@ -1304,6 +1304,18 @@
"days_off_new_end_date": "Date de fin",
"days_off_new_invalid_dates": "Impossible d'ajouter cette absence. Les dates sont invalides.",
"days_off_new_success": "Absence ajoutée avec succès.",
+ "days_off_conflict_modal_title_singular": "Attention : conflit avec une affectation planifiée",
+ "days_off_conflict_modal_title_plural": "Attention : conflit avec {count} affectations planifiées",
+ "days_off_conflict_modal_intro": "Votre absence est bien enregistrée. Elle chevauche cependant des affectations déjà prévues — pensez à prévenir au plus vite les responsables concernés pour qu'un remplacement puisse être organisé :",
+ "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_extras": " (+{count} autres)",
+ "programs_event_conflict_badge_singular": "Conflit",
+ "programs_event_conflict_badge_plural": "{count} conflits",
+ "programs_event_conflict_badge_aria": "Conflit d'affectation : {count} personne(s) absente(s)",
"days_off_admin_meta_title": "Absences - Unitae",
"days_off_admin_page_title": "Absences",
"days_off_admin_page_subtitle": "Liste de toutes les absences sur la période sélectionnée.",