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
64 changes: 44 additions & 20 deletions app/features/dashboard/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getUserTerritories,
type TerritoryStatus,
} from '~/features/dashboard/server/dashboard.server'
import { getResponsibleConflicts } from '~/features/dashboard/server/get-responsible-conflicts.server'
import { buildUrgentItems } from '~/features/dashboard/ui/build-urgent-items'
import { OnboardingChecklist } from '~/features/dashboard/ui/OnboardingChecklist'
import * as m from '~/i18n/paraglide/messages'
Expand Down Expand Up @@ -43,7 +44,12 @@ export function loader({ context }: Route.LoaderArgs) {
const permissions = context.get(permissionsContext)
const isAdmin = permissions.has(Permission.Admin)
const isTerritoriesManager = permissions.has(Permission.TerritoriesManager)
const isProgramManager = permissions.has(Permission.ProgramManager)
const canViewBoard = permissions.has(Permission.BoardViewer)
// The responsible-conflict card deep-links to /programs?hasConflicts=true.
// Gate the query the same way so we don't hand a user a link to a page
// they cannot open — mirrors the canViewBoard pattern below.
const canViewPrograms = permissions.has(Permission.ProgramViewer) || isProgramManager

// Member-bound queries (territories, programme assignments) need the linked
// Member id; account-bound queries (documents/views) use the UserAccount id.
Expand All @@ -54,25 +60,35 @@ export function loader({ context }: Route.LoaderArgs) {
return safeQuery(label, currentUser.id, () => run(memberId))
}

const [territories, recentDocuments, unreadDocumentCount, absences, nextMeeting, dayoffConflict] =
await Promise.all([
memberSafeQuery('territories', mid => getUserTerritories(db, mid)),
canViewBoard
? safeQuery('documents', currentUser.id, () =>
getRecentDocuments(db, currentUser.id, currentUser.congregationId),
)
: Promise.resolve(null),
canViewBoard
? safeQuery('unread-count', currentUser.id, () =>
getUnreadDocumentCount(db, currentUser.id, currentUser.congregationId),
)
: Promise.resolve(0),
safeQuery('absences', currentUser.id, () =>
getUpcomingAbsences(db, currentUser.id, currentUser.congregationId),
),
memberSafeQuery('next-meeting', mid => getNextMeeting(db, mid)),
memberSafeQuery('dayoff-conflict', mid => getConflictingAssignments(db, mid)),
])
const [
territories,
recentDocuments,
unreadDocumentCount,
absences,
nextMeeting,
dayoffConflict,
responsibleConflicts,
] = await Promise.all([
memberSafeQuery('territories', mid => getUserTerritories(db, mid)),
canViewBoard
? safeQuery('documents', currentUser.id, () =>
getRecentDocuments(db, currentUser.id, currentUser.congregationId),
)
: Promise.resolve(null),
canViewBoard
? safeQuery('unread-count', currentUser.id, () =>
getUnreadDocumentCount(db, currentUser.id, currentUser.congregationId),
)
: Promise.resolve(0),
safeQuery('absences', currentUser.id, () => getUpcomingAbsences(db, currentUser.id, currentUser.congregationId)),
memberSafeQuery('next-meeting', mid => getNextMeeting(db, mid)),
memberSafeQuery('dayoff-conflict', mid => getConflictingAssignments(db, mid)),
canViewPrograms
? safeQuery('responsible-conflicts', currentUser.id, () =>
getResponsibleConflicts(db, currentUser.id, isProgramManager),
)
: Promise.resolve(null),
])

// Onboarding: count entities for admin checklist
let onboarding = null
Expand All @@ -99,6 +115,7 @@ export function loader({ context }: Route.LoaderArgs) {
nextMeeting,
absences,
dayoffConflict,
responsibleConflicts,
onboarding,
isAdmin,
isTerritoriesManager,
Expand Down Expand Up @@ -135,6 +152,7 @@ export default function Dashboard({ loaderData }: Route.ComponentProps) {
nextMeeting,
absences,
dayoffConflict,
responsibleConflicts,
onboarding,
isAdmin,
isTerritoriesManager,
Expand All @@ -148,7 +166,13 @@ export default function Dashboard({ loaderData }: Route.ComponentProps) {
})

// Build urgent items from across features
const urgentItems = buildUrgentItems(territories, unreadDocumentCount, nextMeeting, dayoffConflict)
const urgentItems = buildUrgentItems(
territories,
unreadDocumentCount,
nextMeeting,
dayoffConflict,
responsibleConflicts,
)

return (
<div className="mx-auto flex max-w-6xl flex-col gap-8">
Expand Down
137 changes: 136 additions & 1 deletion app/features/dashboard/server/dashboard.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { PrismaClient } from '~/database/generated/client'
import { EventKind } from '~/features/events/model/event-kind.type'
import { TerritoryKind } from '~/features/territories/model/territory-kind.type'
import { PublisherType } from '~/shared/types/publisher-type'

const adapter = new PrismaPg({
connectionString: process.env.DB_RUNTIME_URL ?? process.env.DB_URL,
Expand Down Expand Up @@ -219,6 +218,7 @@ afterAll(async () => {

const { getUserTerritories, getRecentDocuments, getUnreadDocumentCount, getNextMeeting, getConflictingAssignments } =
await import('./dashboard.server')
const { getResponsibleConflicts } = await import('./get-responsible-conflicts.server')
const { refreshConflictFlags } = await import('~/features/events/server/programme-assignments.server')

// --- Tests ---
Expand Down Expand Up @@ -669,4 +669,139 @@ describe('getConflictingAssignments (integration)', () => {
})
}
})

// Invariant pin (responsible side): `getResponsibleConflicts` derives its
// result from the persisted `hasConflict` flag with no caching layer, so
// once the flag flips to `false` the responsible's card must vanish on
// the next read. Mirrors the absentee-side "refreshConflictFlags clears
// stale hasConflict and the alert disappears" pin earlier in this file.
it('getResponsibleConflicts drops the entry when hasConflict clears on the underlying assignment', async () => {
const setup = await withScope(congregationId, async tx => {
const eventKind = await tx.eventKind.findFirstOrThrow({
where: { congregationId, key: { not: EventKind.Off } },
})
const template = await tx.programmeTemplate.create({
data: {
name: 'Responsible Invariant Template',
key: `resp-invariant-template-${ts}`,
kindId: eventKind.id,
congregationId,
},
})
// Bob is the responsible for this template; Alice is the absentee.
await tx.programmeTemplateResponsible.create({
data: {
templateId: template.id,
userId: bobAccountId,
congregationId,
},
})
const event = await tx.event.create({
data: {
name: 'Responsible Invariant Event',
kindId: eventKind.id,
templateId: template.id,
startDate: new Date('2028-01-05T19:00:00Z'),
endDate: new Date('2028-01-05T21:00:00Z'),
createdById: aliceAccountId,
congregationId,
},
})
const part = await tx.programmePartAssignment.create({
data: {
eventId: event.id,
assigneeId: aliceId,
name: 'Discours',
section: 'main',
order: 1,
hasConflict: true,
congregationId,
},
})
return { templateId: template.id, eventId: event.id, partId: part.id }
})

try {
// Bob is the responsible; he sees the outstanding conflict on his template.
const before = await withScope(congregationId, tx => getResponsibleConflicts(tx, bobAccountId, false))
expect(before.count).toBe(1)
expect(before.absenteeNames).toEqual(['Alice Dupont'])

// Simulate resolution: the underlying assignment is no longer in conflict
// (either the absence went away or the assignment was reassigned).
await withScope(congregationId, tx =>
tx.programmePartAssignment.update({
where: { id_congregationId: { id: setup.partId, congregationId } },
data: { hasConflict: false },
}),
)

const after = await withScope(congregationId, tx => getResponsibleConflicts(tx, bobAccountId, false))
expect(after).toEqual({ count: 0, absenteeNames: [], totalAbsenteesCount: 0 })
} finally {
await withScope(congregationId, async tx => {
await tx.programmePartAssignment.delete({
where: { id_congregationId: { id: setup.partId, congregationId } },
})
await tx.event.delete({ where: { id_congregationId: { id: setup.eventId, congregationId } } })
await tx.programmeTemplateResponsible.deleteMany({ where: { templateId: setup.templateId } })
await tx.programmeTemplate.delete({
where: { id_congregationId: { id: setup.templateId, congregationId } },
})
})
}
})

// A ProgramManager should see conflicts on events they don't own via a
// template responsibility — including untemplated events, which have no
// responsibles at all. This pins the "manager sees everything" branch of
// the filter (the non-manager path is covered by unit tests).
it('getResponsibleConflicts includes untemplated events for ProgramManager users', async () => {
const setup = await withScope(congregationId, async tx => {
const eventKind = await tx.eventKind.findFirstOrThrow({
where: { congregationId, key: { not: EventKind.Off } },
})
const event = await tx.event.create({
data: {
name: 'Untemplated Manager Event',
kindId: eventKind.id,
templateId: null,
startDate: new Date('2028-02-10T19:00:00Z'),
endDate: new Date('2028-02-10T21:00:00Z'),
createdById: aliceAccountId,
congregationId,
},
})
const part = await tx.programmePartAssignment.create({
data: {
eventId: event.id,
assigneeId: aliceId,
name: 'Custom part',
section: 'main',
order: 1,
hasConflict: true,
congregationId,
},
})
return { eventId: event.id, partId: part.id }
})

try {
// Bob is neither a template responsible nor a manager — must see nothing.
const nonManager = await withScope(congregationId, tx => getResponsibleConflicts(tx, bobAccountId, false))
expect(nonManager.count).toBe(0)

// ProgramManager path — same query, isProgramManager=true — must include it.
const asManager = await withScope(congregationId, tx => getResponsibleConflicts(tx, bobAccountId, true))
expect(asManager.count).toBe(1)
expect(asManager.absenteeNames).toEqual(['Alice Dupont'])
} finally {
await withScope(congregationId, async tx => {
await tx.programmePartAssignment.delete({
where: { id_congregationId: { id: setup.partId, congregationId } },
})
await tx.event.delete({ where: { id_congregationId: { id: setup.eventId, congregationId } } })
})
}
})
})
Loading
Loading