diff --git a/app/database/migrations/20260720350000_rename_programme_to_event/migration.sql b/app/database/migrations/20260720350000_rename_programme_to_event/migration.sql new file mode 100644 index 00000000..59011650 --- /dev/null +++ b/app/database/migrations/20260720350000_rename_programme_to_event/migration.sql @@ -0,0 +1,111 @@ +-- Pure naming rename. Tables → new names; PKs / FKs / indexes get renamed +-- to match Prisma's naming convention. `ALTER TABLE … RENAME TO` preserves +-- rows, FK data, and RLS policies (they are bound to table OIDs, not names) +-- — the `20260507000000_rename_user_role_to_permission` precedent did the +-- same. Historical entityType strings on AuditLog and NotificationEvent are +-- rewritten in step 5 so audit queries stay uniform. + +-- 1. Table renames. +ALTER TABLE "ProgrammeTemplate" RENAME TO "EventTemplate"; +ALTER TABLE "ProgrammeTemplatePart" RENAME TO "TemplatePart"; +ALTER TABLE "ProgrammeTemplateServiceRole" RENAME TO "TemplateServiceRole"; +ALTER TABLE "ProgrammePartAssignment" RENAME TO "EventPart"; +ALTER TABLE "ProgrammeServiceRoleAssignment" RENAME TO "EventServiceRole"; +ALTER TABLE "ProgrammeTemplateResponsible" RENAME TO "TemplateResponsible"; +ALTER TABLE "ProgrammeTemplatePartAllowedRole" RENAME TO "TemplatePartAllowedRole"; +ALTER TABLE "ProgrammePartAssignmentAllowedRole" RENAME TO "EventPartAllowedRole"; +ALTER TABLE "ProgrammeTemplateServiceRoleAllowedRole" RENAME TO "TemplateServiceRoleAllowedRole"; +ALTER TABLE "ProgrammeServiceRoleAssignmentAllowedRole" RENAME TO "EventServiceRoleAllowedRole"; + +-- 2. Primary key constraint renames. +ALTER TABLE "EventTemplate" RENAME CONSTRAINT "ProgrammeTemplate_pkey" TO "EventTemplate_pkey"; +ALTER TABLE "TemplatePart" RENAME CONSTRAINT "ProgrammeTemplatePart_pkey" TO "TemplatePart_pkey"; +ALTER TABLE "TemplateServiceRole" RENAME CONSTRAINT "ProgrammeTemplateServiceRole_pkey" TO "TemplateServiceRole_pkey"; +ALTER TABLE "EventPart" RENAME CONSTRAINT "ProgrammePartAssignment_pkey" TO "EventPart_pkey"; +ALTER TABLE "EventServiceRole" RENAME CONSTRAINT "ProgrammeServiceRoleAssignment_pkey" TO "EventServiceRole_pkey"; +ALTER TABLE "TemplateResponsible" RENAME CONSTRAINT "ProgrammeTemplateResponsible_pkey" TO "TemplateResponsible_pkey"; +ALTER TABLE "TemplatePartAllowedRole" RENAME CONSTRAINT "ProgrammeTemplatePartAllowedRole_pkey" TO "TemplatePartAllowedRole_pkey"; +ALTER TABLE "EventPartAllowedRole" RENAME CONSTRAINT "ProgrammePartAssignmentAllowedRole_pkey" TO "EventPartAllowedRole_pkey"; +ALTER TABLE "TemplateServiceRoleAllowedRole" RENAME CONSTRAINT "ProgrammeTemplateServiceRoleAllowedRole_pkey" TO "TemplateServiceRoleAllowedRole_pkey"; +ALTER TABLE "EventServiceRoleAllowedRole" RENAME CONSTRAINT "ProgrammeServiceRoleAssignmentAllowedRole_pkey" TO "EventServiceRoleAllowedRole_pkey"; + +-- 3. Foreign key constraint renames. +ALTER TABLE "EventTemplate" RENAME CONSTRAINT "ProgrammeTemplate_congregationId_fkey" TO "EventTemplate_congregationId_fkey"; + +ALTER TABLE "TemplatePart" RENAME CONSTRAINT "ProgrammeTemplatePart_congregationId_fkey" TO "TemplatePart_congregationId_fkey"; +ALTER TABLE "TemplatePart" RENAME CONSTRAINT "ProgrammeTemplatePart_templateId_fkey" TO "TemplatePart_templateId_fkey"; + +ALTER TABLE "TemplateServiceRole" RENAME CONSTRAINT "ProgrammeTemplateServiceRole_congregationId_fkey" TO "TemplateServiceRole_congregationId_fkey"; +ALTER TABLE "TemplateServiceRole" RENAME CONSTRAINT "ProgrammeTemplateServiceRole_templateId_fkey" TO "TemplateServiceRole_templateId_fkey"; + +ALTER TABLE "EventPart" RENAME CONSTRAINT "ProgrammePartAssignment_assigneeId_fkey" TO "EventPart_assigneeId_fkey"; +ALTER TABLE "EventPart" RENAME CONSTRAINT "ProgrammePartAssignment_assistantId_fkey" TO "EventPart_assistantId_fkey"; +ALTER TABLE "EventPart" RENAME CONSTRAINT "ProgrammePartAssignment_congregationId_fkey" TO "EventPart_congregationId_fkey"; +ALTER TABLE "EventPart" RENAME CONSTRAINT "ProgrammePartAssignment_eventId_fkey" TO "EventPart_eventId_fkey"; +ALTER TABLE "EventPart" RENAME CONSTRAINT "ProgrammePartAssignment_externalSpeakerId_fkey" TO "EventPart_externalSpeakerId_fkey"; +ALTER TABLE "EventPart" RENAME CONSTRAINT "ProgrammePartAssignment_partId_fkey" TO "EventPart_partId_fkey"; + +ALTER TABLE "EventServiceRole" RENAME CONSTRAINT "ProgrammeServiceRoleAssignment_assigneeId_fkey" TO "EventServiceRole_assigneeId_fkey"; +ALTER TABLE "EventServiceRole" RENAME CONSTRAINT "ProgrammeServiceRoleAssignment_congregationId_fkey" TO "EventServiceRole_congregationId_fkey"; +ALTER TABLE "EventServiceRole" RENAME CONSTRAINT "ProgrammeServiceRoleAssignment_eventId_fkey" TO "EventServiceRole_eventId_fkey"; +ALTER TABLE "EventServiceRole" RENAME CONSTRAINT "ProgrammeServiceRoleAssignment_serviceRoleId_fkey" TO "EventServiceRole_serviceRoleId_fkey"; + +ALTER TABLE "TemplateResponsible" RENAME CONSTRAINT "ProgrammeTemplateResponsible_congregationId_fkey" TO "TemplateResponsible_congregationId_fkey"; +ALTER TABLE "TemplateResponsible" RENAME CONSTRAINT "ProgrammeTemplateResponsible_templateId_fkey" TO "TemplateResponsible_templateId_fkey"; +ALTER TABLE "TemplateResponsible" RENAME CONSTRAINT "ProgrammeTemplateResponsible_userId_fkey" TO "TemplateResponsible_userId_fkey"; + +ALTER TABLE "TemplatePartAllowedRole" RENAME CONSTRAINT "ProgrammeTemplatePartAllowedRole_congregationId_fkey" TO "TemplatePartAllowedRole_congregationId_fkey"; +ALTER TABLE "TemplatePartAllowedRole" RENAME CONSTRAINT "ProgrammeTemplatePartAllowedRole_partId_fkey" TO "TemplatePartAllowedRole_partId_fkey"; +ALTER TABLE "TemplatePartAllowedRole" RENAME CONSTRAINT "ProgrammeTemplatePartAllowedRole_roleId_fkey" TO "TemplatePartAllowedRole_roleId_fkey"; + +ALTER TABLE "EventPartAllowedRole" RENAME CONSTRAINT "ProgrammePartAssignmentAllowedRole_assignmentId_fkey" TO "EventPartAllowedRole_assignmentId_fkey"; +ALTER TABLE "EventPartAllowedRole" RENAME CONSTRAINT "ProgrammePartAssignmentAllowedRole_congregationId_fkey" TO "EventPartAllowedRole_congregationId_fkey"; +ALTER TABLE "EventPartAllowedRole" RENAME CONSTRAINT "ProgrammePartAssignmentAllowedRole_roleId_fkey" TO "EventPartAllowedRole_roleId_fkey"; + +ALTER TABLE "TemplateServiceRoleAllowedRole" RENAME CONSTRAINT "ProgrammeTemplateServiceRoleAllowedRole_congregationId_fkey" TO "TemplateServiceRoleAllowedRole_congregationId_fkey"; +ALTER TABLE "TemplateServiceRoleAllowedRole" RENAME CONSTRAINT "ProgrammeTemplateServiceRoleAllowedRole_roleId_fkey" TO "TemplateServiceRoleAllowedRole_roleId_fkey"; +ALTER TABLE "TemplateServiceRoleAllowedRole" RENAME CONSTRAINT "ProgrammeTemplateServiceRoleAllowedRole_serviceRoleId_fkey" TO "TemplateServiceRoleAllowedRole_serviceRoleId_fkey"; + +ALTER TABLE "EventServiceRoleAllowedRole" RENAME CONSTRAINT "ProgrammeServiceRoleAssignmentAllowedRole_assignmentId_fkey" TO "EventServiceRoleAllowedRole_assignmentId_fkey"; +ALTER TABLE "EventServiceRoleAllowedRole" RENAME CONSTRAINT "ProgrammeServiceRoleAssignmentAllowedRole_congregationId_fkey" TO "EventServiceRoleAllowedRole_congregationId_fkey"; +ALTER TABLE "EventServiceRoleAllowedRole" RENAME CONSTRAINT "ProgrammeServiceRoleAssignmentAllowedRole_roleId_fkey" TO "EventServiceRoleAllowedRole_roleId_fkey"; + +-- 4. Index renames. +ALTER INDEX "ProgrammeTemplate_id_congregationId_key" RENAME TO "EventTemplate_id_congregationId_key"; +ALTER INDEX "ProgrammeTemplate_key_congregationId_key" RENAME TO "EventTemplate_key_congregationId_key"; + +ALTER INDEX "ProgrammeTemplatePart_id_congregationId_key" RENAME TO "TemplatePart_id_congregationId_key"; + +ALTER INDEX "ProgrammeTemplateServiceRole_id_congregationId_key" RENAME TO "TemplateServiceRole_id_congregationId_key"; + +ALTER INDEX "ProgrammePartAssignment_externalSpeakerId_idx" RENAME TO "EventPart_externalSpeakerId_idx"; +ALTER INDEX "ProgrammePartAssignment_id_congregationId_key" RENAME TO "EventPart_id_congregationId_key"; + +ALTER INDEX "ProgrammeServiceRoleAssignment_id_congregationId_key" RENAME TO "EventServiceRole_id_congregationId_key"; + +ALTER INDEX "ProgrammeTemplateResponsible_id_congregationId_key" RENAME TO "TemplateResponsible_id_congregationId_key"; +ALTER INDEX "ProgrammeTemplateResponsible_templateId_congregationId_key" RENAME TO "TemplateResponsible_templateId_congregationId_key"; + +ALTER INDEX "ProgrammeTemplatePartAllowedRole_congregationId_idx" RENAME TO "TemplatePartAllowedRole_congregationId_idx"; +ALTER INDEX "ProgrammeTemplatePartAllowedRole_roleId_idx" RENAME TO "TemplatePartAllowedRole_roleId_idx"; + +ALTER INDEX "ProgrammePartAssignmentAllowedRole_congregationId_idx" RENAME TO "EventPartAllowedRole_congregationId_idx"; +ALTER INDEX "ProgrammePartAssignmentAllowedRole_roleId_idx" RENAME TO "EventPartAllowedRole_roleId_idx"; + +ALTER INDEX "ProgrammeTemplateServiceRoleAllowedRole_congregationId_idx" RENAME TO "TemplateServiceRoleAllowedRole_congregationId_idx"; +ALTER INDEX "ProgrammeTemplateServiceRoleAllowedRole_roleId_idx" RENAME TO "TemplateServiceRoleAllowedRole_roleId_idx"; + +ALTER INDEX "ProgrammeServiceRoleAssignmentAllowedRole_congregationId_idx" RENAME TO "EventServiceRoleAllowedRole_congregationId_idx"; +ALTER INDEX "ProgrammeServiceRoleAssignmentAllowedRole_roleId_idx" RENAME TO "EventServiceRoleAllowedRole_roleId_idx"; + +-- 5. Rewrite historical entityType strings so audit and notification +-- queries don't have to reason about both names forever. +UPDATE "AuditLog" SET "entityType" = 'EventTemplate' WHERE "entityType" = 'ProgrammeTemplate'; +UPDATE "AuditLog" SET "entityType" = 'TemplatePart' WHERE "entityType" = 'ProgrammeTemplatePart'; +UPDATE "AuditLog" SET "entityType" = 'TemplateServiceRole' WHERE "entityType" = 'ProgrammeTemplateServiceRole'; +UPDATE "AuditLog" SET "entityType" = 'EventPart' WHERE "entityType" = 'ProgrammePartAssignment'; +UPDATE "AuditLog" SET "entityType" = 'EventServiceRole' WHERE "entityType" = 'ProgrammeServiceRoleAssignment'; +UPDATE "AuditLog" SET "entityType" = 'TemplateResponsible' WHERE "entityType" = 'ProgrammeTemplateResponsible'; + +UPDATE "NotificationEvent" SET "entityType" = 'EventPart' WHERE "entityType" = 'ProgrammePartAssignment'; +UPDATE "NotificationEvent" SET "entityType" = 'EventServiceRole' WHERE "entityType" = 'ProgrammeServiceRoleAssignment'; diff --git a/app/database/migrations/20260720400000_finish_programme_to_event_cleanup/migration.sql b/app/database/migrations/20260720400000_finish_programme_to_event_cleanup/migration.sql new file mode 100644 index 00000000..f6e4a2df --- /dev/null +++ b/app/database/migrations/20260720400000_finish_programme_to_event_cleanup/migration.sql @@ -0,0 +1,36 @@ +-- Follow-up cleanup for `20260720350000_rename_programme_to_event`: +-- +-- 1. Rewrites `NotificationEvent.debounceKey` for still-pending rows so the +-- new event-part debounce lookup (`buildDebounceKey('EventPart', ...)`) +-- matches pre-rename entries. Without this, publishers reassigned in the +-- 30-min post-deploy window would get duplicated "assigned" mails and/or +-- out-of-order "unassigned" mails because the cancel/replace path filters +-- by `debounceKey`, which was computed from the old entityType string. +-- +-- 2. Renames the FK columns on the two join tables from `assignmentId` (the +-- original `ProgrammePartAssignmentAllowedRole` and +-- `ProgrammeServiceRoleAssignmentAllowedRole` column) to the target model +-- name (`eventPartId` / `eventServiceRoleId`). Keeps table/PK/FK/index +-- rename consistent with the model rename. + +-- 1. NotificationEvent.debounceKey rewrite — only for pending rows (sent +-- rows are historical and get auto-cleaned in 7-30 days). +UPDATE "NotificationEvent" +SET "debounceKey" = 'EventPart:' || substring("debounceKey" from length('ProgrammePartAssignment:') + 1) +WHERE "status" = 'pending' AND "debounceKey" LIKE 'ProgrammePartAssignment:%'; + +UPDATE "NotificationEvent" +SET "debounceKey" = 'EventServiceRole:' || substring("debounceKey" from length('ProgrammeServiceRoleAssignment:') + 1) +WHERE "status" = 'pending' AND "debounceKey" LIKE 'ProgrammeServiceRoleAssignment:%'; + +-- 2. Column renames on the two join tables. `ALTER TABLE ... RENAME COLUMN` +-- preserves indexes, constraints, and RLS policies bound to the column +-- since they reference the column by OID. +ALTER TABLE "EventPartAllowedRole" RENAME COLUMN "assignmentId" TO "eventPartId"; +ALTER TABLE "EventServiceRoleAllowedRole" RENAME COLUMN "assignmentId" TO "eventServiceRoleId"; + +-- 3. Rename the FK constraints to match the new column names. +ALTER TABLE "EventPartAllowedRole" + RENAME CONSTRAINT "EventPartAllowedRole_assignmentId_fkey" TO "EventPartAllowedRole_eventPartId_fkey"; +ALTER TABLE "EventServiceRoleAllowedRole" + RENAME CONSTRAINT "EventServiceRoleAllowedRole_assignmentId_fkey" TO "EventServiceRoleAllowedRole_eventServiceRoleId_fkey"; diff --git a/app/database/migrations/20260720500000_rename_service_role_to_service_part/migration.sql b/app/database/migrations/20260720500000_rename_service_role_to_service_part/migration.sql new file mode 100644 index 00000000..927af3c5 --- /dev/null +++ b/app/database/migrations/20260720500000_rename_service_role_to_service_part/migration.sql @@ -0,0 +1,76 @@ +-- Renames the "ServiceRole" family to "ServicePart" so the "role" word is +-- unambiguously reserved for permission Roles. The four join tables in this +-- feature (Template/Event × Part/ServicePart × AllowedRole) now all read +-- consistently: `AllowedRole` means "which Roles are allowed on this +-- part slot". +-- +-- Same shape as 20260720350000: ALTER TABLE ... RENAME preserves rows, FK +-- data, indexes, and RLS policies (bound to table OID, not name). Only the +-- catalog identifiers change. + +-- 1. Rename tables. +ALTER TABLE "TemplateServiceRole" RENAME TO "TemplateServicePart"; +ALTER TABLE "EventServiceRole" RENAME TO "EventServicePart"; +ALTER TABLE "TemplateServiceRoleAllowedRole" RENAME TO "TemplateServicePartAllowedRole"; +ALTER TABLE "EventServiceRoleAllowedRole" RENAME TO "EventServicePartAllowedRole"; + +-- 2. Rename FK columns that referenced the old model names. +ALTER TABLE "EventServicePart" RENAME COLUMN "serviceRoleId" TO "servicePartId"; +ALTER TABLE "TemplateServicePartAllowedRole" RENAME COLUMN "serviceRoleId" TO "servicePartId"; +ALTER TABLE "EventServicePartAllowedRole" RENAME COLUMN "eventServiceRoleId" TO "eventServicePartId"; + +-- 3. Rename primary-key constraints. +ALTER TABLE "TemplateServicePart" RENAME CONSTRAINT "TemplateServiceRole_pkey" TO "TemplateServicePart_pkey"; +ALTER TABLE "EventServicePart" RENAME CONSTRAINT "EventServiceRole_pkey" TO "EventServicePart_pkey"; +ALTER TABLE "TemplateServicePartAllowedRole" RENAME CONSTRAINT "TemplateServiceRoleAllowedRole_pkey" TO "TemplateServicePartAllowedRole_pkey"; +ALTER TABLE "EventServicePartAllowedRole" RENAME CONSTRAINT "EventServiceRoleAllowedRole_pkey" TO "EventServicePartAllowedRole_pkey"; + +-- 4. Rename foreign-key constraints to match Prisma's `NewName_col_fkey` +-- convention. Missing renames here only cause cosmetic drift in +-- `\d`; queries still work because Postgres resolves FKs by OID. +ALTER TABLE "TemplateServicePart" + RENAME CONSTRAINT "TemplateServiceRole_templateId_fkey" TO "TemplateServicePart_templateId_fkey"; +ALTER TABLE "TemplateServicePart" + RENAME CONSTRAINT "TemplateServiceRole_congregationId_fkey" TO "TemplateServicePart_congregationId_fkey"; + +ALTER TABLE "EventServicePart" + RENAME CONSTRAINT "EventServiceRole_eventId_fkey" TO "EventServicePart_eventId_fkey"; +ALTER TABLE "EventServicePart" + RENAME CONSTRAINT "EventServiceRole_serviceRoleId_fkey" TO "EventServicePart_servicePartId_fkey"; +ALTER TABLE "EventServicePart" + RENAME CONSTRAINT "EventServiceRole_assigneeId_fkey" TO "EventServicePart_assigneeId_fkey"; +ALTER TABLE "EventServicePart" + RENAME CONSTRAINT "EventServiceRole_congregationId_fkey" TO "EventServicePart_congregationId_fkey"; + +ALTER TABLE "TemplateServicePartAllowedRole" + RENAME CONSTRAINT "TemplateServiceRoleAllowedRole_serviceRoleId_fkey" TO "TemplateServicePartAllowedRole_servicePartId_fkey"; +ALTER TABLE "TemplateServicePartAllowedRole" + RENAME CONSTRAINT "TemplateServiceRoleAllowedRole_roleId_fkey" TO "TemplateServicePartAllowedRole_roleId_fkey"; +ALTER TABLE "TemplateServicePartAllowedRole" + RENAME CONSTRAINT "TemplateServiceRoleAllowedRole_congregationId_fkey" TO "TemplateServicePartAllowedRole_congregationId_fkey"; + +ALTER TABLE "EventServicePartAllowedRole" + RENAME CONSTRAINT "EventServiceRoleAllowedRole_eventServiceRoleId_fkey" TO "EventServicePartAllowedRole_eventServicePartId_fkey"; +ALTER TABLE "EventServicePartAllowedRole" + RENAME CONSTRAINT "EventServiceRoleAllowedRole_roleId_fkey" TO "EventServicePartAllowedRole_roleId_fkey"; +ALTER TABLE "EventServicePartAllowedRole" + RENAME CONSTRAINT "EventServiceRoleAllowedRole_congregationId_fkey" TO "EventServicePartAllowedRole_congregationId_fkey"; + +-- 5. Rename unique indexes. +ALTER INDEX "TemplateServiceRole_id_congregationId_key" RENAME TO "TemplateServicePart_id_congregationId_key"; +ALTER INDEX "EventServiceRole_id_congregationId_key" RENAME TO "EventServicePart_id_congregationId_key"; + +-- 6. Rewrite historical entityType strings so AuditLog readers see the new +-- model name for every past mutation (mirrors the Programme→Event fix in +-- 20260720350000). +UPDATE "AuditLog" SET "entityType" = 'TemplateServicePart' WHERE "entityType" = 'TemplateServiceRole'; +UPDATE "AuditLog" SET "entityType" = 'EventServicePart' WHERE "entityType" = 'EventServiceRole'; +UPDATE "NotificationEvent" SET "entityType" = 'EventServicePart' WHERE "entityType" = 'EventServiceRole'; + +-- 7. Rewrite still-pending NotificationEvent debounce keys so the new +-- dispatchAssignmentDiffs lookup (`buildDebounceKey('EventServicePart', +-- ...)`) matches entries queued before the deploy. Sent rows are +-- historical and get pruned in 7-30 days. +UPDATE "NotificationEvent" +SET "debounceKey" = 'EventServicePart:' || substring("debounceKey" from length('EventServiceRole:') + 1) +WHERE "status" = 'pending' AND "debounceKey" LIKE 'EventServiceRole:%'; diff --git a/app/database/schema.prisma b/app/database/schema.prisma index 5a1c3394..e8ee5e43 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -92,16 +92,16 @@ model Congregation { consentRecords ConsentRecord[] auditLogs AuditLog[] - programmeTemplates ProgrammeTemplate[] - programmeTemplateParts ProgrammeTemplatePart[] - programmeTemplateServiceRoles ProgrammeTemplateServiceRole[] - programmePartAssignments ProgrammePartAssignment[] - programmeServiceRoleAssignments ProgrammeServiceRoleAssignment[] - programmeTemplateResponsibles ProgrammeTemplateResponsible[] - programmeTemplatePartAllowedRoles ProgrammeTemplatePartAllowedRole[] - programmePartAssignmentAllowedRoles ProgrammePartAssignmentAllowedRole[] - programmeTemplateServiceRoleAllowedRoles ProgrammeTemplateServiceRoleAllowedRole[] - programmeServiceRoleAssignmentAllowedRoles ProgrammeServiceRoleAssignmentAllowedRole[] + eventTemplates EventTemplate[] + templateParts TemplatePart[] + templateServiceParts TemplateServicePart[] + eventParts EventPart[] + eventServiceParts EventServicePart[] + templateResponsibles TemplateResponsible[] + templatePartAllowedRoles TemplatePartAllowedRole[] + eventPartAllowedRoles EventPartAllowedRole[] + templateServicePartAllowedRoles TemplateServicePartAllowedRole[] + eventServicePartAllowedRoles EventServicePartAllowedRole[] externalSpeakers ExternalSpeaker[] notificationEvents NotificationEvent[] @@ -175,9 +175,9 @@ model Member { // Domain relations (FK target Member.id) attributions Attribution[] @relation("publisher") activities PublisherActivity[] @relation("publisherActivity") - partAssignments ProgrammePartAssignment[] @relation("partAssignee") - partAssistantAssignments ProgrammePartAssignment[] @relation("partAssistant") - serviceRoleAssignments ProgrammeServiceRoleAssignment[] @relation("serviceRoleAssignee") + eventPartsAsAssignee EventPart[] @relation("partAssignee") + eventPartsAsAssistant EventPart[] @relation("partAssistant") + eventServicePartsAsAssignee EventServicePart[] @relation("servicePartAssignee") // Identity-role assignments (built-in roles like brother/elder/pioneer plus // any custom role the user may add later that expresses identity) @@ -228,7 +228,7 @@ model UserAccount { documentsViewed BoardDocument[] @relation("viewedBy") documentVersionUploads BoardDocumentVersion[] @relation("boardDocumentVersionUploader") dynamicDocumentViews BoardDynamicDocumentView[] - templateResponsibilities ProgrammeTemplateResponsible[] @relation("templateResponsible") + templateResponsibilities TemplateResponsible[] @relation("templateResponsible") passwordResetTokens PasswordResetToken[] emailVerificationTokens EmailVerificationToken[] calendarFeedTokens CalendarFeedToken[] @@ -259,10 +259,10 @@ model Role { members UserRoleAssignment[] memberAssignments MemberRoleAssignment[] - allowedForTemplateParts ProgrammeTemplatePartAllowedRole[] - allowedForPartAssignments ProgrammePartAssignmentAllowedRole[] - allowedForTemplateServiceRoles ProgrammeTemplateServiceRoleAllowedRole[] - allowedForServiceRoleAssignments ProgrammeServiceRoleAssignmentAllowedRole[] + allowedForTemplateParts TemplatePartAllowedRole[] + allowedForEventParts EventPartAllowedRole[] + allowedForTemplateServiceParts TemplateServicePartAllowedRole[] + allowedForEventServiceParts EventServicePartAllowedRole[] allowedForBoardSections BoardSectionVisibilityRole[] congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade) @@ -649,11 +649,11 @@ model Event { endDate DateTime status String @default("draft") - template ProgrammeTemplate? @relation("templateEvents", fields: [templateId], references: [id]) + template EventTemplate? @relation("templateEvents", fields: [templateId], references: [id]) templateId Int? - partAssignments ProgrammePartAssignment[] - serviceRoleAssignments ProgrammeServiceRoleAssignment[] + eventParts EventPart[] + eventServiceParts EventServicePart[] createdBy UserAccount @relation("createdBy", fields: [createdById], references: [id]) createdById Int @@ -666,7 +666,7 @@ model Event { @@unique([id, congregationId]) } -model ProgrammeTemplate { +model EventTemplate { id Int @id @default(autoincrement()) name String key String @@ -677,10 +677,10 @@ model ProgrammeTemplate { endTime String @default("21:00") color String @default("#cfcfcf") - parts ProgrammeTemplatePart[] - serviceRoles ProgrammeTemplateServiceRole[] + parts TemplatePart[] + serviceParts TemplateServicePart[] events Event[] @relation("templateEvents") - responsibles ProgrammeTemplateResponsible[] + responsibles TemplateResponsible[] congregation Congregation @relation(fields: [congregationId], references: [id]) congregationId Int @@ -692,7 +692,7 @@ model ProgrammeTemplate { @@unique([id, congregationId]) } -model ProgrammeTemplatePart { +model TemplatePart { id Int @id @default(autoincrement()) name String section String @default("") @@ -706,11 +706,11 @@ model ProgrammeTemplatePart { speakerLabel String? readerLabel String? - template ProgrammeTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade) + template EventTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade) templateId Int - assignments ProgrammePartAssignment[] - allowedRoles ProgrammeTemplatePartAllowedRole[] + eventParts EventPart[] + allowedRoles TemplatePartAllowedRole[] congregation Congregation @relation(fields: [congregationId], references: [id]) congregationId Int @@ -718,16 +718,16 @@ model ProgrammeTemplatePart { @@unique([id, congregationId]) } -model ProgrammeTemplateServiceRole { +model TemplateServicePart { id Int @id @default(autoincrement()) name String key String - template ProgrammeTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade) + template EventTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade) templateId Int - assignments ProgrammeServiceRoleAssignment[] - allowedRoles ProgrammeTemplateServiceRoleAllowedRole[] + eventServiceParts EventServicePart[] + allowedRoles TemplateServicePartAllowedRole[] congregation Congregation @relation(fields: [congregationId], references: [id]) congregationId Int @@ -735,7 +735,7 @@ model ProgrammeTemplateServiceRole { @@unique([id, congregationId]) } -model ProgrammePartAssignment { +model EventPart { id Int @id @default(autoincrement()) topic String @default("") note String @default("") @@ -757,7 +757,7 @@ model ProgrammePartAssignment { event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) eventId Int - part ProgrammeTemplatePart? @relation(fields: [partId], references: [id], onDelete: SetNull) + part TemplatePart? @relation(fields: [partId], references: [id], onDelete: SetNull) partId Int? assignee Member? @relation("partAssignee", fields: [assigneeId], references: [id]) @@ -771,7 +771,7 @@ model ProgrammePartAssignment { externalSpeaker ExternalSpeaker? @relation(fields: [externalSpeakerId], references: [id], onDelete: SetNull) externalSpeakerId Int? - allowedRoles ProgrammePartAssignmentAllowedRole[] + allowedRoles EventPartAllowedRole[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -792,7 +792,7 @@ model ExternalSpeaker { notes String? archivedAt DateTime? - partAssignments ProgrammePartAssignment[] + eventParts EventPart[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -804,24 +804,24 @@ model ExternalSpeaker { @@index([congregationId, archivedAt]) } -model ProgrammeServiceRoleAssignment { +model EventServicePart { id Int @id @default(autoincrement()) note String @default("") hasConflict Boolean @default(false) - // Inline field (copied from template service role on generation, or set manually) + // Inline field (copied from template service part on generation, or set manually) name String @default("") event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) eventId Int - serviceRole ProgrammeTemplateServiceRole? @relation(fields: [serviceRoleId], references: [id], onDelete: SetNull) - serviceRoleId Int? + servicePart TemplateServicePart? @relation(fields: [servicePartId], references: [id], onDelete: SetNull) + servicePartId Int? - assignee Member? @relation("serviceRoleAssignee", fields: [assigneeId], references: [id]) + assignee Member? @relation("servicePartAssignee", fields: [assigneeId], references: [id]) assigneeId Int? - allowedRoles ProgrammeServiceRoleAssignmentAllowedRole[] + allowedRoles EventServicePartAllowedRole[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -832,8 +832,8 @@ model ProgrammeServiceRoleAssignment { @@unique([id, congregationId]) } -model ProgrammeTemplatePartAllowedRole { - part ProgrammeTemplatePart @relation(fields: [partId], references: [id], onDelete: Cascade) +model TemplatePartAllowedRole { + part TemplatePart @relation(fields: [partId], references: [id], onDelete: Cascade) partId Int role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) roleId Int @@ -847,53 +847,53 @@ model ProgrammeTemplatePartAllowedRole { @@index([congregationId]) } -model ProgrammePartAssignmentAllowedRole { - assignment ProgrammePartAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) - assignmentId Int - role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) - roleId Int - asKind String +model EventPartAllowedRole { + eventPart EventPart @relation(fields: [eventPartId], references: [id], onDelete: Cascade) + eventPartId Int + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + roleId Int + asKind String congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade) congregationId Int - @@id([assignmentId, roleId, asKind]) + @@id([eventPartId, roleId, asKind]) @@index([roleId]) @@index([congregationId]) } -model ProgrammeTemplateServiceRoleAllowedRole { - serviceRole ProgrammeTemplateServiceRole @relation(fields: [serviceRoleId], references: [id], onDelete: Cascade) - serviceRoleId Int - role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) +model TemplateServicePartAllowedRole { + servicePart TemplateServicePart @relation(fields: [servicePartId], references: [id], onDelete: Cascade) + servicePartId Int + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) roleId Int congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade) congregationId Int - @@id([serviceRoleId, roleId]) + @@id([servicePartId, roleId]) @@index([roleId]) @@index([congregationId]) } -model ProgrammeServiceRoleAssignmentAllowedRole { - assignment ProgrammeServiceRoleAssignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade) - assignmentId Int - role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) - roleId Int +model EventServicePartAllowedRole { + eventServicePart EventServicePart @relation(fields: [eventServicePartId], references: [id], onDelete: Cascade) + eventServicePartId Int + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + roleId Int congregation Congregation @relation(fields: [congregationId], references: [id], onDelete: Cascade) congregationId Int - @@id([assignmentId, roleId]) + @@id([eventServicePartId, roleId]) @@index([roleId]) @@index([congregationId]) } -model ProgrammeTemplateResponsible { +model TemplateResponsible { id Int @id @default(autoincrement()) - template ProgrammeTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade) + template EventTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade) templateId Int user UserAccount @relation("templateResponsible", fields: [userId], references: [id], onDelete: Cascade) diff --git a/app/database/seed-marketing.ts b/app/database/seed-marketing.ts index eaf7dab3..8baeb8d2 100644 --- a/app/database/seed-marketing.ts +++ b/app/database/seed-marketing.ts @@ -10,13 +10,13 @@ * - 18 territories with buildings & entrances * - 12 months of activity reports * - Board sections with documents - * - Events with programme assignments + * - Events with parts and service roles * - Attributions (active + historical) */ import 'dotenv/config' import { randomBytes, scrypt } from 'node:crypto' import { PrismaPg } from '@prisma/adapter-pg' -import { ProgrammeTemplateKey } from '../features/events/model/programme-template.type' +import { EventTemplateKey } from '../features/events/model/event-template.type' import { seedDefaultTemplates } from '../features/events/server/seed-templates.server' import { EntranceKind } from '../features/territories/model/entrance-kind.type' import { TerritoryAttributionKind } from '../features/territories/model/territory-attribution-kind.type' @@ -519,20 +519,20 @@ const BOARD_SECTIONS = [ // --------------------------------------------------------------------------- async function cleanCongregationData(congregationId: number) { - await prisma.programmeServiceRoleAssignment.deleteMany({ + await prisma.eventServicePart.deleteMany({ where: { congregationId }, }) - await prisma.programmePartAssignment.deleteMany({ + await prisma.eventPart.deleteMany({ where: { congregationId }, }) - await prisma.programmeTemplateResponsible.deleteMany({ + await prisma.templateResponsible.deleteMany({ where: { congregationId }, }) - await prisma.programmeTemplateServiceRole.deleteMany({ + await prisma.templateServicePart.deleteMany({ where: { congregationId }, }) - await prisma.programmeTemplatePart.deleteMany({ where: { congregationId } }) - await prisma.programmeTemplate.deleteMany({ where: { congregationId } }) + await prisma.templatePart.deleteMany({ where: { congregationId } }) + await prisma.eventTemplate.deleteMany({ where: { congregationId } }) await prisma.event.deleteMany({ where: { congregationId } }) await prisma.boardDynamicDocumentView.deleteMany({ where: { settings: { congregationId } }, @@ -625,14 +625,14 @@ async function main() { console.log(` ✓ Congregation "${congregation.name}" (id=${congId})`) - // ── Programme templates ─────────────────────────────────────────────── + // ── Event templates ─────────────────────────────────────────────────── await seedDefaultTemplates(prisma, congId, 'fr') - const dayOffTemplate = await prisma.programmeTemplate.findFirstOrThrow({ - where: { key: ProgrammeTemplateKey.DayOff, congregationId: congId }, + const dayOffTemplate = await prisma.eventTemplate.findFirstOrThrow({ + where: { key: EventTemplateKey.DayOff, congregationId: congId }, }) - console.log(' ✓ Programme templates') + console.log(' ✓ Event templates') // ── Users / Publishers ──────────────────────────────────────────────── // `id` = Member id (used as publisherId/assigneeId everywhere); `accountId` = @@ -1078,14 +1078,14 @@ async function main() { // ── Events (past meetings + upcoming + days off) ────────────────────── let eventCount = 0 - const midweekTemplate = await prisma.programmeTemplate.findFirst({ - where: { key: ProgrammeTemplateKey.MidweekMeeting, congregationId: congId }, - include: { parts: true, serviceRoles: true }, + const midweekTemplate = await prisma.eventTemplate.findFirst({ + where: { key: EventTemplateKey.MidweekMeeting, congregationId: congId }, + include: { parts: true, serviceParts: true }, }) - const weekendTemplate = await prisma.programmeTemplate.findFirst({ - where: { key: ProgrammeTemplateKey.WeekendMeeting, congregationId: congId }, - include: { parts: true, serviceRoles: true }, + const weekendTemplate = await prisma.eventTemplate.findFirst({ + where: { key: EventTemplateKey.WeekendMeeting, congregationId: congId }, + include: { parts: true, serviceParts: true }, }) // Generate 8 weeks of meetings (past 4 + future 4) @@ -1117,7 +1117,7 @@ async function main() { for (const part of midweekTemplate.parts) { const assignee = pick(createdUsers) const needsAssistant = !part.durationMin && Math.random() > 0.5 - await prisma.programmePartAssignment.create({ + await prisma.eventPart.create({ data: { name: part.name, section: part.section, @@ -1134,12 +1134,12 @@ async function main() { } // Create service role assignments - for (const role of midweekTemplate.serviceRoles) { - await prisma.programmeServiceRoleAssignment.create({ + for (const role of midweekTemplate.serviceParts) { + await prisma.eventServicePart.create({ data: { name: role.name, eventId: event.id, - serviceRoleId: role.id, + servicePartId: role.id, assigneeId: pick(createdUsers).id, congregationId: congId, }, @@ -1170,7 +1170,7 @@ async function main() { }) for (const part of weekendTemplate.parts) { - await prisma.programmePartAssignment.create({ + await prisma.eventPart.create({ data: { name: part.name, section: part.section, @@ -1185,12 +1185,12 @@ async function main() { }) } - for (const role of weekendTemplate.serviceRoles) { - await prisma.programmeServiceRoleAssignment.create({ + for (const role of weekendTemplate.serviceParts) { + await prisma.eventServicePart.create({ data: { name: role.name, eventId: event.id, - serviceRoleId: role.id, + servicePartId: role.id, assigneeId: pick(createdUsers).id, congregationId: congId, }, @@ -1223,7 +1223,7 @@ async function main() { eventCount++ } - console.log(` ✓ ${eventCount} events with programme assignments`) + console.log(` ✓ ${eventCount} events with parts and service roles`) console.log('\n✅ Marketing seed complete!') console.log(` Login: marc.dupont@demo.unitae.app / demo1234`) diff --git a/app/features/authentication/server/register-congregation.server.test.ts b/app/features/authentication/server/register-congregation.server.test.ts index d9134b10..97302c5a 100644 --- a/app/features/authentication/server/register-congregation.server.test.ts +++ b/app/features/authentication/server/register-congregation.server.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const scopedDb = { eventKind: { upsert: vi.fn() }, - programmeTemplate: { findFirst: vi.fn(), create: vi.fn() }, + eventTemplate: { findFirst: vi.fn(), create: vi.fn() }, role: { upsert: vi.fn(), findUnique: vi.fn() }, permission: { findUnique: vi.fn() }, rolePermission: { upsert: vi.fn() }, @@ -40,8 +40,8 @@ beforeEach(() => { vi.mocked(db.permission.findUnique).mockResolvedValue({ id: 5, key: 'admin' } as never) vi.mocked(db.congregationUserPermission.create).mockResolvedValue({} as never) scopedDb.eventKind.upsert.mockResolvedValue({} as never) - scopedDb.programmeTemplate.findFirst.mockResolvedValue(null as never) - scopedDb.programmeTemplate.create.mockResolvedValue({} as never) + scopedDb.eventTemplate.findFirst.mockResolvedValue(null as never) + scopedDb.eventTemplate.create.mockResolvedValue({} as never) }) describe('registerCongregation', () => { diff --git a/app/features/authentication/server/setup-first-account.server.test.ts b/app/features/authentication/server/setup-first-account.server.test.ts index a375c925..fc289dec 100644 --- a/app/features/authentication/server/setup-first-account.server.test.ts +++ b/app/features/authentication/server/setup-first-account.server.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const scopedDb = { eventKind: { upsert: vi.fn() }, - programmeTemplate: { findFirst: vi.fn(), create: vi.fn() }, + eventTemplate: { findFirst: vi.fn(), create: vi.fn() }, role: { upsert: vi.fn(), findUnique: vi.fn() }, permission: { findUnique: vi.fn() }, rolePermission: { upsert: vi.fn() }, @@ -39,8 +39,8 @@ beforeEach(() => { vi.mocked(db.permission.findUnique).mockResolvedValue({ id: 5, key: 'admin' } as never) vi.mocked(db.congregationUserPermission.create).mockResolvedValue({} as never) scopedDb.eventKind.upsert.mockResolvedValue({} as never) - scopedDb.programmeTemplate.findFirst.mockResolvedValue(null as never) - scopedDb.programmeTemplate.create.mockResolvedValue({} as never) + scopedDb.eventTemplate.findFirst.mockResolvedValue(null as never) + scopedDb.eventTemplate.create.mockResolvedValue({} as never) }) describe('setupFirstAccount', () => { diff --git a/app/features/dashboard/routes/index.tsx b/app/features/dashboard/routes/index.tsx index 8e9a826c..c04bc465 100644 --- a/app/features/dashboard/routes/index.tsx +++ b/app/features/dashboard/routes/index.tsx @@ -331,7 +331,7 @@ function NextMeetingCard({ meeting }: { meeting: Awaited 0 || meeting.userServiceRoleIds.length > 0 + const hasUserAssignments = meeting.userPartIds.length > 0 || meeting.userServicePartIds.length > 0 return ( @@ -348,7 +348,7 @@ function NextMeetingCard({ meeting }: { meeting: Awaited{m.dashboard_next_meeting_no_assignments()}

) : (
- {meeting.partAssignments + {meeting.eventParts .filter(p => meeting.userPartIds.includes(p.id)) .map(part => { const roleLabel = part.viewerRole === 'reader' ? partReaderLabel(part) : partSpeakerLabel(part) @@ -365,8 +365,8 @@ function NextMeetingCard({ meeting }: { meeting: Awaited ) })} - {meeting.serviceRoleAssignments - .filter(r => meeting.userServiceRoleIds.includes(r.id)) + {meeting.eventServiceParts + .filter(r => meeting.userServicePartIds.includes(r.id)) .map(role => (
{role.name} diff --git a/app/features/dashboard/server/dashboard.integration.test.ts b/app/features/dashboard/server/dashboard.integration.test.ts index 426ca695..687c1fc5 100644 --- a/app/features/dashboard/server/dashboard.integration.test.ts +++ b/app/features/dashboard/server/dashboard.integration.test.ts @@ -134,7 +134,7 @@ beforeAll(async () => { }) // Future event with programme assignments - const eventKind = await tx.programmeTemplate.create({ + const eventKind = await tx.eventTemplate.create({ data: { name: 'Midweek', key: `midweek-${ts}`, color: '#00aa00', congregationId }, }) @@ -150,7 +150,7 @@ beforeAll(async () => { }, }) - await tx.programmePartAssignment.create({ + await tx.eventPart.create({ data: { eventId: futureEvent.id, assigneeId: aliceId, @@ -163,7 +163,7 @@ beforeAll(async () => { }, }) - await tx.programmePartAssignment.create({ + await tx.eventPart.create({ data: { eventId: futureEvent.id, assigneeId: bobId, @@ -174,7 +174,7 @@ beforeAll(async () => { }, }) - await tx.programmeServiceRoleAssignment.create({ + await tx.eventServicePart.create({ data: { eventId: futureEvent.id, assigneeId: bobId, @@ -200,10 +200,10 @@ beforeAll(async () => { afterAll(async () => { await withScope(congregationId, async tx => { - await tx.programmeServiceRoleAssignment.deleteMany({ where: { congregationId } }) - await tx.programmePartAssignment.deleteMany({ where: { congregationId } }) + await tx.eventServicePart.deleteMany({ where: { congregationId } }) + await tx.eventPart.deleteMany({ where: { congregationId } }) await tx.event.deleteMany({ where: { congregationId } }) - await tx.programmeTemplate.deleteMany({ where: { congregationId } }) + await tx.eventTemplate.deleteMany({ where: { congregationId } }) await tx.boardDocument.deleteMany({ where: { congregationId } }) await tx.boardSection.deleteMany({ where: { congregationId } }) await tx.attribution.deleteMany({ where: { congregationId } }) @@ -220,7 +220,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') +const { refreshConflictFlags } = await import('~/features/events/server/event-part-assignments.server') // --- Tests --- @@ -268,14 +268,14 @@ describe('getNextMeeting (integration)', () => { const result = await withScope(congregationId, tx => getNextMeeting(tx, aliceId)) expect(result).not.toBeNull() expect(result?.name).toContain('Future Meeting') - expect(result?.partAssignments.length).toBeGreaterThanOrEqual(2) - expect(result?.serviceRoleAssignments.length).toBeGreaterThanOrEqual(1) + expect(result?.eventParts.length).toBeGreaterThanOrEqual(2) + expect(result?.eventServiceParts.length).toBeGreaterThanOrEqual(1) }) it('identifies parts assigned to the user (as assignee)', async () => { const result = await withScope(congregationId, tx => getNextMeeting(tx, aliceId)) expect(result?.userPartIds).toHaveLength(1) - const userPart = result?.partAssignments.find(p => result.userPartIds.includes(p.id)) + const userPart = result?.eventParts.find(p => result.userPartIds.includes(p.id)) expect(userPart?.name).toBe('Talk') }) @@ -283,7 +283,7 @@ describe('getNextMeeting (integration)', () => { const result = await withScope(congregationId, tx => getNextMeeting(tx, bobId)) // Bob is assistant on Talk and assignee on Reading expect(result?.userPartIds).toHaveLength(2) - expect(result?.userServiceRoleIds).toHaveLength(1) + expect(result?.userServicePartIds).toHaveLength(1) }) it('does not return past events', async () => { @@ -293,7 +293,7 @@ describe('getNextMeeting (integration)', () => { it("ignores another user's day off when picking the next meeting", async () => { const offEventId = await withScope(congregationId, async tx => { - const offKind = await tx.programmeTemplate.create({ + const offKind = await tx.eventTemplate.create({ data: { name: 'Absence', key: 'day-off', color: '#888888', congregationId }, }) const off = await tx.event.create({ @@ -316,7 +316,7 @@ describe('getNextMeeting (integration)', () => { } finally { await withScope(congregationId, async tx => { await tx.event.delete({ where: { id_congregationId: { id: offEventId, congregationId } } }) - await tx.programmeTemplate.deleteMany({ where: { key: 'day-off', congregationId } }) + await tx.eventTemplate.deleteMany({ where: { key: 'day-off', congregationId } }) }) } }) @@ -327,8 +327,8 @@ describe('getConflictingAssignments (integration)', () => { name: string startDate: Date endDate?: Date - parts?: { assigneeId?: number | null; assistantId?: number | null; hasConflict?: boolean; name?: string }[] - serviceRoles?: { assigneeId: number; hasConflict?: boolean; name?: string }[] + eventParts?: { assigneeId?: number | null; assistantId?: number | null; hasConflict?: boolean; name?: string }[] + eventServiceParts?: { assigneeId: number; hasConflict?: boolean; name?: string }[] cong?: number createdById?: number } @@ -336,7 +336,7 @@ describe('getConflictingAssignments (integration)', () => { function seedEvent(opts: SeedOpts) { const cong = opts.cong ?? congregationId return withScope(cong, async tx => { - const eventKind = await tx.programmeTemplate.findFirstOrThrow({ + const eventKind = await tx.eventTemplate.findFirstOrThrow({ where: { congregationId: cong, key: { not: 'day-off' } }, }) const event = await tx.event.create({ @@ -354,8 +354,8 @@ describe('getConflictingAssignments (integration)', () => { }, }) const partIds = await Promise.all( - (opts.parts ?? []).map(async (p, i) => { - const created = await tx.programmePartAssignment.create({ + (opts.eventParts ?? []).map(async (p, i) => { + const created = await tx.eventPart.create({ data: { eventId: event.id, assigneeId: p.assigneeId ?? null, @@ -370,9 +370,9 @@ describe('getConflictingAssignments (integration)', () => { return created.id }), ) - const serviceRoleIds = await Promise.all( - (opts.serviceRoles ?? []).map(async sr => { - const created = await tx.programmeServiceRoleAssignment.create({ + const servicePartIds = await Promise.all( + (opts.eventServiceParts ?? []).map(async sr => { + const created = await tx.eventServicePart.create({ data: { eventId: event.id, assigneeId: sr.assigneeId, @@ -384,14 +384,14 @@ describe('getConflictingAssignments (integration)', () => { return created.id }), ) - return { eventId: event.id, partIds, serviceRoleIds } + return { eventId: event.id, partIds, servicePartIds } }) } async function cleanupEvent(eventId: number, cong: number = congregationId) { await withScope(cong, async tx => { - await tx.programmeServiceRoleAssignment.deleteMany({ where: { eventId, congregationId: cong } }) - await tx.programmePartAssignment.deleteMany({ where: { eventId, congregationId: cong } }) + await tx.eventServicePart.deleteMany({ where: { eventId, congregationId: cong } }) + await tx.eventPart.deleteMany({ where: { eventId, congregationId: cong } }) await tx.event.delete({ where: { id_congregationId: { id: eventId, congregationId: cong } } }) }) } @@ -400,7 +400,7 @@ describe('getConflictingAssignments (integration)', () => { const seeded = await seedEvent({ name: 'Assignee Conflict', startDate: new Date('2027-08-01T19:00:00Z'), - parts: [{ assigneeId: aliceId, hasConflict: true, name: 'Discours' }], + eventParts: [{ assigneeId: aliceId, hasConflict: true, name: 'Discours' }], }) try { const result = await withScope(congregationId, tx => getConflictingAssignments(tx, aliceId)) @@ -417,7 +417,7 @@ describe('getConflictingAssignments (integration)', () => { const seeded = await seedEvent({ name: 'Assistant Conflict', startDate: new Date('2027-08-02T19:00:00Z'), - parts: [{ assigneeId: bobId, assistantId: aliceId, hasConflict: true, name: 'Lecture' }], + eventParts: [{ assigneeId: bobId, assistantId: aliceId, hasConflict: true, name: 'Lecture' }], }) try { const result = await withScope(congregationId, tx => getConflictingAssignments(tx, aliceId)) @@ -432,12 +432,12 @@ describe('getConflictingAssignments (integration)', () => { const seeded = await seedEvent({ name: 'Service Role Conflict', startDate: new Date('2027-08-03T19:00:00Z'), - serviceRoles: [{ assigneeId: aliceId, hasConflict: true, name: 'Son' }], + eventServiceParts: [{ assigneeId: aliceId, hasConflict: true, name: 'Son' }], }) try { const result = await withScope(congregationId, tx => getConflictingAssignments(tx, aliceId)) expect(result?.kind).toBe('service-role') - expect(result?.id).toBe(seeded.serviceRoleIds[0]) + expect(result?.id).toBe(seeded.servicePartIds[0]) expect(result?.name).toBe('Son') } finally { await cleanupEvent(seeded.eventId) @@ -448,18 +448,18 @@ describe('getConflictingAssignments (integration)', () => { const later = await seedEvent({ name: 'Later Part', startDate: new Date('2027-09-15T19:00:00Z'), - parts: [{ assigneeId: aliceId, hasConflict: true }], + eventParts: [{ assigneeId: aliceId, hasConflict: true }], }) const earlier = await seedEvent({ name: 'Earlier Service Role', startDate: new Date('2027-09-01T19:00:00Z'), - serviceRoles: [{ assigneeId: aliceId, hasConflict: true, name: 'Early Sound' }], + eventServiceParts: [{ 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?.name).toBe('Early Sound') - expect(result?.id).toBe(earlier.serviceRoleIds[0]) + expect(result?.id).toBe(earlier.servicePartIds[0]) } finally { await cleanupEvent(earlier.eventId) await cleanupEvent(later.eventId) @@ -470,8 +470,8 @@ describe('getConflictingAssignments (integration)', () => { const seeded = await seedEvent({ name: 'No Conflict', startDate: new Date('2027-10-01T19:00:00Z'), - parts: [{ assigneeId: aliceId, hasConflict: false }], - serviceRoles: [{ assigneeId: aliceId, hasConflict: false }], + eventParts: [{ assigneeId: aliceId, hasConflict: false }], + eventServiceParts: [{ assigneeId: aliceId, hasConflict: false }], }) try { const result = await withScope(congregationId, tx => getConflictingAssignments(tx, aliceId)) @@ -486,7 +486,7 @@ describe('getConflictingAssignments (integration)', () => { name: 'Past Conflict', startDate: new Date('2024-01-01T19:00:00Z'), endDate: new Date('2024-01-01T21:00:00Z'), - parts: [{ assigneeId: aliceId, hasConflict: true }], + eventParts: [{ assigneeId: aliceId, hasConflict: true }], }) try { const result = await withScope(congregationId, tx => getConflictingAssignments(tx, aliceId)) @@ -503,7 +503,7 @@ describe('getConflictingAssignments (integration)', () => { }) const otherSeed = await withScope(otherCong.id, async tx => { - const otherKind = await tx.programmeTemplate.create({ + const otherKind = await tx.eventTemplate.create({ data: { name: 'Midweek', key: `midweek-other-${otherTs}`, color: '#00aa00', congregationId: otherCong.id }, }) const otherMember = await tx.member.create({ @@ -530,7 +530,7 @@ describe('getConflictingAssignments (integration)', () => { }, }) // Same numeric id range as Alice — guarantees that without RLS the other-cong row would match - const part = await tx.programmePartAssignment.create({ + const part = await tx.eventPart.create({ data: { eventId: event.id, assigneeId: otherMember.id, @@ -561,7 +561,7 @@ describe('getConflictingAssignments (integration)', () => { expect(aliceFromHerCong).toBeNull() } finally { await withScope(otherCong.id, async tx => { - await tx.programmePartAssignment.delete({ + await tx.eventPart.delete({ where: { id_congregationId: { id: otherSeed.partId, congregationId: otherCong.id } }, }) await tx.event.delete({ @@ -569,7 +569,7 @@ describe('getConflictingAssignments (integration)', () => { }) await tx.userAccount.delete({ where: { id: otherSeed.otherAccountId } }) await tx.member.delete({ where: { id: otherSeed.otherMemberId } }) - await tx.programmeTemplate.delete({ + await tx.eventTemplate.delete({ where: { id_congregationId: { id: otherSeed.otherKindId, congregationId: otherCong.id } }, }) }) @@ -584,7 +584,7 @@ describe('getConflictingAssignments (integration)', () => { // for templated events or stops touching one of the two assignment tables. it('refreshConflictFlags clears stale hasConflict and the alert disappears', async () => { const setup = await withScope(congregationId, async tx => { - const template = await tx.programmeTemplate.create({ + const template = await tx.eventTemplate.create({ data: { name: 'Invariant Template', key: `invariant-template-${ts}`, @@ -603,7 +603,7 @@ describe('getConflictingAssignments (integration)', () => { }, }) // Pre-set the flag to true to simulate a stale conflict left over from a now-deleted day-off. - const part = await tx.programmePartAssignment.create({ + const part = await tx.eventPart.create({ data: { eventId: event.id, assigneeId: aliceId, @@ -614,7 +614,7 @@ describe('getConflictingAssignments (integration)', () => { congregationId, }, }) - const serviceRole = await tx.programmeServiceRoleAssignment.create({ + const servicePart = await tx.eventServicePart.create({ data: { eventId: event.id, assigneeId: aliceId, @@ -623,7 +623,7 @@ describe('getConflictingAssignments (integration)', () => { congregationId, }, }) - return { templateId: template.id, eventId: event.id, partId: part.id, serviceRoleId: serviceRole.id } + return { templateId: template.id, eventId: event.id, partId: part.id, servicePartId: servicePart.id } }) try { @@ -641,14 +641,14 @@ describe('getConflictingAssignments (integration)', () => { ) const partFlag = await withScope(congregationId, tx => - tx.programmePartAssignment.findUniqueOrThrow({ + tx.eventPart.findUniqueOrThrow({ where: { id_congregationId: { id: setup.partId, congregationId } }, select: { hasConflict: true }, }), ) const serviceFlag = await withScope(congregationId, tx => - tx.programmeServiceRoleAssignment.findUniqueOrThrow({ - where: { id_congregationId: { id: setup.serviceRoleId, congregationId } }, + tx.eventServicePart.findUniqueOrThrow({ + where: { id_congregationId: { id: setup.servicePartId, congregationId } }, select: { hasConflict: true }, }), ) @@ -659,14 +659,14 @@ describe('getConflictingAssignments (integration)', () => { expect(after).toBeNull() } finally { await withScope(congregationId, async tx => { - await tx.programmeServiceRoleAssignment.delete({ - where: { id_congregationId: { id: setup.serviceRoleId, congregationId } }, + await tx.eventServicePart.delete({ + where: { id_congregationId: { id: setup.servicePartId, congregationId } }, }) - await tx.programmePartAssignment.delete({ + await tx.eventPart.delete({ where: { id_congregationId: { id: setup.partId, congregationId } }, }) await tx.event.delete({ where: { id_congregationId: { id: setup.eventId, congregationId } } }) - await tx.programmeTemplate.delete({ + await tx.eventTemplate.delete({ where: { id_congregationId: { id: setup.templateId, congregationId } }, }) }) @@ -680,7 +680,7 @@ describe('getConflictingAssignments (integration)', () => { // 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 template = await tx.programmeTemplate.create({ + const template = await tx.eventTemplate.create({ data: { name: 'Responsible Invariant Template', key: `resp-invariant-template-${ts}`, @@ -688,7 +688,7 @@ describe('getConflictingAssignments (integration)', () => { }, }) // Bob is the responsible for this template; Alice is the absentee. - await tx.programmeTemplateResponsible.create({ + await tx.templateResponsible.create({ data: { templateId: template.id, userId: bobAccountId, @@ -706,7 +706,7 @@ describe('getConflictingAssignments (integration)', () => { status: 'released', }, }) - const part = await tx.programmePartAssignment.create({ + const part = await tx.eventPart.create({ data: { eventId: event.id, assigneeId: aliceId, @@ -729,7 +729,7 @@ describe('getConflictingAssignments (integration)', () => { // 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({ + tx.eventPart.update({ where: { id_congregationId: { id: setup.partId, congregationId } }, data: { hasConflict: false }, }), @@ -739,12 +739,12 @@ describe('getConflictingAssignments (integration)', () => { expect(after).toEqual({ count: 0, absenteeNames: [], totalAbsenteesCount: 0 }) } finally { await withScope(congregationId, async tx => { - await tx.programmePartAssignment.delete({ + await tx.eventPart.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({ + await tx.templateResponsible.deleteMany({ where: { templateId: setup.templateId } }) + await tx.eventTemplate.delete({ where: { id_congregationId: { id: setup.templateId, congregationId } }, }) }) @@ -768,7 +768,7 @@ describe('getConflictingAssignments (integration)', () => { status: 'released', }, }) - const part = await tx.programmePartAssignment.create({ + const part = await tx.eventPart.create({ data: { eventId: event.id, assigneeId: aliceId, @@ -793,7 +793,7 @@ describe('getConflictingAssignments (integration)', () => { expect(asManager.absenteeNames).toEqual(['Alice Dupont']) } finally { await withScope(congregationId, async tx => { - await tx.programmePartAssignment.delete({ + await tx.eventPart.delete({ where: { id_congregationId: { id: setup.partId, congregationId } }, }) await tx.event.delete({ where: { id_congregationId: { id: setup.eventId, congregationId } } }) diff --git a/app/features/dashboard/server/dashboard.server.test.ts b/app/features/dashboard/server/dashboard.server.test.ts index d3a9fce4..4fb23ad2 100644 --- a/app/features/dashboard/server/dashboard.server.test.ts +++ b/app/features/dashboard/server/dashboard.server.test.ts @@ -6,8 +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() }, + eventPart: { findMany: vi.fn() }, + eventServicePart: { findMany: vi.fn() }, role: { findMany: vi.fn() }, }, })) @@ -22,7 +22,6 @@ const { getUnreadDocumentCount, getNextMeeting, getUpcomingAbsences, - getUpcomingAssignments, getConflictingAssignments, } = await import('./dashboard.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') @@ -147,7 +146,7 @@ describe('getNextMeeting', () => { startDate: new Date(2026, 3, 25), endDate: new Date(2026, 3, 25), template: { name: 'Midweek', color: '#000' }, - partAssignments: [ + eventParts: [ { id: 10, name: 'Talk', @@ -171,7 +170,7 @@ describe('getNextMeeting', () => { assistant: null, }, ], - serviceRoleAssignments: [ + eventServiceParts: [ { id: 20, name: 'Sound', assignee: { id: 42, firstname: 'John', lastname: 'Doe' } }, { id: 21, name: 'Stage', assignee: { id: 50, firstname: 'Bob', lastname: 'Brown' } }, ], @@ -180,10 +179,10 @@ describe('getNextMeeting', () => { const result = await getNextMeeting(db, 42) expect(result).not.toBeNull() expect(result?.userPartIds).toEqual([10]) - expect(result?.userServiceRoleIds).toEqual([20]) + expect(result?.userServicePartIds).toEqual([20]) // Viewer is the assignee on part 10 → speaker. Part 11 belongs to someone // else so the viewer has no role there. - const parts = result?.partAssignments ?? [] + const parts = result?.eventParts ?? [] expect(parts.find(p => p.id === 10)?.viewerRole).toBe('speaker') expect(parts.find(p => p.id === 11)?.viewerRole).toBeNull() }) @@ -195,7 +194,7 @@ describe('getNextMeeting', () => { startDate: new Date(2026, 3, 25), endDate: new Date(2026, 3, 25), template: null, - partAssignments: [ + eventParts: [ { id: 10, name: 'Study', @@ -211,12 +210,12 @@ describe('getNextMeeting', () => { assistant: { id: 42, firstname: 'John', lastname: 'Doe' }, }, ], - serviceRoleAssignments: [], + eventServiceParts: [], } as never) const result = await getNextMeeting(db, 42) expect(result?.userPartIds).toEqual([10]) - expect(result?.partAssignments[0].viewerRole).toBe('reader') + expect(result?.eventParts[0].viewerRole).toBe('reader') }) // Locks the shape: the Prisma select MUST project speakerLabel and readerLabel @@ -231,7 +230,7 @@ describe('getNextMeeting', () => { startDate: new Date(2026, 3, 25), endDate: new Date(2026, 3, 25), template: null, - partAssignments: [ + eventParts: [ { id: 10, name: 'Bible reading', @@ -255,13 +254,13 @@ describe('getNextMeeting', () => { assistant: null, }, ], - serviceRoleAssignments: [], + eventServiceParts: [], } as never) const result = await getNextMeeting(db, 42) - expect(result?.partAssignments[0]).toMatchObject({ speakerLabel: 'STUDENT-SENTINEL-42', readerLabel: null }) - expect(result?.partAssignments[1]).toMatchObject({ + expect(result?.eventParts[0]).toMatchObject({ speakerLabel: 'STUDENT-SENTINEL-42', readerLabel: null }) + expect(result?.eventParts[1]).toMatchObject({ speakerLabel: 'STUDENT-SENTINEL-99', readerLabel: 'HOUSEHOLDER-SENTINEL-99', }) @@ -269,8 +268,8 @@ describe('getNextMeeting', () => { // Also assert the Prisma select requested the fields — a fixture that // happened to include the sentinels would pass without this. const call = vi.mocked(db.event.findFirst).mock.calls[0][0] - const select = call?.select as { partAssignments?: { select?: Record } } - expect(select.partAssignments?.select).toMatchObject({ speakerLabel: true, readerLabel: true }) + const select = call?.select as { eventParts?: { select?: Record } } + expect(select.eventParts?.select).toMatchObject({ speakerLabel: true, readerLabel: true }) }) it('returns empty arrays when user has no assignments', async () => { @@ -280,7 +279,7 @@ describe('getNextMeeting', () => { startDate: new Date(2026, 3, 25), endDate: new Date(2026, 3, 25), template: null, - partAssignments: [ + eventParts: [ { id: 10, name: 'Talk', @@ -291,12 +290,12 @@ describe('getNextMeeting', () => { assistant: null, }, ], - serviceRoleAssignments: [], + eventServiceParts: [], } as never) const result = await getNextMeeting(db, 42) expect(result?.userPartIds).toEqual([]) - expect(result?.userServiceRoleIds).toEqual([]) + expect(result?.userServicePartIds).toEqual([]) }) // The dashboard is publisher-facing. Drafts must not surface — same @@ -368,37 +367,17 @@ describe('getUpcomingAbsences', () => { }) }) -// --- 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] - expect((partCall as { where: { event: unknown } }).where.event).toMatchObject({ status: 'released' }) - - const [serviceCall] = vi.mocked(db.programmeServiceRoleAssignment.findMany).mock.calls[0] - expect((serviceCall as { where: { event: unknown } }).where.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) + vi.mocked(db.eventPart.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] as never) await getConflictingAssignments(db, 42) - const [partCall] = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0] + const [partCall] = vi.mocked(db.eventPart.findMany).mock.calls[0] expect((partCall as { where: { event: unknown } }).where.event).toMatchObject({ status: 'released' }) - const [serviceCall] = vi.mocked(db.programmeServiceRoleAssignment.findMany).mock.calls[0] + const [serviceCall] = vi.mocked(db.eventServicePart.findMany).mock.calls[0] expect((serviceCall as { where: { event: unknown } }).where.event).toMatchObject({ status: 'released' }) }) }) diff --git a/app/features/dashboard/server/dashboard.server.ts b/app/features/dashboard/server/dashboard.server.ts index e2e7a711..ee2ddded 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 { EventStatus, ProgrammeTemplateKey } from '~/features/events' +import { EventStatus, EventTemplateKey } 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' @@ -165,96 +165,11 @@ export async function getUpcomingAbsences(db: TransactionClient, userId: number, return { upcoming, shouldNudge } } -export async function getUpcomingAssignments(db: TransactionClient, userId: number) { - const now = new Date() - - const [partAssignments, serviceRoleAssignments] = await Promise.all([ - db.programmePartAssignment.findMany({ - where: { - OR: [{ assigneeId: userId }, { assistantId: userId }], - // Drafts are the manager's scratch space — never previewed to - // publishers. - event: { startDate: { gte: now }, status: EventStatus.Released }, - }, - select: { - id: true, - name: true, - topic: true, - assigneeId: true, - assistantId: true, - event: { - select: { - name: true, - startDate: true, - }, - }, - }, - orderBy: { event: { startDate: 'asc' } }, - take: DASHBOARD_RECENT_ITEMS_LIMIT, - }), - db.programmeServiceRoleAssignment.findMany({ - where: { - assigneeId: userId, - event: { startDate: { gte: now }, status: EventStatus.Released }, - }, - select: { - id: true, - name: true, - event: { - select: { - name: true, - startDate: true, - }, - }, - }, - orderBy: { event: { startDate: 'asc' } }, - take: DASHBOARD_RECENT_ITEMS_LIMIT, - }), - ]) - - type Assignment = { - kind: 'part' | 'service-role' - id: number - name: string - topic?: string | null - role: 'speaker' | 'assistant' | 'service' - eventName: string - eventDate: Date - } - - const assignments: Assignment[] = [ - ...partAssignments.map( - (a): Assignment => ({ - kind: 'part', - id: a.id, - name: a.name, - topic: a.topic, - role: a.assigneeId === userId ? 'speaker' : 'assistant', - eventName: a.event.name, - eventDate: a.event.startDate, - }), - ), - ...serviceRoleAssignments.map( - (a): Assignment => ({ - kind: 'service-role', - id: a.id, - name: a.name, - role: 'service', - eventName: a.event.name, - eventDate: a.event.startDate, - }), - ), - ] - - assignments.sort((a, b) => a.eventDate.getTime() - b.eventDate.getTime()) - return assignments.slice(0, 5) -} - export async function getConflictingAssignments(db: TransactionClient, userId: number) { const now = new Date() const [partConflicts, serviceConflicts] = await Promise.all([ - db.programmePartAssignment.findMany({ + db.eventPart.findMany({ where: { hasConflict: true, OR: [{ assigneeId: userId }, { assistantId: userId }], @@ -271,7 +186,7 @@ export async function getConflictingAssignments(db: TransactionClient, userId: n orderBy: { event: { startDate: 'asc' } }, take: 1, }), - db.programmeServiceRoleAssignment.findMany({ + db.eventServicePart.findMany({ where: { hasConflict: true, assigneeId: userId, @@ -318,7 +233,7 @@ export async function getNextMeeting(db: TransactionClient, userId: number) { // NOT: { template: {...} } instead of template: { key: { not } } — the // second form inner-joins through template and silently drops null- // template rows, which older legacy events might still carry. - NOT: { template: { key: ProgrammeTemplateKey.DayOff } }, + NOT: { template: { key: EventTemplateKey.DayOff } }, // Publisher-facing dashboard — drafts must stay hidden. status: EventStatus.Released, }, @@ -328,7 +243,7 @@ export async function getNextMeeting(db: TransactionClient, userId: number) { startDate: true, endDate: true, template: { select: { name: true, color: true } }, - partAssignments: { + eventParts: { select: { id: true, name: true, @@ -342,7 +257,7 @@ export async function getNextMeeting(db: TransactionClient, userId: number) { }, orderBy: { order: 'asc' }, }, - serviceRoleAssignments: { + eventServiceParts: { select: { id: true, name: true, @@ -359,19 +274,19 @@ export async function getNextMeeting(db: TransactionClient, userId: number) { // engineer it. `null` means "viewer has no role on this part" — the UI // filters on userPartIds so nulls never render, but keeping the field // present makes the shape uniform and typed. - const partAssignments = event.partAssignments.map(p => ({ + const eventParts = event.eventParts.map(p => ({ ...p, viewerRole: p.assignee?.id === userId ? ('speaker' as const) : p.assistant?.id === userId ? ('reader' as const) : null, })) - const userPartIds = new Set(partAssignments.filter(p => p.viewerRole !== null).map(p => p.id)) - const userServiceRoleIds = new Set(event.serviceRoleAssignments.filter(r => r.assignee?.id === userId).map(r => r.id)) + const userPartIds = new Set(eventParts.filter(p => p.viewerRole !== null).map(p => p.id)) + const userServicePartIds = new Set(event.eventServiceParts.filter(r => r.assignee?.id === userId).map(r => r.id)) return { ...event, - partAssignments, + eventParts, userPartIds: [...userPartIds], - userServiceRoleIds: [...userServiceRoleIds], + userServicePartIds: [...userServicePartIds], } } 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 a0cd9261..b3015a51 100644 --- a/app/features/dashboard/server/get-responsible-conflicts.server.test.ts +++ b/app/features/dashboard/server/get-responsible-conflicts.server.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { - programmePartAssignment: { findMany: vi.fn() }, - programmeServiceRoleAssignment: { findMany: vi.fn() }, + eventPart: { findMany: vi.fn() }, + eventServicePart: { findMany: vi.fn() }, }, })) @@ -12,8 +12,8 @@ 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) + vi.mocked(db.eventPart.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] as never) }) describe('getResponsibleConflicts', () => { @@ -29,7 +29,7 @@ describe('getResponsibleConflicts', () => { const userId = 100 await getResponsibleConflicts(db, userId, false) - const partCall = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0] + const partCall = vi.mocked(db.eventPart.findMany).mock.calls[0][0] const partWhere = partCall?.where as Record expect(partWhere.hasConflict).toBe(true) expect(partWhere.event).toEqual({ @@ -43,7 +43,7 @@ describe('getResponsibleConflicts', () => { const userId = 100 await getResponsibleConflicts(db, userId, false) - const serviceCall = vi.mocked(db.programmeServiceRoleAssignment.findMany).mock.calls[0][0] + const serviceCall = vi.mocked(db.eventServicePart.findMany).mock.calls[0][0] const serviceWhere = serviceCall?.where as Record expect(serviceWhere.event).toEqual({ startDate: { gte: expect.any(Date) }, @@ -59,7 +59,7 @@ describe('getResponsibleConflicts', () => { 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 partCall = vi.mocked(db.eventPart.findMany).mock.calls[0][0] const partWhere = partCall?.where as Record expect(partWhere.event).toEqual({ startDate: { gte: expect.any(Date) }, status: 'released' }) expect(partWhere.event).not.toHaveProperty('template') @@ -68,7 +68,7 @@ describe('getResponsibleConflicts', () => { it('only considers upcoming events (startDate >= now)', async () => { await getResponsibleConflicts(db, 100, true) - const [partCall] = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0] + const [partCall] = vi.mocked(db.eventPart.findMany).mock.calls[0] const event = (partCall as { where: { event: { startDate: { gte: Date } } } }).where.event expect(event.startDate.gte.getTime()).toBeGreaterThan(Date.now() - 5000) }) @@ -78,7 +78,7 @@ describe('getResponsibleConflicts', () => { // the same event) collapse to one conflict. Otherwise a member appearing // twice double-counts the badge. it('dedupes by (memberId, eventId) when computing count', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { eventId: 1, assigneeId: 100, @@ -101,7 +101,7 @@ describe('getResponsibleConflicts', () => { }) it('counts one conflict per (member × event) — same member on two events counts twice', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { eventId: 1, assigneeId: 100, @@ -124,7 +124,7 @@ describe('getResponsibleConflicts', () => { }) it('merges names across part and service assignments (deduped)', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { eventId: 1, assigneeId: 100, @@ -133,7 +133,7 @@ describe('getResponsibleConflicts', () => { assistant: null, }, ] as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([ { eventId: 2, assigneeId: 100, assignee: { firstname: 'Alice', lastname: 'Dupont' } }, ] as never) @@ -143,7 +143,7 @@ describe('getResponsibleConflicts', () => { }) it('caps absenteeNames at 3, sorted alphabetically; count still reflects all conflicts', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { eventId: 1, assigneeId: 100, @@ -192,7 +192,7 @@ describe('getResponsibleConflicts', () => { // related member could not be joined. Either way it must not corrupt the // aggregation. This pin guards the `record()` null-skip at line 66. it('silently skips rows whose assignee is null (no assignee booked or join failed)', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { eventId: 1, assigneeId: null, @@ -219,7 +219,7 @@ describe('getResponsibleConflicts', () => { // potentially the absentee, so both are enumerated. Downstream the // responsible sees them and can click through for detail. it('includes both assignee and assistant of a part row', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { eventId: 1, assigneeId: 100, diff --git a/app/features/dashboard/server/get-responsible-conflicts.server.ts b/app/features/dashboard/server/get-responsible-conflicts.server.ts index 26573c92..6f2976d5 100644 --- a/app/features/dashboard/server/get-responsible-conflicts.server.ts +++ b/app/features/dashboard/server/get-responsible-conflicts.server.ts @@ -18,7 +18,7 @@ const MAX_ABSENTEE_NAMES = 3 // Scoping: // - non-manager → only events on templates where the user is the documented -// responsible (ProgrammeTemplateResponsible.userId); +// responsible (TemplateResponsible.userId); // - ProgramManager → all events, including untemplated ones which have // no responsible relation at all. export async function getResponsibleConflicts( @@ -35,7 +35,7 @@ export async function getResponsibleConflicts( : { startDate: { gte: now }, status: EventStatus.Released, template: { responsibles: { some: { userId } } } } const [partRows, serviceRows] = await Promise.all([ - db.programmePartAssignment.findMany({ + db.eventPart.findMany({ where: { hasConflict: true, event: eventFilter }, select: { eventId: true, @@ -45,7 +45,7 @@ export async function getResponsibleConflicts( assistant: { select: { firstname: true, lastname: true } }, }, }), - db.programmeServiceRoleAssignment.findMany({ + db.eventServicePart.findMany({ where: { hasConflict: true, event: eventFilter }, select: { eventId: true, diff --git a/app/features/dashboard/ui/build-urgent-items.test.ts b/app/features/dashboard/ui/build-urgent-items.test.ts index f12ff5b6..ac80c521 100644 --- a/app/features/dashboard/ui/build-urgent-items.test.ts +++ b/app/features/dashboard/ui/build-urgent-items.test.ts @@ -20,7 +20,7 @@ const { buildUrgentItems, urgentTerritoriesItems, urgentPartAssignmentItems, - urgentServiceRoleItems, + urgentServicePartItems, urgentDayoffConflictItems, urgentResponsibleConflictItems, urgentDocumentsItem, @@ -56,15 +56,15 @@ type PartAssignment = { assistant: Person | null viewerRole: 'speaker' | 'reader' | null } -type ServiceRoleAssignment = { id: number; name: string; assignee: Person | null } +type ServicePartAssignment = { id: number; name: string; assignee: Person | null } function makeNextMeeting( startDate: Date, { userPartIds = [] as number[], - userServiceRoleIds = [] as number[], - partAssignments = [] as PartAssignment[], - serviceRoleAssignments = [] as ServiceRoleAssignment[], + userServicePartIds = [] as number[], + eventParts = [] as PartAssignment[], + eventServiceParts = [] as ServicePartAssignment[], } = {}, ) { return { @@ -73,10 +73,10 @@ function makeNextMeeting( startDate, endDate: new Date(startDate.getTime() + 2 * 60 * 60 * 1000), // +2h template: { name: 'Midweek', color: '#000' } as { name: string; color: string } | null, - partAssignments, - serviceRoleAssignments, + eventParts, + eventServiceParts, userPartIds, - userServiceRoleIds, + userServicePartIds, } } @@ -142,7 +142,7 @@ describe('urgentPartAssignmentItems', () => { it('returns empty array when meeting is more than 3 days away', () => { const meeting = makeNextMeeting(new Date(2026, 3, 28), { userPartIds: [10], - partAssignments: [ + eventParts: [ { id: 10, name: 'Discours', @@ -163,7 +163,7 @@ describe('urgentPartAssignmentItems', () => { it('returns item when meeting is within 3 days', () => { const meeting = makeNextMeeting(new Date(2026, 3, 26, 19, 0), { userPartIds: [10], - partAssignments: [ + eventParts: [ { id: 10, name: 'Discours', @@ -188,7 +188,7 @@ describe('urgentPartAssignmentItems', () => { it('returns item when meeting is today', () => { const meeting = makeNextMeeting(new Date(2026, 3, 24, 19, 0), { userPartIds: [10], - partAssignments: [ + eventParts: [ { id: 10, name: 'Discours', @@ -207,32 +207,32 @@ describe('urgentPartAssignmentItems', () => { }) }) -// --- urgentServiceRoleItems --- +// --- urgentServicePartItems --- -describe('urgentServiceRoleItems', () => { +describe('urgentServicePartItems', () => { it('returns empty array for null meeting', () => { - expect(urgentServiceRoleItems(null)).toEqual([]) + expect(urgentServicePartItems(null)).toEqual([]) }) it('returns empty array when user has no service roles', () => { - const meeting = makeNextMeeting(new Date(2026, 3, 25), { userServiceRoleIds: [] }) - expect(urgentServiceRoleItems(meeting)).toEqual([]) + const meeting = makeNextMeeting(new Date(2026, 3, 25), { userServicePartIds: [] }) + expect(urgentServicePartItems(meeting)).toEqual([]) }) it('returns empty array when meeting is more than 3 days away', () => { const meeting = makeNextMeeting(new Date(2026, 3, 28), { - userServiceRoleIds: [5], - serviceRoleAssignments: [{ id: 5, name: 'Son', assignee: { id: 1, firstname: 'John', lastname: 'Doe' } }], + userServicePartIds: [5], + eventServiceParts: [{ id: 5, name: 'Son', assignee: { id: 1, firstname: 'John', lastname: 'Doe' } }], }) - expect(urgentServiceRoleItems(meeting)).toEqual([]) + expect(urgentServicePartItems(meeting)).toEqual([]) }) it('returns item with priority 3 when meeting is within 3 days', () => { const meeting = makeNextMeeting(new Date(2026, 3, 25, 19, 0), { - userServiceRoleIds: [5], - serviceRoleAssignments: [{ id: 5, name: 'Son', assignee: { id: 1, firstname: 'John', lastname: 'Doe' } }], + userServicePartIds: [5], + eventServiceParts: [{ id: 5, name: 'Son', assignee: { id: 1, firstname: 'John', lastname: 'Doe' } }], }) - const items = urgentServiceRoleItems(meeting) + const items = urgentServicePartItems(meeting) expect(items).toHaveLength(1) expect(items[0].priority).toBe(3) expect(items[0].to).toBe('/board') @@ -389,7 +389,7 @@ describe('buildUrgentItems', () => { const territories = [makeTerritory(1, 'T-1', 'overdue', new Date(2026, 3, 20))] const meeting = makeNextMeeting(new Date(2026, 3, 25, 19, 0), { userPartIds: [10], - partAssignments: [ + eventParts: [ { id: 10, name: 'Discours', @@ -413,8 +413,8 @@ describe('buildUrgentItems', () => { const meetingDate = new Date(2026, 3, 25, 19, 0) const meeting = makeNextMeeting(meetingDate, { userPartIds: [10], - userServiceRoleIds: [5], - partAssignments: [ + userServicePartIds: [5], + eventParts: [ { id: 10, name: 'Discours', @@ -428,7 +428,7 @@ describe('buildUrgentItems', () => { readerLabel: null, }, ], - serviceRoleAssignments: [{ id: 5, name: 'Son', assignee: null }], + eventServiceParts: [{ id: 5, name: 'Son', assignee: null }], }) const conflict = makeConflict(7, 'Discours public', meetingDate) const items = buildUrgentItems(null, null, meeting, conflict, null) diff --git a/app/features/dashboard/ui/build-urgent-items.ts b/app/features/dashboard/ui/build-urgent-items.ts index 6681e5b1..f419467b 100644 --- a/app/features/dashboard/ui/build-urgent-items.ts +++ b/app/features/dashboard/ui/build-urgent-items.ts @@ -59,7 +59,7 @@ export function urgentPartAssignmentItems(nextMeeting: NextMeeting): UrgentItem[ if (!nextMeeting || nextMeeting.userPartIds.length === 0) return [] if (new Date(nextMeeting.startDate).getTime() - Date.now() > THREE_DAYS_MS) return [] - const userPart = nextMeeting.partAssignments.find(p => nextMeeting.userPartIds.includes(p.id)) + const userPart = nextMeeting.eventParts.find(p => nextMeeting.userPartIds.includes(p.id)) if (!userPart) return [] return [ @@ -76,11 +76,11 @@ export function urgentPartAssignmentItems(nextMeeting: NextMeeting): UrgentItem[ ] } -export function urgentServiceRoleItems(nextMeeting: NextMeeting): UrgentItem[] { - if (!nextMeeting || nextMeeting.userServiceRoleIds.length === 0) return [] +export function urgentServicePartItems(nextMeeting: NextMeeting): UrgentItem[] { + if (!nextMeeting || nextMeeting.userServicePartIds.length === 0) return [] if (new Date(nextMeeting.startDate).getTime() - Date.now() > THREE_DAYS_MS) return [] - const userRole = nextMeeting.serviceRoleAssignments.find(r => nextMeeting.userServiceRoleIds.includes(r.id)) + const userRole = nextMeeting.eventServiceParts.find(r => nextMeeting.userServicePartIds.includes(r.id)) if (!userRole) return [] return [ @@ -170,7 +170,7 @@ export function buildUrgentItems( const items = [ ...urgentTerritoriesItems(territories), ...urgentPartAssignmentItems(nextMeeting), - ...urgentServiceRoleItems(nextMeeting), + ...urgentServicePartItems(nextMeeting), ...urgentDayoffConflictItems(dayoffConflict), ...urgentResponsibleConflictItems(responsibleConflicts), ...urgentDocumentsItem(unreadDocumentCount), diff --git a/app/features/display-board/index.server.ts b/app/features/display-board/index.server.ts index 330c70c3..860fc4cf 100644 --- a/app/features/display-board/index.server.ts +++ b/app/features/display-board/index.server.ts @@ -1,4 +1,4 @@ // Public server-only surface of the display-board feature. +export { resolveProgrammeLink } from './server/event-link.server' export { boardNotifications } from './server/notifications.server' -export { resolveProgrammeLink } from './server/programme-link.server' diff --git a/app/features/display-board/model/programme-display.test.ts b/app/features/display-board/model/event-display.test.ts similarity index 99% rename from app/features/display-board/model/programme-display.test.ts rename to app/features/display-board/model/event-display.test.ts index e6585870..0591709e 100644 --- a/app/features/display-board/model/programme-display.test.ts +++ b/app/features/display-board/model/event-display.test.ts @@ -5,7 +5,7 @@ vi.mock('~/i18n/paraglide/messages', () => ({ })) const { formatName, formatAssigneeWithAssistant, nameMatches, getPartDisplay, partMatchesQuery } = await import( - './programme-display' + './event-display' ) function makeUser(firstname: string | null, lastname: string | null, anonymizedAt: Date | null = null) { diff --git a/app/features/display-board/model/programme-display.ts b/app/features/display-board/model/event-display.ts similarity index 100% rename from app/features/display-board/model/programme-display.ts rename to app/features/display-board/model/event-display.ts diff --git a/app/features/display-board/routes/documents/new-dynamic.tsx b/app/features/display-board/routes/documents/new-dynamic.tsx index 55aae8bf..630a1eb6 100644 --- a/app/features/display-board/routes/documents/new-dynamic.tsx +++ b/app/features/display-board/routes/documents/new-dynamic.tsx @@ -137,7 +137,7 @@ export async function action({ request, context }: Route.ActionArgs) { // For programme documents, build default config with all templates selected let dynamicConfig: ProgrammeDynamicConfig | undefined if (dynamicType === DynamicType.Programme) { - const templates = await db.programmeTemplate.findMany({ + const templates = await db.eventTemplate.findMany({ where: { congregationId }, select: { id: true }, }) diff --git a/app/features/display-board/routes/dynamic/viewer.tsx b/app/features/display-board/routes/dynamic/viewer.tsx index 46957b07..b4a23e9e 100644 --- a/app/features/display-board/routes/dynamic/viewer.tsx +++ b/app/features/display-board/routes/dynamic/viewer.tsx @@ -7,7 +7,7 @@ import { getDynamicDocumentData, markDynamicDocumentViewed, } from '~/features/display-board/server/dynamic-documents.server' -import { filterDynamicDataToEvent } from '~/features/display-board/server/programme-event-filter.server' +import { filterDynamicDataToEvent } from '~/features/display-board/server/event-filter.server' import { PioneersView } from '~/features/display-board/ui/dynamic/PioneersView' import { ProgrammeView } from '~/features/display-board/ui/dynamic/ProgrammeView' import { PublisherGroupsView } from '~/features/display-board/ui/dynamic/PublisherGroupsView' diff --git a/app/features/display-board/schemas/dynamic-config.schema.ts b/app/features/display-board/schemas/dynamic-config.schema.ts index 212b602b..06f2639a 100644 --- a/app/features/display-board/schemas/dynamic-config.schema.ts +++ b/app/features/display-board/schemas/dynamic-config.schema.ts @@ -1,13 +1,13 @@ import { z } from 'zod' -const programmeTemplateConfigSchema = z.object({ +const eventTemplateConfigSchema = z.object({ templateId: z.number().int().positive(), parts: z.boolean(), services: z.boolean(), }) export const programmeDynamicConfigSchema = z.object({ - templates: z.array(programmeTemplateConfigSchema), + templates: z.array(eventTemplateConfigSchema), groupBy: z.enum(['date', 'template']).default('date'), }) 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 70e17ea0..7f8051d8 100644 --- a/app/features/display-board/server/dynamic-documents.integration.test.ts +++ b/app/features/display-board/server/dynamic-documents.integration.test.ts @@ -80,7 +80,7 @@ beforeAll(async () => { }) // Create programme template and future event - const template = await tx.programmeTemplate.create({ + const template = await tx.eventTemplate.create({ data: { name: `Midweek ${ts}`, key: `midweek-${ts}`, congregationId }, }) @@ -118,9 +118,9 @@ afterAll(async () => { await tx.boardDynamicDocumentView.deleteMany({ where: { settings: { congregationId } } }) await tx.boardDynamicDocumentSettings.deleteMany({ where: { congregationId } }) await tx.boardSection.deleteMany({ where: { congregationId } }) - await tx.programmePartAssignment.deleteMany({ where: { congregationId } }) + await tx.eventPart.deleteMany({ where: { congregationId } }) await tx.event.deleteMany({ where: { congregationId } }) - await tx.programmeTemplate.deleteMany({ where: { congregationId } }) + await tx.eventTemplate.deleteMany({ where: { congregationId } }) await tx.publisherGroup.deleteMany({ where: { congregationId } }) await tx.userAccount.deleteMany({ where: { congregationId } }) await tx.member.deleteMany({ where: { congregationId } }) 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 621795ec..bfd35c69 100644 --- a/app/features/display-board/server/dynamic-documents.server.test.ts +++ b/app/features/display-board/server/dynamic-documents.server.test.ts @@ -20,7 +20,7 @@ const mockDb = { findFirst: vi.fn(), findMany: vi.fn(), }, - programmePartAssignment: { + eventPart: { findFirst: vi.fn(), }, boardDynamicDocumentView: { @@ -160,7 +160,7 @@ describe('getContentVersion', () => { const eventDate = new Date('2026-04-10') const assignmentDate = new Date('2026-04-18') mockDb.event.findFirst.mockResolvedValue({ updatedAt: eventDate }) - mockDb.programmePartAssignment.findFirst.mockResolvedValue({ updatedAt: assignmentDate }) + mockDb.eventPart.findFirst.mockResolvedValue({ updatedAt: assignmentDate }) const result = await getContentVersion(mockDb as never, 'programme', 'midweek', 10) @@ -224,13 +224,13 @@ describe('getDynamicPreview programme draft filter', () => { 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) + mockDb.eventPart.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] + const assignmentCall = mockDb.eventPart.findFirst.mock.calls[0][0] expect(assignmentCall.where.event).toMatchObject({ status: 'released' }) }) }) diff --git a/app/features/display-board/server/dynamic-documents.server.ts b/app/features/display-board/server/dynamic-documents.server.ts index 49aadfd1..8a2aaff4 100644 --- a/app/features/display-board/server/dynamic-documents.server.ts +++ b/app/features/display-board/server/dynamic-documents.server.ts @@ -65,7 +65,7 @@ export async function listAvailableDynamicTypes( } // Programme: always available (users can create multiple with different configs) - const templateCount = await db.programmeTemplate.count({ where: { congregationId } }) + const templateCount = await db.eventTemplate.count({ where: { congregationId } }) if (templateCount > 0) { available.push({ dynamicType: DynamicType.Programme, @@ -132,7 +132,7 @@ export async function getContentVersion( orderBy: { updatedAt: 'desc' }, select: { updatedAt: true }, }), - db.programmePartAssignment.findFirst({ + db.eventPart.findFirst({ where: { congregationId, event: { templateId: { in: templateIds }, startDate: { gte: fromDate }, status: EventStatus.Released }, @@ -157,7 +157,7 @@ export async function getContentVersion( orderBy: { updatedAt: 'desc' }, select: { updatedAt: true }, }), - db.programmePartAssignment.findFirst({ + db.eventPart.findFirst({ where: { congregationId, event: { template: { key: dynamicRef }, startDate: { gte: fromDate }, status: EventStatus.Released }, @@ -346,7 +346,7 @@ function fetchProgrammeByIds( }, include: { template: true, - partAssignments: { + eventParts: { orderBy: [{ order: 'asc' }, { trackOrder: { sort: 'asc', nulls: 'last' } }], include: { assignee: { select: userSelect }, @@ -354,7 +354,7 @@ function fetchProgrammeByIds( externalSpeaker: { select: { name: true } }, }, }, - serviceRoleAssignments: includeServices ? { include: { assignee: { select: userSelect } } } : false, + eventServiceParts: includeServices ? { include: { assignee: { select: userSelect } } } : false, }, orderBy: { startDate: 'asc' }, }) @@ -376,7 +376,7 @@ function fetchProgrammeByKey( status: EventStatus.Released, }, include: { - partAssignments: { + eventParts: { orderBy: [{ order: 'asc' }, { trackOrder: { sort: 'asc', nulls: 'last' } }], include: { assignee: { select: userSelect }, @@ -384,7 +384,7 @@ function fetchProgrammeByKey( externalSpeaker: { select: { name: true } }, }, }, - serviceRoleAssignments: showServices ? { include: { assignee: { select: userSelect } } } : false, + eventServiceParts: showServices ? { include: { assignee: { select: userSelect } } } : false, }, orderBy: { startDate: 'asc' }, }) diff --git a/app/features/display-board/server/programme-event-filter.server.test.ts b/app/features/display-board/server/event-filter.server.test.ts similarity index 80% rename from app/features/display-board/server/programme-event-filter.server.test.ts rename to app/features/display-board/server/event-filter.server.test.ts index 2b9f4e40..655622f3 100644 --- a/app/features/display-board/server/programme-event-filter.server.test.ts +++ b/app/features/display-board/server/event-filter.server.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from 'vitest' import { DynamicType } from '~/features/display-board/model/dynamic-document.type' -import { filterDynamicDataToEvent } from './programme-event-filter.server' +import { filterDynamicDataToEvent } from './event-filter.server' const PROGRAMME_DATA = { type: DynamicType.Programme, events: [ - { id: 100, name: 'Meeting A', partAssignments: [], serviceRoleAssignments: [] }, - { id: 200, name: 'Meeting B', partAssignments: [], serviceRoleAssignments: [] }, - { id: 300, name: 'Meeting C', partAssignments: [], serviceRoleAssignments: [] }, + { id: 100, name: 'Meeting A', parts: [], serviceParts: [] }, + { id: 200, name: 'Meeting B', parts: [], serviceParts: [] }, + { id: 300, name: 'Meeting C', parts: [], serviceParts: [] }, ], config: null, templateKey: null, diff --git a/app/features/display-board/server/programme-event-filter.server.ts b/app/features/display-board/server/event-filter.server.ts similarity index 100% rename from app/features/display-board/server/programme-event-filter.server.ts rename to app/features/display-board/server/event-filter.server.ts diff --git a/app/features/display-board/server/programme-link.server.integration.test.ts b/app/features/display-board/server/event-link.server.integration.test.ts similarity index 95% rename from app/features/display-board/server/programme-link.server.integration.test.ts rename to app/features/display-board/server/event-link.server.integration.test.ts index f64b6c27..2d8a10ca 100644 --- a/app/features/display-board/server/programme-link.server.integration.test.ts +++ b/app/features/display-board/server/event-link.server.integration.test.ts @@ -23,7 +23,7 @@ function withScope(congregationId: number, fn: (tx: Tx) => Promise): Promi }) } -const { resolveProgrammeLink } = await import('./programme-link.server') +const { resolveProgrammeLink } = await import('./event-link.server') const ts = Date.now() let congId: number @@ -57,11 +57,11 @@ beforeAll(async () => { }) sectionId = section.id - const templateA = await tx.programmeTemplate.create({ + const templateA = await tx.eventTemplate.create({ data: { name: 'Weekly meeting', key: `weekly-meeting-${ts}`, congregationId: congId }, }) templateAId = templateA.id - const templateB = await tx.programmeTemplate.create({ + const templateB = await tx.eventTemplate.create({ data: { name: 'Public talk', key: `public-talk-${ts}`, congregationId: congId }, }) templateBId = templateB.id @@ -96,7 +96,7 @@ afterAll(async () => { await withScope(congId, async tx => { await tx.boardDynamicDocumentSettings.deleteMany({}) await tx.event.deleteMany({}) - await tx.programmeTemplate.deleteMany({}) + await tx.eventTemplate.deleteMany({}) await tx.boardSection.deleteMany({}) await tx.userAccount.deleteMany({}) }) diff --git a/app/features/display-board/server/programme-link.server.test.ts b/app/features/display-board/server/event-link.server.test.ts similarity index 99% rename from app/features/display-board/server/programme-link.server.test.ts rename to app/features/display-board/server/event-link.server.test.ts index d9f37c9b..3290c454 100644 --- a/app/features/display-board/server/programme-link.server.test.ts +++ b/app/features/display-board/server/event-link.server.test.ts @@ -12,7 +12,7 @@ vi.mock('~/shared/infra/logger.server', () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: warnSpy, error: vi.fn() }), })) -const { resolveProgrammeLink } = await import('./programme-link.server') +const { resolveProgrammeLink } = await import('./event-link.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') beforeEach(() => { diff --git a/app/features/display-board/server/programme-link.server.ts b/app/features/display-board/server/event-link.server.ts similarity index 98% rename from app/features/display-board/server/programme-link.server.ts rename to app/features/display-board/server/event-link.server.ts index bc182541..fbbb7d12 100644 --- a/app/features/display-board/server/programme-link.server.ts +++ b/app/features/display-board/server/event-link.server.ts @@ -2,7 +2,7 @@ import { parseProgrammeConfig } from '~/features/display-board/model/dynamic-doc import type { TransactionClient } from '~/shared/infra/db.server' import { createLogger } from '~/shared/infra/logger.server' -const logger = createLogger('programme-link') +const logger = createLogger('event-link') // Resolves the best-effort public URL where an assignee can view their upcoming // event. Preferred target is a Programme dynamic document on the board that diff --git a/app/features/display-board/ui/dynamic/ProgrammeView.tsx b/app/features/display-board/ui/dynamic/ProgrammeView.tsx index 9e827c20..8e49dd3b 100644 --- a/app/features/display-board/ui/dynamic/ProgrammeView.tsx +++ b/app/features/display-board/ui/dynamic/ProgrammeView.tsx @@ -2,12 +2,7 @@ import type { LucideIcon } from 'lucide-react' import { BookOpen, Calendar, ChevronRight, Gem, HeartHandshake } from 'lucide-react' import { type RefObject, useEffect, useMemo, useRef, useState } from 'react' import type { ProgrammeDynamicConfig } from '~/features/display-board/model/dynamic-document.type' -import { - formatName, - getPartDisplay, - nameMatches, - partMatchesQuery, -} from '~/features/display-board/model/programme-display' +import { formatName, getPartDisplay, nameMatches, partMatchesQuery } from '~/features/display-board/model/event-display' import { groupPartsBySlot } from '~/features/events' import * as m from '~/i18n/paraglide/messages' import { Badge } from '~/shared/ui/badge' @@ -74,7 +69,7 @@ interface PartAssignment { } | null } -interface ServiceRoleAssignment { +interface ServicePartAssignment { id: number name: string assignee?: { @@ -90,8 +85,8 @@ interface ProgrammeEvent { name: string startDate: Date templateId?: number | null - partAssignments: PartAssignment[] - serviceRoleAssignments?: ServiceRoleAssignment[] + eventParts: PartAssignment[] + eventServiceParts?: ServicePartAssignment[] } export interface ProgrammeViewData { @@ -248,7 +243,7 @@ function ServiceSection({ hasParts, query, }: { - services: ServiceRoleAssignment[] + services: ServicePartAssignment[] hasParts: boolean query: string }) { @@ -451,7 +446,7 @@ export function ProgrammeView({ events, showServices, config, highlightQuery, sc const eventShowParts = templateConfig?.parts ?? true const eventShowServices = templateConfig?.services ?? showServices - const sections = groupPartsBySlot(event.partAssignments) + const sections = groupPartsBySlot(event.eventParts) const isFirstOfWeek = firstEventIdPerWeek.has(event.id) const weekKey = getWeekKey(new Date(event.startDate)) @@ -524,8 +519,8 @@ export function ProgrammeView({ events, showServices, config, highlightQuery, sc })} {/* Services */} - {eventShowServices && event.serviceRoleAssignments && event.serviceRoleAssignments.length > 0 && ( - + {eventShowServices && event.eventServiceParts && event.eventServiceParts.length > 0 && ( + )}
diff --git a/app/features/events/emails/programme-assignment-assigned.tsx b/app/features/events/emails/event-part-assigned.tsx similarity index 97% rename from app/features/events/emails/programme-assignment-assigned.tsx rename to app/features/events/emails/event-part-assigned.tsx index af36ca29..f7061eaa 100644 --- a/app/features/events/emails/programme-assignment-assigned.tsx +++ b/app/features/events/emails/event-part-assigned.tsx @@ -1,6 +1,6 @@ import { Body, Container, Head, Heading, Hr, Html, Link, Preview, Section, Tailwind, Text } from 'react-email' import * as m from '~/i18n/paraglide/messages' -import type { ProgrammeRole } from '../model/programme-role' +import type { ProgrammeRole } from '../model/template-role' function roleLabel(role: ProgrammeRole): string { if (role === 'speaker') return m.email_programme_role_speaker() diff --git a/app/features/events/emails/programme-assignment-unassigned.tsx b/app/features/events/emails/event-part-unassigned.tsx similarity index 97% rename from app/features/events/emails/programme-assignment-unassigned.tsx rename to app/features/events/emails/event-part-unassigned.tsx index dffa07fd..0daf04f9 100644 --- a/app/features/events/emails/programme-assignment-unassigned.tsx +++ b/app/features/events/emails/event-part-unassigned.tsx @@ -1,6 +1,6 @@ import { Body, Container, Head, Heading, Hr, Html, Link, Preview, Section, Tailwind, Text } from 'react-email' import * as m from '~/i18n/paraglide/messages' -import type { ProgrammeRole } from '../model/programme-role' +import type { ProgrammeRole } from '../model/template-role' function roleLabel(role: ProgrammeRole): string { if (role === 'speaker') return m.email_programme_role_speaker() diff --git a/app/features/events/index.server.ts b/app/features/events/index.server.ts index fa275a16..09b57f3c 100644 --- a/app/features/events/index.server.ts +++ b/app/features/events/index.server.ts @@ -1,10 +1,9 @@ // Public server-only surface of the events feature. export { getNextDaysOffs } from './server/days-off.server' -export { eventsNotifications } from './server/notifications.server' export { deleteTemplatePart, - deleteTemplateServiceRole, + deleteTemplateServicePart, duplicateTemplate, getTemplateById, getTemplates, @@ -14,6 +13,7 @@ export { setTemplateResponsible, updateTemplate, upsertTemplatePart, - upsertTemplateServiceRole, -} from './server/programme-templates.server' + upsertTemplateServicePart, +} from './server/event-templates.server' +export { eventsNotifications } from './server/notifications.server' export { seedDefaultTemplates } from './server/seed-templates.server' diff --git a/app/features/events/index.ts b/app/features/events/index.ts index c78a8a52..8a65d554 100644 --- a/app/features/events/index.ts +++ b/app/features/events/index.ts @@ -2,8 +2,8 @@ export { dayLabel, dayLabelShort } from './model/day-label' export { EventStatus } from './model/event-status.type' +export { EventTemplateKey, isSystemTemplate } from './model/event-template.type' export { groupPartsBySlot } from './model/group-parts-by-slot' -export { isSystemTemplate, ProgrammeTemplateKey } from './model/programme-template.type' export { InlineDeleteDialog } from './ui/InlineDeleteDialog' export { PartEditSheet } from './ui/PartEditSheet' export { ServiceEditSheet } from './ui/ServiceEditSheet' diff --git a/app/features/events/model/event-template.test.ts b/app/features/events/model/event-template.test.ts new file mode 100644 index 00000000..7870e263 --- /dev/null +++ b/app/features/events/model/event-template.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { EventTemplateKey, isSystemTemplate } from './event-template.type' + +describe('isSystemTemplate', () => { + it('recognises the day-off template', () => { + expect(isSystemTemplate(EventTemplateKey.DayOff)).toBe(true) + }) + + it('recognises the freeform template', () => { + expect(isSystemTemplate(EventTemplateKey.Freeform)).toBe(true) + }) + + it('leaves meeting-style seeded templates editable', () => { + expect(isSystemTemplate(EventTemplateKey.MidweekMeeting)).toBe(false) + expect(isSystemTemplate(EventTemplateKey.WeekendMeeting)).toBe(false) + expect(isSystemTemplate(EventTemplateKey.Memorial)).toBe(false) + }) + + it('leaves user-created keys editable', () => { + expect(isSystemTemplate('assembly')).toBe(false) + expect(isSystemTemplate('')).toBe(false) + }) +}) diff --git a/app/features/events/model/programme-template.type.ts b/app/features/events/model/event-template.type.ts similarity index 80% rename from app/features/events/model/programme-template.type.ts rename to app/features/events/model/event-template.type.ts index 6ce6f2bf..7c7cde39 100644 --- a/app/features/events/model/programme-template.type.ts +++ b/app/features/events/model/event-template.type.ts @@ -1,4 +1,4 @@ -export enum ProgrammeTemplateKey { +export enum EventTemplateKey { MidweekMeeting = 'midweek-meeting', WeekendMeeting = 'weekend-meeting', Memorial = 'memorial', @@ -6,10 +6,7 @@ export enum ProgrammeTemplateKey { Freeform = 'freeform', } -const SYSTEM_TEMPLATE_KEYS: readonly ProgrammeTemplateKey[] = [ - ProgrammeTemplateKey.DayOff, - ProgrammeTemplateKey.Freeform, -] +const SYSTEM_TEMPLATE_KEYS: readonly EventTemplateKey[] = [EventTemplateKey.DayOff, EventTemplateKey.Freeform] /** * System templates are seeded by the app itself and back domain concepts diff --git a/app/features/events/model/programme-template.test.ts b/app/features/events/model/programme-template.test.ts deleted file mode 100644 index da9ecf36..00000000 --- a/app/features/events/model/programme-template.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isSystemTemplate, ProgrammeTemplateKey } from './programme-template.type' - -describe('isSystemTemplate', () => { - it('recognises the day-off template', () => { - expect(isSystemTemplate(ProgrammeTemplateKey.DayOff)).toBe(true) - }) - - it('recognises the freeform template', () => { - expect(isSystemTemplate(ProgrammeTemplateKey.Freeform)).toBe(true) - }) - - it('leaves meeting-style seeded templates editable', () => { - expect(isSystemTemplate(ProgrammeTemplateKey.MidweekMeeting)).toBe(false) - expect(isSystemTemplate(ProgrammeTemplateKey.WeekendMeeting)).toBe(false) - expect(isSystemTemplate(ProgrammeTemplateKey.Memorial)).toBe(false) - }) - - it('leaves user-created keys editable', () => { - expect(isSystemTemplate('assembly')).toBe(false) - expect(isSystemTemplate('')).toBe(false) - }) -}) diff --git a/app/features/events/model/programme-role.ts b/app/features/events/model/template-role.ts similarity index 85% rename from app/features/events/model/programme-role.ts rename to app/features/events/model/template-role.ts index 07e0104d..5f98aa57 100644 --- a/app/features/events/model/programme-role.ts +++ b/app/features/events/model/template-role.ts @@ -1,5 +1,5 @@ -// Single source of truth for the three roles an assignee can hold on a -// programme part or service-role assignment. The Zod schema in +// Single source of truth for the three roles an assignee can hold on an +// event part or event service part. The Zod schema in // `notifications.server.tsx`, the notify-assignment helper, and both email // templates all derive from this const — adding a new role (e.g. 'chairman') // is a one-line edit that fails compile everywhere the enum is spelled out. diff --git a/app/features/events/routes/days-off/delete.tsx b/app/features/events/routes/days-off/delete.tsx index 55fe0766..5ec92c16 100644 --- a/app/features/events/routes/days-off/delete.tsx +++ b/app/features/events/routes/days-off/delete.tsx @@ -1,6 +1,6 @@ import { redirect } from 'react-router' import { commitSession, getSession } from '~/features/authentication/index.server' -import { ProgrammeTemplateKey } from '~/features/events/model/programme-template.type' +import { EventTemplateKey } from '~/features/events/model/event-template.type' import { deleteDayOff } from '~/features/events/server/days-off.server' import * as m from '~/i18n/paraglide/messages' import { currentAccountContext, withScopeFromContext } from '~/shared/auth/route-context.server' @@ -22,7 +22,7 @@ export function loader({ params, context }: Route.LoaderArgs) { const event = await db.event.findUnique({ where: { id_congregationId: { id: requireParamId(params.eventId, '/me/days-off'), congregationId }, - template: { key: ProgrammeTemplateKey.DayOff }, + template: { key: EventTemplateKey.DayOff }, }, include: { createdBy: true }, }) diff --git a/app/features/events/routes/programs/bulk-delete.tsx b/app/features/events/routes/programs/bulk-delete.tsx index c6557b2c..813a2a1e 100644 --- a/app/features/events/routes/programs/bulk-delete.tsx +++ b/app/features/events/routes/programs/bulk-delete.tsx @@ -1,7 +1,7 @@ import { redirect } from 'react-router' 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 { bulkDeleteEvents } from '~/features/events/server/event-parts.server' +import { canManageAnyProgram, filterToManageableEventIds } from '~/features/events/server/events-auth.server' import { currentAccountContext, permissionsContext, withScopeFromContext } from '~/shared/auth/route-context.server' import logger from '~/shared/infra/logger.server' import type { Permission } from '~/shared/types/permission' diff --git a/app/features/events/routes/programs/bulk-release.tsx b/app/features/events/routes/programs/bulk-release.tsx index 6abd3dd5..63abc8a7 100644 --- a/app/features/events/routes/programs/bulk-release.tsx +++ b/app/features/events/routes/programs/bulk-release.tsx @@ -2,7 +2,7 @@ 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 { canManageAnyProgram, filterToManageableEventIds } from '~/features/events/server/events-auth.server' import * as m from '~/i18n/paraglide/messages' import { congregationContext, diff --git a/app/features/events/routes/programs/bulk-unrelease.tsx b/app/features/events/routes/programs/bulk-unrelease.tsx index d388b646..df4eb256 100644 --- a/app/features/events/routes/programs/bulk-unrelease.tsx +++ b/app/features/events/routes/programs/bulk-unrelease.tsx @@ -2,7 +2,7 @@ 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 { canManageAnyProgram, filterToManageableEventIds } from '~/features/events/server/events-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' diff --git a/app/features/events/routes/programs/days-off.tsx b/app/features/events/routes/programs/days-off.tsx index d4153163..1f498ed3 100644 --- a/app/features/events/routes/programs/days-off.tsx +++ b/app/features/events/routes/programs/days-off.tsx @@ -1,7 +1,7 @@ import { AlertTriangle, CalendarOff, ChevronRight } from 'lucide-react' import { useState } from 'react' import { Link, redirect } from 'react-router' -import { ProgrammeTemplateKey } from '~/features/events/model/programme-template.type' +import { EventTemplateKey } from '~/features/events/model/event-template.type' import { computeFilters, getDefaultDateRange } from '~/features/events/server/event-filters.server' import { type ConflictingEvent, @@ -44,7 +44,7 @@ export function loader({ request, context }: Route.LoaderArgs) { const url = new URL(request.url) const selectors = computeFilters(url.searchParams) - selectors.template = { key: ProgrammeTemplateKey.DayOff } + selectors.template = { key: EventTemplateKey.DayOff } const defaults = getDefaultDateRange() const defaultFrom = defaults.from.toISOString().split('T')[0] @@ -76,14 +76,14 @@ export function loader({ request, context }: Route.LoaderArgs) { }, })) - // Find conflicting programme events for each day-off (with dates for per-week scoping) + // Find conflicting events for each day-off (with dates for per-week scoping) const conflictsByDayOff: Record = {} for (const event of events) { - // Programme assignments are bound to Member ids; resolve via the creator's linked member + // Event parts and event service parts are bound to Member ids; resolve via the creator's linked member const memberId = event.createdBy.memberId - const [partAssignments, serviceAssignments] = memberId + const [parts, serviceAssignments] = memberId ? await Promise.all([ - db.programmePartAssignment.findMany({ + db.eventPart.findMany({ where: { hasConflict: true, congregationId, @@ -96,7 +96,7 @@ export function loader({ request, context }: Route.LoaderArgs) { }, select: { event: { select: { id: true, name: true, startDate: true } } }, }), - db.programmeServiceRoleAssignment.findMany({ + db.eventServicePart.findMany({ where: { hasConflict: true, congregationId, @@ -112,10 +112,10 @@ export function loader({ request, context }: Route.LoaderArgs) { ]) : [[], []] - // Deduplicate by programme event ID + // Deduplicate by event ID const seen = new Set() const conflicts: ConflictingEvent[] = [] - for (const a of [...partAssignments, ...serviceAssignments]) { + for (const a of [...parts, ...serviceAssignments]) { if (!seen.has(a.event.id)) { seen.add(a.event.id) conflicts.push({ @@ -131,7 +131,7 @@ export function loader({ request, context }: Route.LoaderArgs) { // Check if any day-offs exist at all (for contextual empty state) const totalDayOffCount = await db.event.count({ - where: { congregationId, template: { key: ProgrammeTemplateKey.DayOff } }, + where: { congregationId, template: { key: EventTemplateKey.DayOff } }, }) const hasAnyDaysOff = events.length > 0 || totalDayOffCount > 0 diff --git a/app/features/events/routes/programs/events/_edit-event-intents.server.ts b/app/features/events/routes/programs/events/_edit-event-intents.server.ts index 46a5b6a8..892d233b 100644 --- a/app/features/events/routes/programs/events/_edit-event-intents.server.ts +++ b/app/features/events/routes/programs/events/_edit-event-intents.server.ts @@ -11,15 +11,15 @@ import { } from '~/features/events/schemas/program-edit.schema' import { addPartAssignment, - addServiceRoleAssignment, + addServicePartAssignment, applyTemplateToEvent, deletePartAssignment, - deleteServiceRoleAssignment, + deleteServicePartAssignment, type UpdateEventFields, updateEvent, updatePartAssignment, - updateServiceRoleAssignment, -} from '~/features/events/server/programme-events.server' + updateServicePartAssignment, +} from '~/features/events/server/event-parts.server' import * as m from '~/i18n/paraglide/messages' import type { TransactionClient } from '~/shared/infra/db.server' import { combineLocalDateTime } from '~/shared/utils/event-time' @@ -187,7 +187,7 @@ async function handleAddService( const submission = parseWithZod(formData, { schema: addServiceSchema }) if (submission.status !== 'success') return submission - await addServiceRoleAssignment( + await addServicePartAssignment( db, { eventId, @@ -209,7 +209,7 @@ async function handleUpdateService( const submission = parseWithZod(formData, { schema: updateServiceSchema }) if (submission.status !== 'success') return submission - await updateServiceRoleAssignment( + await updateServicePartAssignment( db, submission.value.serviceAssignmentId, { name: submission.value.serviceName, allowedRoleIds: submission.value.allowedRoleIds }, @@ -227,7 +227,7 @@ async function handleDeleteService( const submission = parseWithZod(formData, { schema: deleteServiceSchema }) if (submission.status !== 'success') return submission - await deleteServiceRoleAssignment(db, submission.value.serviceAssignmentId, congregationId) + await deleteServicePartAssignment(db, submission.value.serviceAssignmentId, congregationId) return { message: m.programs_edit_service_deleted() } } diff --git a/app/features/events/routes/programs/events/assign-part.tsx b/app/features/events/routes/programs/events/assign-part.tsx index 47123824..cfac172c 100644 --- a/app/features/events/routes/programs/events/assign-part.tsx +++ b/app/features/events/routes/programs/events/assign-part.tsx @@ -4,13 +4,13 @@ import { data, Form, Link, redirect, useSearchParams } from 'react-router' import { commitSession, getSession } from '~/features/authentication/index.server' import { assignPartSchema } from '~/features/events/schemas/assign-part.schema' import { loadPartAssignmentCandidates } from '~/features/events/server/assign-part-loader.server' +import { assignPart, getEventProgramme } from '~/features/events/server/event-part-assignments.server' +import { canEditEvent } from '~/features/events/server/events-auth.server' import { buildAssignmentContext, dispatchAssignmentDiffs, partAssignmentDiffs, } from '~/features/events/server/notify-assignment.server' -import { assignPart, getEventProgramme } from '~/features/events/server/programme-assignments.server' -import { canEditEvent } from '~/features/events/server/programme-auth.server' import { ExternalSpeakerInfoCard } from '~/features/events/ui/ExternalSpeakerInfoCard' import { PublisherInfoCard } from '~/features/events/ui/PublisherInfoCard' import * as m from '~/i18n/paraglide/messages' @@ -58,7 +58,7 @@ export function loader({ request, params, context }: Route.LoaderArgs) { throw redirect('/programs') } - const assignment = event.partAssignments.find(a => a.id === assignmentId) + const assignment = event.eventParts.find(a => a.id === assignmentId) const candidates = await loadPartAssignmentCandidates(db, assignment, congregationId) return { @@ -99,7 +99,7 @@ export async function action({ request, params, context }: Route.ActionArgs) { throw redirect('/programs') } - const assignmentBefore = await db.programmePartAssignment.findFirst({ + const assignmentBefore = await db.eventPart.findFirst({ where: { id: assignmentId, congregationId }, select: { name: true }, }) @@ -129,7 +129,7 @@ export async function action({ request, params, context }: Route.ActionArgs) { const notifyCtx = buildAssignmentContext({ event, assignmentName: assignmentBefore?.name, - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: assignmentId, congregationId, actorId: currentUser.id, diff --git a/app/features/events/routes/programs/events/assign-service.tsx b/app/features/events/routes/programs/events/assign-service.tsx index a70360b9..cf08c811 100644 --- a/app/features/events/routes/programs/events/assign-service.tsx +++ b/app/features/events/routes/programs/events/assign-service.tsx @@ -4,16 +4,16 @@ import { data, Form, redirect, useSearchParams } from 'react-router' import { commitSession, getSession } from '~/features/authentication/index.server' import { assignServiceSchema } from '~/features/events/schemas/assign-service.schema' import { - getServiceRoleAssignmentAllowedRoleIds, + getServicePartAssignmentAllowedRoleIds, resolveEligibleUserIds, } from '~/features/events/server/allowed-roles.server' +import { assignServicePart, getEventProgramme } from '~/features/events/server/event-part-assignments.server' +import { canEditEvent } from '~/features/events/server/events-auth.server' import { buildAssignmentContext, dispatchAssignmentDiffs, - serviceRoleAssignmentDiffs, + servicePartAssignmentDiffs, } from '~/features/events/server/notify-assignment.server' -import { assignServiceRole, getEventProgramme } from '~/features/events/server/programme-assignments.server' -import { canEditEvent } from '~/features/events/server/programme-auth.server' import { PublisherInfoCard } from '~/features/events/ui/PublisherInfoCard' import * as m from '~/i18n/paraglide/messages' import { @@ -58,7 +58,7 @@ export function loader({ request, params, context }: Route.LoaderArgs) { throw redirect('/programs') } - const assignment = event.serviceRoleAssignments.find(a => a.id === assignmentId) + const assignment = event.eventServiceParts.find(a => a.id === assignmentId) const users = await db.member.findMany({ where: { congregationId, leftAt: null }, @@ -68,7 +68,7 @@ export function loader({ request, params, context }: Route.LoaderArgs) { let assigneeCandidates = users if (assignment) { - const allowed = await getServiceRoleAssignmentAllowedRoleIds(db, assignment.id, congregationId) + const allowed = await getServicePartAssignmentAllowedRoleIds(db, assignment.id, congregationId) const eligible = await resolveEligibleUserIds(db, allowed, congregationId) assigneeCandidates = eligible.map(id => userById.get(id)).filter((u): u is (typeof users)[number] => u != null) } @@ -100,12 +100,12 @@ export async function action({ request, params, context }: Route.ActionArgs) { throw redirect('/programs') } - const assignmentBefore = await db.programmeServiceRoleAssignment.findFirst({ + const assignmentBefore = await db.eventServicePart.findFirst({ where: { id: assignmentId, congregationId }, select: { name: true }, }) - const result = await assignServiceRole(db, assignmentId, assigneeId, congregationId) + const result = await assignServicePart(db, assignmentId, assigneeId, congregationId) if ('error' in result) { session.flash('error', result.error) @@ -123,14 +123,14 @@ export async function action({ request, params, context }: Route.ActionArgs) { buildAssignmentContext({ event, assignmentName: assignmentBefore?.name, - entityType: 'ProgrammeServiceRoleAssignment', + entityType: 'EventServicePart', entityId: assignmentId, congregationId, actorId: currentUser.id, locale: cong.locale, timezone: cong.timezone, }), - serviceRoleAssignmentDiffs({ previousAssigneeId: result.previousAssigneeId }, { assigneeId }), + servicePartAssignmentDiffs({ previousAssigneeId: result.previousAssigneeId }, { assigneeId }), ).catch(err => logger.error('Failed to dispatch programme-assignment notifications', { err, diff --git a/app/features/events/routes/programs/events/delete.tsx b/app/features/events/routes/programs/events/delete.tsx index f0a255a7..c63703e0 100644 --- a/app/features/events/routes/programs/events/delete.tsx +++ b/app/features/events/routes/programs/events/delete.tsx @@ -1,7 +1,7 @@ import { redirect } from 'react-router' import { commitSession, getSession } from '~/features/authentication/index.server' -import { canEditEvent } from '~/features/events/server/programme-auth.server' -import { deleteEvent } from '~/features/events/server/programme-events.server' +import { deleteEvent } from '~/features/events/server/event-parts.server' +import { canEditEvent } from '~/features/events/server/events-auth.server' import * as m from '~/i18n/paraglide/messages' import { congregationContext, diff --git a/app/features/events/routes/programs/events/edit.tsx b/app/features/events/routes/programs/events/edit.tsx index e36fd817..67fbde8c 100644 --- a/app/features/events/routes/programs/events/edit.tsx +++ b/app/features/events/routes/programs/events/edit.tsx @@ -1,12 +1,12 @@ import { useState } from 'react' import { data, redirect, useFetcher } from 'react-router' import { commitSession, getSession } from '~/features/authentication/index.server' -import { getEventProgramme } from '~/features/events/server/programme-assignments.server' -import { canEditEvent } from '~/features/events/server/programme-auth.server' -import { getTemplates } from '~/features/events/server/programme-templates.server' +import { getEventProgramme } from '~/features/events/server/event-part-assignments.server' +import { getTemplates } from '~/features/events/server/event-templates.server' +import { canEditEvent } from '~/features/events/server/events-auth.server' import { EventInfoCard } from '~/features/events/ui/EventInfoCard' import { EventPartsCard, type PartAssignment, reorderPartIds } from '~/features/events/ui/EventPartsCard' -import { EventServicesCard, type ServiceRoleAssignment } from '~/features/events/ui/EventServicesCard' +import { EventServicesCard, type ServicePartAssignment } from '~/features/events/ui/EventServicesCard' import { InlineDeleteDialog } from '~/features/events/ui/InlineDeleteDialog' import { PartEditSheet } from '~/features/events/ui/PartEditSheet' import { ServiceEditSheet } from '~/features/events/ui/ServiceEditSheet' @@ -48,39 +48,37 @@ export function loader({ params, context }: Route.LoaderArgs) { const [templates, allRoles] = await Promise.all([getTemplates(db, congregationId), listRoles(db, congregationId)]) - const partAllowed = await db.programmePartAssignmentAllowedRole.findMany({ - where: { assignmentId: { in: event.partAssignments.map(p => p.id) }, congregationId }, - select: { assignmentId: true, roleId: true, asKind: true }, + const partAllowed = await db.eventPartAllowedRole.findMany({ + where: { eventPartId: { in: event.eventParts.map(p => p.id) }, congregationId }, + select: { eventPartId: true, roleId: true, asKind: true }, }) - const serviceAllowed = await db.programmeServiceRoleAssignmentAllowedRole.findMany({ - where: { assignmentId: { in: event.serviceRoleAssignments.map(s => s.id) }, congregationId }, - select: { assignmentId: true, roleId: true }, + const serviceAllowed = await db.eventServicePartAllowedRole.findMany({ + where: { eventServicePartId: { in: event.eventServiceParts.map(s => s.id) }, congregationId }, + select: { eventServicePartId: true, roleId: true }, }) - const partAssignmentsWithRoles = event.partAssignments.map(p => ({ + const partsWithRoles = event.eventParts.map(p => ({ ...p, allowedSpeakerRoleIds: partAllowed - .filter(r => r.assignmentId === p.id && r.asKind === 'speaker') - .map(r => r.roleId), - allowedReaderRoleIds: partAllowed - .filter(r => r.assignmentId === p.id && r.asKind === 'reader') + .filter(r => r.eventPartId === p.id && r.asKind === 'speaker') .map(r => r.roleId), + allowedReaderRoleIds: partAllowed.filter(r => r.eventPartId === p.id && r.asKind === 'reader').map(r => r.roleId), })) - const serviceAssignmentsWithRoles = event.serviceRoleAssignments.map(s => ({ + const serviceAssignmentsWithRoles = event.eventServiceParts.map(s => ({ ...s, - allowedRoleIds: serviceAllowed.filter(r => r.assignmentId === s.id).map(r => r.roleId), + allowedRoleIds: serviceAllowed.filter(r => r.eventServicePartId === s.id).map(r => r.roleId), })) const roles = allRoles.map(r => ({ id: r.id, key: r.key, name: r.name, isBuiltIn: r.isBuiltIn })) - const sectionSuggestions = distinct(event.partAssignments.map(p => p.section)) - const trackSuggestions = distinct(event.partAssignments.map(p => p.track)) + const sectionSuggestions = distinct(event.eventParts.map(p => p.section)) + const trackSuggestions = distinct(event.eventParts.map(p => p.track)) return { event: { ...event, - partAssignments: partAssignmentsWithRoles, - serviceRoleAssignments: serviceAssignmentsWithRoles, + eventParts: partsWithRoles, + eventServiceParts: serviceAssignmentsWithRoles, }, templates, roles, @@ -131,7 +129,7 @@ export default function EditEventPage({ loaderData }: Route.ComponentProps) { const [editingPart, setEditingPart] = useState(null) const [partSheetOpen, setPartSheetOpen] = useState(false) - const [editingService, setEditingService] = useState(null) + const [editingService, setEditingService] = useState(null) const [serviceSheetOpen, setServiceSheetOpen] = useState(false) const [deleteTarget, setDeleteTarget] = useState<{ type: 'part' | 'service'; id: number; name: string } | null>(null) @@ -140,7 +138,7 @@ export default function EditEventPage({ loaderData }: Route.ComponentProps) { function handlePartDragEnd(e: { active: { id: number | string }; over: { id: number | string } | null }) { if (!e.over) return - const ids = event.partAssignments.map(p => p.id) + const ids = event.eventParts.map(p => p.id) const reordered = reorderPartIds(ids, Number(e.active.id), Number(e.over.id)) if (reordered === ids) return @@ -188,7 +186,7 @@ export default function EditEventPage({ loaderData }: Route.ComponentProps) { { setEditingService(null) setServiceSheetOpen(true) @@ -225,7 +223,7 @@ export default function EditEventPage({ loaderData }: Route.ComponentProps) { part={editingPart} mode="event" fetcher={partFetcher} - defaultOrder={event.partAssignments.length + 1} + defaultOrder={event.eventParts.length + 1} roles={roles} sectionSuggestions={sectionSuggestions} trackSuggestions={trackSuggestions} diff --git a/app/features/events/routes/programs/events/external-speaker-info.tsx b/app/features/events/routes/programs/events/external-speaker-info.tsx index 5f7ff981..5cee2428 100644 --- a/app/features/events/routes/programs/events/external-speaker-info.tsx +++ b/app/features/events/routes/programs/events/external-speaker-info.tsx @@ -32,7 +32,7 @@ export function loader({ request, params, context }: Route.LoaderArgs) { }) if (!speaker) return Response.json(null) - const recentHistory = await db.programmePartAssignment.findMany({ + const recentHistory = await db.eventPart.findMany({ where: { congregationId, externalSpeakerId, diff --git a/app/features/events/routes/programs/events/publisher-info.tsx b/app/features/events/routes/programs/events/publisher-info.tsx index e712fadb..209be5bb 100644 --- a/app/features/events/routes/programs/events/publisher-info.tsx +++ b/app/features/events/routes/programs/events/publisher-info.tsx @@ -1,4 +1,4 @@ -import { ProgrammeTemplateKey } from '~/features/events/model/programme-template.type' +import { EventTemplateKey } from '~/features/events/model/event-template.type' import type { PartSlot } from '~/features/events/server/cadence-shared.server' import { listUserSameEventAssignments } from '~/features/events/server/list-user-same-event-assignments.server' import { resolvePublisherCadence } from '~/features/events/server/resolve-publisher-cadence.server' @@ -53,7 +53,7 @@ export function loader({ request, params, context }: Route.LoaderArgs) { where: { congregationId, createdById: accountId, - template: { key: ProgrammeTemplateKey.DayOff }, + template: { key: EventTemplateKey.DayOff }, startDate: { lte: event.endDate }, endDate: { gte: event.startDate }, }, diff --git a/app/features/events/routes/programs/events/release.tsx b/app/features/events/routes/programs/events/release.tsx index e868292d..a11243ea 100644 --- a/app/features/events/routes/programs/events/release.tsx +++ b/app/features/events/routes/programs/events/release.tsx @@ -1,7 +1,7 @@ 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 { canEditEvent } from '~/features/events/server/events-auth.server' import * as m from '~/i18n/paraglide/messages' import { congregationContext, diff --git a/app/features/events/routes/programs/events/remove-assignment.tsx b/app/features/events/routes/programs/events/remove-assignment.tsx index 1df410c7..bbd68886 100644 --- a/app/features/events/routes/programs/events/remove-assignment.tsx +++ b/app/features/events/routes/programs/events/remove-assignment.tsx @@ -1,13 +1,13 @@ import { data, redirect } from 'react-router' import { commitSession, getSession } from '~/features/authentication/index.server' +import { unassignPart, unassignServicePart } from '~/features/events/server/event-part-assignments.server' +import { canEditEvent } from '~/features/events/server/events-auth.server' import { buildAssignmentContext, dispatchAssignmentDiffs, partAssignmentDiffs, - serviceRoleAssignmentDiffs, + servicePartAssignmentDiffs, } from '~/features/events/server/notify-assignment.server' -import { unassignPart, unassignServiceRole } from '~/features/events/server/programme-assignments.server' -import { canEditEvent } from '~/features/events/server/programme-auth.server' import * as m from '~/i18n/paraglide/messages' import { congregationContext, @@ -51,7 +51,7 @@ export async function action({ request, params, context }: Route.ActionArgs) { let didRemove = false if (type === 'part') { - const assignmentBefore = await db.programmePartAssignment.findFirst({ + const assignmentBefore = await db.eventPart.findFirst({ where: { id: assignmentId, congregationId }, select: { name: true }, }) @@ -66,7 +66,7 @@ export async function action({ request, params, context }: Route.ActionArgs) { buildAssignmentContext({ event, assignmentName: assignmentBefore?.name, - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: assignmentId, congregationId, actorId: currentUser.id, @@ -87,11 +87,11 @@ export async function action({ request, params, context }: Route.ActionArgs) { ) } } else if (type === 'service') { - const assignmentBefore = await db.programmeServiceRoleAssignment.findFirst({ + const assignmentBefore = await db.eventServicePart.findFirst({ where: { id: assignmentId, congregationId }, select: { name: true }, }) - const result = await unassignServiceRole(db, assignmentId, congregationId) + const result = await unassignServicePart(db, assignmentId, congregationId) logger.info(`Unassigned service role. User ID: ${currentUser.id}. Assignment: ${assignmentId}.`) if (result) { @@ -101,14 +101,14 @@ export async function action({ request, params, context }: Route.ActionArgs) { buildAssignmentContext({ event, assignmentName: assignmentBefore?.name, - entityType: 'ProgrammeServiceRoleAssignment', + entityType: 'EventServicePart', entityId: assignmentId, congregationId, actorId: currentUser.id, locale: cong.locale, timezone: cong.timezone, }), - serviceRoleAssignmentDiffs({ previousAssigneeId: result.previousAssigneeId }, { assigneeId: null }), + servicePartAssignmentDiffs({ previousAssigneeId: result.previousAssigneeId }, { assigneeId: null }), ).catch(err => logger.error('Failed to dispatch programme-assignment notifications', { err, diff --git a/app/features/events/routes/programs/events/reorder-parts.tsx b/app/features/events/routes/programs/events/reorder-parts.tsx index 6b557f88..960fb3c9 100644 --- a/app/features/events/routes/programs/events/reorder-parts.tsx +++ b/app/features/events/routes/programs/events/reorder-parts.tsx @@ -1,6 +1,6 @@ import { redirect } from 'react-router' -import { canEditEvent } from '~/features/events/server/programme-auth.server' -import { reorderPartAssignments } from '~/features/events/server/programme-events.server' +import { reorderPartAssignments } from '~/features/events/server/event-parts.server' +import { canEditEvent } from '~/features/events/server/events-auth.server' import { currentAccountContext, permissionsContext, withScopeFromContext } from '~/shared/auth/route-context.server' import type { Permission } from '~/shared/types/permission' import { requireParamId } from '~/shared/utils/params.server' diff --git a/app/features/events/routes/programs/events/unrelease.tsx b/app/features/events/routes/programs/events/unrelease.tsx index 78f91c91..5d269ee2 100644 --- a/app/features/events/routes/programs/events/unrelease.tsx +++ b/app/features/events/routes/programs/events/unrelease.tsx @@ -1,7 +1,7 @@ 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 { canEditEvent } from '~/features/events/server/events-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' diff --git a/app/features/events/routes/programs/events/view.tsx b/app/features/events/routes/programs/events/view.tsx index b348f55f..36c0de29 100644 --- a/app/features/events/routes/programs/events/view.tsx +++ b/app/features/events/routes/programs/events/view.tsx @@ -15,12 +15,12 @@ import { Link, redirect, useFetcher } from 'react-router' import { EventStatus } from '~/features/events/model/event-status.type' import { getPartAssignmentAllowedRoleIds, - getServiceRoleAssignmentAllowedRoleIds, + getServicePartAssignmentAllowedRoleIds, resolveEligibleUserIds, } from '~/features/events/server/allowed-roles.server' +import { getEventProgramme } from '~/features/events/server/event-part-assignments.server' +import { canEditEvent } from '~/features/events/server/events-auth.server' import { listExternalSpeakers } from '~/features/events/server/external-speakers.server' -import { getEventProgramme } from '~/features/events/server/programme-assignments.server' -import { canEditEvent } from '~/features/events/server/programme-auth.server' import { AssignPartSheet } from '~/features/events/ui/AssignPartSheet' import { AssignServiceSheet } from '~/features/events/ui/AssignServiceSheet' import { UnassignConfirmDialog } from '~/features/events/ui/UnassignConfirmDialog' @@ -107,7 +107,7 @@ export function loader({ params, context }: Route.LoaderArgs) { const serviceCandidates: Record = {} if (canEdit) { const userById = new Map(users.map(u => [u.id, u])) - for (const assignment of event.partAssignments) { + for (const assignment of event.eventParts) { const speakerAllowed = await getPartAssignmentAllowedRoleIds(db, assignment.id, 'speaker', congregationId) const readerAllowed = await getPartAssignmentAllowedRoleIds(db, assignment.id, 'reader', congregationId) const speakerIds = await resolveEligibleUserIds(db, speakerAllowed, congregationId) @@ -117,8 +117,8 @@ export function loader({ params, context }: Route.LoaderArgs) { readerIds: readerIds.filter(id => userById.has(id)), } } - for (const assignment of event.serviceRoleAssignments) { - const allowed = await getServiceRoleAssignmentAllowedRoleIds(db, assignment.id, congregationId) + for (const assignment of event.eventServiceParts) { + const allowed = await getServicePartAssignmentAllowedRoleIds(db, assignment.id, congregationId) const eligible = await resolveEligibleUserIds(db, allowed, congregationId) serviceCandidates[assignment.id] = eligible.filter(id => userById.has(id)) } @@ -174,34 +174,34 @@ export default function EventViewPage({ loaderData }: Route.ComponentProps) { } | null>(null) // Derived values - const hasAnyTopic = event.partAssignments.some(a => a.topic) - const partAssignedCount = event.partAssignments.filter(a => a.assigneeId ?? a.externalSpeakerId).length - const serviceAssignedCount = event.serviceRoleAssignments.filter(a => a.assigneeId).length + const hasAnyTopic = event.eventParts.some(a => a.topic) + const partAssignedCount = event.eventParts.filter(a => a.assigneeId ?? a.externalSpeakerId).length + const serviceAssignedCount = event.eventServiceParts.filter(a => a.assigneeId).length // Group parts by section, then by track within each section - type PartAssignment = (typeof event.partAssignments)[number] - type TrackGroup = { track: string; parts: PartAssignment[] } + type PartAssignment = (typeof event.eventParts)[number] + type TrackGroup = { track: string; eventParts: PartAssignment[] } type SectionGroup = { section: string; tracks: TrackGroup[] } const partsBySection: SectionGroup[] = [] let currentSection: string | null = null let currentTrack: string | null = null - for (const part of event.partAssignments) { + for (const part of event.eventParts) { const section = part.section || '' const track = part.track || '' if (section !== currentSection) { - partsBySection.push({ section, tracks: [{ track, parts: [] }] }) + partsBySection.push({ section, tracks: [{ track, eventParts: [] }] }) currentSection = section currentTrack = track } else if (track !== currentTrack) { - partsBySection.at(-1)?.tracks.push({ track, parts: [] }) + partsBySection.at(-1)?.tracks.push({ track, eventParts: [] }) currentTrack = track } const lastSection = partsBySection.at(-1) - lastSection?.tracks.at(-1)?.parts.push(part) + lastSection?.tracks.at(-1)?.eventParts.push(part) } const colCount = 4 + (hasAnyTopic ? 1 : 0) + (canEdit ? 1 : 0) @@ -286,7 +286,7 @@ export default function EventViewPage({ loaderData }: Route.ComponentProps) { {m.programs_view_assigned_count({ count: String(partAssignedCount), - total: String(event.partAssignments.length), + total: String(event.eventParts.length), })} @@ -326,7 +326,7 @@ export default function EventViewPage({ loaderData }: Route.ComponentProps) { )} - {trackGroup.parts.map(assignment => ( + {trackGroup.eventParts.map(assignment => ( {m.programs_view_assigned_count({ count: String(serviceAssignedCount), - total: String(event.serviceRoleAssignments.length), + total: String(event.eventServiceParts.length), })} @@ -369,7 +369,7 @@ export default function EventViewPage({ loaderData }: Route.ComponentProps) { - {event.serviceRoleAssignments.map(assignment => ( + {event.eventServiceParts.map(assignment => ( {assignment.name} ({ ...event, canEdit: isProgramManager || (event.templateId != null && responsibleTemplateIdSet.has(event.templateId)), - conflictCount: event._count.partAssignments + event._count.serviceRoleAssignments, + conflictCount: event._count.eventParts + event._count.eventServiceParts, })) return { diff --git a/app/features/events/routes/programs/new.tsx b/app/features/events/routes/programs/new.tsx index a0b7b9da..9662047c 100644 --- a/app/features/events/routes/programs/new.tsx +++ b/app/features/events/routes/programs/new.tsx @@ -9,13 +9,13 @@ import { recurringEventSchema, singleEventSchema, } from '~/features/events/schemas/program-new.schema' -import { canManageAnyProgram, getResponsibleTemplateIds } from '~/features/events/server/programme-auth.server' -import { createFreeformEvent } from '~/features/events/server/programme-events.server' +import { createFreeformEvent } from '~/features/events/server/event-parts.server' import { createSingleEventFromTemplate, generateEventsFromTemplate, -} from '~/features/events/server/programme-generation.server' -import { getTemplates } from '~/features/events/server/programme-templates.server' +} from '~/features/events/server/event-template-generation.server' +import { getTemplates } from '~/features/events/server/event-templates.server' +import { canManageAnyProgram, getResponsibleTemplateIds } from '~/features/events/server/events-auth.server' import * as m from '~/i18n/paraglide/messages' import { congregationContext, diff --git a/app/features/events/server/allowed-roles.integration.test.ts b/app/features/events/server/allowed-roles.integration.test.ts index ade0cf22..5b16a33a 100644 --- a/app/features/events/server/allowed-roles.integration.test.ts +++ b/app/features/events/server/allowed-roles.integration.test.ts @@ -5,10 +5,10 @@ import { resolveEligibleUserIds } from '~/features/events/server/allowed-roles.s import { applyTemplateToEvent, updatePartAssignment, - updateServiceRoleAssignment, -} from '~/features/events/server/programme-events.server' -import { createSingleEventFromTemplate } from '~/features/events/server/programme-generation.server' -import { upsertTemplatePart, upsertTemplateServiceRole } from '~/features/events/server/programme-templates.server' + updateServicePartAssignment, +} from '~/features/events/server/event-parts.server' +import { createSingleEventFromTemplate } from '~/features/events/server/event-template-generation.server' +import { upsertTemplatePart, upsertTemplateServicePart } from '~/features/events/server/event-templates.server' const adapter = new PrismaPg({ connectionString: process.env.DB_RUNTIME_URL ?? process.env.DB_URL, @@ -38,7 +38,7 @@ let plainPublisherUserId: number let nonPublisherUserId: number let templateId: number let speakerPartId: number -let serviceRoleId: number +let servicePartId: number let foreignTemplatePartId: number let customRoleId: number @@ -139,7 +139,7 @@ beforeAll(async () => { }) // Template + parts + service role - const template = await tx.programmeTemplate.create({ + const template = await tx.eventTemplate.create({ data: { name: 'Test Template', key: `t-${ts}`, @@ -150,7 +150,7 @@ beforeAll(async () => { }) templateId = template.id - const speakerPart = await tx.programmeTemplatePart.create({ + const speakerPart = await tx.templatePart.create({ data: { name: 'Discours', section: '', @@ -163,10 +163,10 @@ beforeAll(async () => { }) speakerPartId = speakerPart.id - const service = await tx.programmeTemplateServiceRole.create({ + const service = await tx.templateServicePart.create({ data: { name: 'Son', key: `son-${ts}`, templateId, congregationId: primaryCongId }, }) - serviceRoleId = service.id + servicePartId = service.id }) await withScope(foreignCongId, async tx => { @@ -175,10 +175,10 @@ beforeAll(async () => { }) foreignElderRoleId = foreignElder.id - const foreignTemplate = await tx.programmeTemplate.create({ + const foreignTemplate = await tx.eventTemplate.create({ data: { name: 'Foreign T', key: `ft-${ts}`, congregationId: foreignCongId }, }) - const foreignPart = await tx.programmeTemplatePart.create({ + const foreignPart = await tx.templatePart.create({ data: { name: 'Foreign Part', section: '', @@ -191,7 +191,7 @@ beforeAll(async () => { }) foreignTemplatePartId = foreignPart.id - await tx.programmeTemplatePartAllowedRole.create({ + await tx.templatePartAllowedRole.create({ data: { partId: foreignTemplatePartId, roleId: foreignElderRoleId, @@ -206,15 +206,15 @@ afterAll(async () => { for (const congId of [primaryCongId, foreignCongId]) { if (!congId) continue await withScope(congId, async tx => { - await tx.programmePartAssignmentAllowedRole.deleteMany({}) - await tx.programmeServiceRoleAssignmentAllowedRole.deleteMany({}) - await tx.programmeTemplatePartAllowedRole.deleteMany({}) - await tx.programmeTemplateServiceRoleAllowedRole.deleteMany({}) - await tx.programmePartAssignment.deleteMany({}) - await tx.programmeServiceRoleAssignment.deleteMany({}) - await tx.programmeTemplatePart.deleteMany({}) - await tx.programmeTemplateServiceRole.deleteMany({}) - await tx.programmeTemplate.deleteMany({}) + await tx.eventPartAllowedRole.deleteMany({}) + await tx.eventServicePartAllowedRole.deleteMany({}) + await tx.templatePartAllowedRole.deleteMany({}) + await tx.templateServicePartAllowedRole.deleteMany({}) + await tx.eventPart.deleteMany({}) + await tx.eventServicePart.deleteMany({}) + await tx.templatePart.deleteMany({}) + await tx.templateServicePart.deleteMany({}) + await tx.eventTemplate.deleteMany({}) await tx.event.deleteMany({}) await tx.memberRoleAssignment.deleteMany({}) await tx.userRoleAssignment.deleteMany({}) @@ -276,7 +276,7 @@ describe('upsertTemplatePart + RLS (integration)', () => { ) const rows = await withScope(primaryCongId, tx => - tx.programmeTemplatePartAllowedRole.findMany({ + tx.templatePartAllowedRole.findMany({ where: { partId: speakerPartId }, orderBy: [{ asKind: 'asc' }, { roleId: 'asc' }], }), @@ -287,35 +287,35 @@ describe('upsertTemplatePart + RLS (integration)', () => { it('does not see allowed-role rows from another congregation', async () => { const rows = await withScope(primaryCongId, tx => - tx.programmeTemplatePartAllowedRole.findMany({ where: { partId: foreignTemplatePartId } }), + tx.templatePartAllowedRole.findMany({ where: { partId: foreignTemplatePartId } }), ) expect(rows).toEqual([]) }) }) -describe('upsertTemplateServiceRole + RLS (integration)', () => { +describe('upsertTemplateServicePart + RLS (integration)', () => { it('persists allowed-role rows for service role', async () => { await withScope(primaryCongId, tx => - upsertTemplateServiceRole( + upsertTemplateServicePart( tx, templateId, - { id: serviceRoleId, name: 'Son', key: `son-${ts}`, allowedRoleIds: [elderRoleId] }, + { id: servicePartId, name: 'Son', key: `son-${ts}`, allowedRoleIds: [elderRoleId] }, primaryCongId, elderAccountId, ), ) const rows = await withScope(primaryCongId, tx => - tx.programmeTemplateServiceRoleAllowedRole.findMany({ where: { serviceRoleId } }), + tx.templateServicePartAllowedRole.findMany({ where: { servicePartId } }), ) - expect(rows).toEqual([{ serviceRoleId, roleId: elderRoleId, congregationId: primaryCongId }]) + expect(rows).toEqual([{ servicePartId, roleId: elderRoleId, congregationId: primaryCongId }]) }) }) describe('createSingleEventFromTemplate copies allowed roles (integration)', () => { it('copies template-part and service-role allowed-role lists onto the new event assignments', async () => { // Pre-condition: speakerPart has [elderRoleId speaker, publisherRoleId reader] from previous test - // serviceRole has [elderRoleId] from previous test + // servicePart has [elderRoleId] from previous test const event = await withScope(primaryCongId, tx => createSingleEventFromTemplate(tx, templateId, new Date('2099-01-15'), elderAccountId, primaryCongId, 'UTC'), ) @@ -323,8 +323,8 @@ describe('createSingleEventFromTemplate copies allowed roles (integration)', () if (!event) return const partAllowed = await withScope(primaryCongId, tx => - tx.programmePartAssignmentAllowedRole.findMany({ - where: { assignment: { eventId: event.id } }, + tx.eventPartAllowedRole.findMany({ + where: { eventPart: { eventId: event.id } }, orderBy: { asKind: 'asc' }, }), ) @@ -334,8 +334,8 @@ describe('createSingleEventFromTemplate copies allowed roles (integration)', () ]) const serviceAllowed = await withScope(primaryCongId, tx => - tx.programmeServiceRoleAssignmentAllowedRole.findMany({ - where: { assignment: { eventId: event.id } }, + tx.eventServicePartAllowedRole.findMany({ + where: { eventServicePart: { eventId: event.id } }, }), ) expect(serviceAllowed.map(r => r.roleId)).toEqual([elderRoleId]) @@ -359,8 +359,8 @@ describe('applyTemplateToEvent copies allowed roles (integration)', () => { }) const partAllowed = await withScope(primaryCongId, tx => - tx.programmePartAssignmentAllowedRole.findMany({ - where: { assignment: { eventId: freeform.id } }, + tx.eventPartAllowedRole.findMany({ + where: { eventPart: { eventId: freeform.id } }, orderBy: { asKind: 'asc' }, }), ) @@ -368,7 +368,7 @@ describe('applyTemplateToEvent copies allowed roles (integration)', () => { }) }) -describe('updatePartAssignment + updateServiceRoleAssignment update allowed roles (integration)', () => { +describe('updatePartAssignment + updateServicePartAssignment update allowed roles (integration)', () => { it('replaces existing allowed-role rows on update', async () => { const event = await withScope(primaryCongId, tx => createSingleEventFromTemplate(tx, templateId, new Date('2099-03-15'), elderAccountId, primaryCongId, 'UTC'), @@ -376,7 +376,7 @@ describe('updatePartAssignment + updateServiceRoleAssignment update allowed role if (!event) throw new Error('event not created') const partAssignment = await withScope(primaryCongId, tx => - tx.programmePartAssignment.findFirst({ where: { eventId: event.id } }), + tx.eventPart.findFirst({ where: { eventId: event.id } }), ) if (!partAssignment) throw new Error('part assignment missing') @@ -400,13 +400,13 @@ describe('updatePartAssignment + updateServiceRoleAssignment update allowed role ) const after = await withScope(primaryCongId, tx => - tx.programmePartAssignmentAllowedRole.findMany({ - where: { assignmentId: partAssignment.id }, + tx.eventPartAllowedRole.findMany({ + where: { eventPartId: partAssignment.id }, orderBy: { asKind: 'asc' }, }), ) expect(after).toEqual([ - { assignmentId: partAssignment.id, roleId: publisherRoleId, asKind: 'speaker', congregationId: primaryCongId }, + { eventPartId: partAssignment.id, roleId: publisherRoleId, asKind: 'speaker', congregationId: primaryCongId }, ]) }) @@ -417,12 +417,12 @@ describe('updatePartAssignment + updateServiceRoleAssignment update allowed role if (!event) throw new Error('event not created') const serviceAssignment = await withScope(primaryCongId, tx => - tx.programmeServiceRoleAssignment.findFirst({ where: { eventId: event.id } }), + tx.eventServicePart.findFirst({ where: { eventId: event.id } }), ) if (!serviceAssignment) throw new Error('service assignment missing') await withScope(primaryCongId, tx => - updateServiceRoleAssignment( + updateServicePartAssignment( tx, serviceAssignment.id, { name: 'Son', allowedRoleIds: [] }, // clear allowed roles @@ -432,7 +432,7 @@ describe('updatePartAssignment + updateServiceRoleAssignment update allowed role ) const after = await withScope(primaryCongId, tx => - tx.programmeServiceRoleAssignmentAllowedRole.findMany({ where: { assignmentId: serviceAssignment.id } }), + tx.eventServicePartAllowedRole.findMany({ where: { eventServicePartId: serviceAssignment.id } }), ) expect(after).toEqual([]) }) diff --git a/app/features/events/server/allowed-roles.server.test.ts b/app/features/events/server/allowed-roles.server.test.ts index 5b328e33..b5b18fc6 100644 --- a/app/features/events/server/allowed-roles.server.test.ts +++ b/app/features/events/server/allowed-roles.server.test.ts @@ -5,10 +5,10 @@ vi.mock('~/shared/infra/db.server', () => ({ role: { findFirst: vi.fn() }, member: { findMany: vi.fn() }, memberRoleAssignment: { findMany: vi.fn() }, - programmeTemplatePartAllowedRole: { findMany: vi.fn(), createMany: vi.fn(), deleteMany: vi.fn() }, - programmePartAssignmentAllowedRole: { findMany: vi.fn(), createMany: vi.fn(), deleteMany: vi.fn() }, - programmeTemplateServiceRoleAllowedRole: { findMany: vi.fn(), createMany: vi.fn(), deleteMany: vi.fn() }, - programmeServiceRoleAssignmentAllowedRole: { findMany: vi.fn(), createMany: vi.fn(), deleteMany: vi.fn() }, + templatePartAllowedRole: { findMany: vi.fn(), createMany: vi.fn(), deleteMany: vi.fn() }, + eventPartAllowedRole: { findMany: vi.fn(), createMany: vi.fn(), deleteMany: vi.fn() }, + templateServicePartAllowedRole: { findMany: vi.fn(), createMany: vi.fn(), deleteMany: vi.fn() }, + eventServicePartAllowedRole: { findMany: vi.fn(), createMany: vi.fn(), deleteMany: vi.fn() }, }, })) @@ -16,8 +16,8 @@ const { resolveEligibleUserIds, setTemplatePartAllowedRoles, setPartAssignmentAllowedRoles, - setTemplateServiceRoleAllowedRoles, - setServiceRoleAssignmentAllowedRoles, + setTemplateServicePartAllowedRoles, + setServicePartAssignmentAllowedRoles, } = await import('./allowed-roles.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') @@ -73,28 +73,28 @@ describe('resolveEligibleUserIds', () => { describe('setTemplatePartAllowedRoles', () => { it('does nothing when desired matches previous', async () => { - vi.mocked(db.programmeTemplatePartAllowedRole.findMany).mockResolvedValue([{ roleId: 1 }, { roleId: 2 }] as never) + vi.mocked(db.templatePartAllowedRole.findMany).mockResolvedValue([{ roleId: 1 }, { roleId: 2 }] as never) const result = await setTemplatePartAllowedRoles(db, 100, 'speaker', [1, 2], 1) expect(result).toEqual({ added: [], removed: [] }) - expect(db.programmeTemplatePartAllowedRole.createMany).not.toHaveBeenCalled() - expect(db.programmeTemplatePartAllowedRole.deleteMany).not.toHaveBeenCalled() + expect(db.templatePartAllowedRole.createMany).not.toHaveBeenCalled() + expect(db.templatePartAllowedRole.deleteMany).not.toHaveBeenCalled() }) it('adds and removes the diff', async () => { - vi.mocked(db.programmeTemplatePartAllowedRole.findMany).mockResolvedValue([{ roleId: 1 }, { roleId: 2 }] as never) - vi.mocked(db.programmeTemplatePartAllowedRole.deleteMany).mockResolvedValue({ count: 1 } as never) - vi.mocked(db.programmeTemplatePartAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) + vi.mocked(db.templatePartAllowedRole.findMany).mockResolvedValue([{ roleId: 1 }, { roleId: 2 }] as never) + vi.mocked(db.templatePartAllowedRole.deleteMany).mockResolvedValue({ count: 1 } as never) + vi.mocked(db.templatePartAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) const result = await setTemplatePartAllowedRoles(db, 100, 'reader', [2, 3], 1) expect(result.added).toEqual([3]) expect(result.removed).toEqual([1]) - expect(db.programmeTemplatePartAllowedRole.deleteMany).toHaveBeenCalledWith({ + expect(db.templatePartAllowedRole.deleteMany).toHaveBeenCalledWith({ where: { partId: 100, asKind: 'reader', congregationId: 1, roleId: { in: [1] } }, }) - expect(db.programmeTemplatePartAllowedRole.createMany).toHaveBeenCalledWith({ + expect(db.templatePartAllowedRole.createMany).toHaveBeenCalledWith({ data: [{ partId: 100, roleId: 3, asKind: 'reader', congregationId: 1 }], skipDuplicates: true, }) @@ -103,44 +103,44 @@ describe('setTemplatePartAllowedRoles', () => { describe('setPartAssignmentAllowedRoles', () => { it('writes to the assignment table with asKind', async () => { - vi.mocked(db.programmePartAssignmentAllowedRole.findMany).mockResolvedValue([] as never) - vi.mocked(db.programmePartAssignmentAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) + vi.mocked(db.eventPartAllowedRole.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventPartAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) await setPartAssignmentAllowedRoles(db, 200, 'speaker', [9], 1) - expect(db.programmePartAssignmentAllowedRole.createMany).toHaveBeenCalledWith({ - data: [{ assignmentId: 200, roleId: 9, asKind: 'speaker', congregationId: 1 }], + expect(db.eventPartAllowedRole.createMany).toHaveBeenCalledWith({ + data: [{ eventPartId: 200, roleId: 9, asKind: 'speaker', congregationId: 1 }], skipDuplicates: true, }) }) }) -describe('setTemplateServiceRoleAllowedRoles', () => { +describe('setTemplateServicePartAllowedRoles', () => { it('writes service-role rows without asKind', async () => { - vi.mocked(db.programmeTemplateServiceRoleAllowedRole.findMany).mockResolvedValue([] as never) - vi.mocked(db.programmeTemplateServiceRoleAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) + vi.mocked(db.templateServicePartAllowedRole.findMany).mockResolvedValue([] as never) + vi.mocked(db.templateServicePartAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) - await setTemplateServiceRoleAllowedRoles(db, 300, [4, 5], 1) + await setTemplateServicePartAllowedRoles(db, 300, [4, 5], 1) - expect(db.programmeTemplateServiceRoleAllowedRole.createMany).toHaveBeenCalledWith({ + expect(db.templateServicePartAllowedRole.createMany).toHaveBeenCalledWith({ data: [ - { serviceRoleId: 300, roleId: 4, congregationId: 1 }, - { serviceRoleId: 300, roleId: 5, congregationId: 1 }, + { servicePartId: 300, roleId: 4, congregationId: 1 }, + { servicePartId: 300, roleId: 5, congregationId: 1 }, ], skipDuplicates: true, }) }) }) -describe('setServiceRoleAssignmentAllowedRoles', () => { +describe('setServicePartAssignmentAllowedRoles', () => { it('writes service-role-assignment rows', async () => { - vi.mocked(db.programmeServiceRoleAssignmentAllowedRole.findMany).mockResolvedValue([] as never) - vi.mocked(db.programmeServiceRoleAssignmentAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) + vi.mocked(db.eventServicePartAllowedRole.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventServicePartAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) - await setServiceRoleAssignmentAllowedRoles(db, 400, [6], 1) + await setServicePartAssignmentAllowedRoles(db, 400, [6], 1) - expect(db.programmeServiceRoleAssignmentAllowedRole.createMany).toHaveBeenCalledWith({ - data: [{ assignmentId: 400, roleId: 6, congregationId: 1 }], + expect(db.eventServicePartAllowedRole.createMany).toHaveBeenCalledWith({ + data: [{ eventServicePartId: 400, roleId: 6, congregationId: 1 }], skipDuplicates: true, }) }) diff --git a/app/features/events/server/allowed-roles.server.ts b/app/features/events/server/allowed-roles.server.ts index 3a4c4228..c5157aa9 100644 --- a/app/features/events/server/allowed-roles.server.ts +++ b/app/features/events/server/allowed-roles.server.ts @@ -4,7 +4,7 @@ import type { TransactionClient } from '~/shared/infra/db.server' export type PartRoleKind = 'speaker' | 'reader' /** - * Resolve member ids eligible for a programme assignment with the given allowed + * Resolve member ids eligible for an event part or service role with the given allowed * roles. A role reaches a Member two ways: directly via `MemberRoleAssignment` * (identity roles like brother/elder/pioneer, auto-synced from Member flags) * or via the linked `UserAccount`'s `UserRoleAssignment` (custom/management @@ -46,7 +46,7 @@ export async function getTemplatePartAllowedRoleIds( asKind: PartRoleKind, congregationId: number, ): Promise { - const rows = await db.programmeTemplatePartAllowedRole.findMany({ + const rows = await db.templatePartAllowedRole.findMany({ where: { partId, asKind, congregationId }, select: { roleId: true }, }) @@ -55,36 +55,36 @@ export async function getTemplatePartAllowedRoleIds( export async function getPartAssignmentAllowedRoleIds( db: TransactionClient, - assignmentId: number, + eventPartId: number, asKind: PartRoleKind, congregationId: number, ): Promise { - const rows = await db.programmePartAssignmentAllowedRole.findMany({ - where: { assignmentId, asKind, congregationId }, + const rows = await db.eventPartAllowedRole.findMany({ + where: { eventPartId, asKind, congregationId }, select: { roleId: true }, }) return rows.map(r => r.roleId) } -export async function getTemplateServiceRoleAllowedRoleIds( +export async function getTemplateServicePartAllowedRoleIds( db: TransactionClient, - serviceRoleId: number, + servicePartId: number, congregationId: number, ): Promise { - const rows = await db.programmeTemplateServiceRoleAllowedRole.findMany({ - where: { serviceRoleId, congregationId }, + const rows = await db.templateServicePartAllowedRole.findMany({ + where: { servicePartId, congregationId }, select: { roleId: true }, }) return rows.map(r => r.roleId) } -export async function getServiceRoleAssignmentAllowedRoleIds( +export async function getServicePartAssignmentAllowedRoleIds( db: TransactionClient, - assignmentId: number, + eventServicePartId: number, congregationId: number, ): Promise { - const rows = await db.programmeServiceRoleAssignmentAllowedRole.findMany({ - where: { assignmentId, congregationId }, + const rows = await db.eventServicePartAllowedRole.findMany({ + where: { eventServicePartId, congregationId }, select: { roleId: true }, }) return rows.map(r => r.roleId) @@ -116,12 +116,12 @@ export async function setTemplatePartAllowedRoles( if (diff.added.length === 0 && diff.removed.length === 0) return diff if (diff.removed.length > 0) { - await db.programmeTemplatePartAllowedRole.deleteMany({ + await db.templatePartAllowedRole.deleteMany({ where: { partId, asKind, congregationId, roleId: { in: diff.removed } }, }) } if (diff.added.length > 0) { - await db.programmeTemplatePartAllowedRole.createMany({ + await db.templatePartAllowedRole.createMany({ data: diff.added.map(roleId => ({ partId, roleId, asKind, congregationId })), skipDuplicates: true, }) @@ -131,71 +131,71 @@ export async function setTemplatePartAllowedRoles( export async function setPartAssignmentAllowedRoles( db: TransactionClient, - assignmentId: number, + eventPartId: number, asKind: PartRoleKind, desiredRoleIds: number[], congregationId: number, ): Promise { - const previous = await getPartAssignmentAllowedRoleIds(db, assignmentId, asKind, congregationId) + const previous = await getPartAssignmentAllowedRoleIds(db, eventPartId, asKind, congregationId) const diff = diffRoleIds(previous, desiredRoleIds) if (diff.added.length === 0 && diff.removed.length === 0) return diff if (diff.removed.length > 0) { - await db.programmePartAssignmentAllowedRole.deleteMany({ - where: { assignmentId, asKind, congregationId, roleId: { in: diff.removed } }, + await db.eventPartAllowedRole.deleteMany({ + where: { eventPartId, asKind, congregationId, roleId: { in: diff.removed } }, }) } if (diff.added.length > 0) { - await db.programmePartAssignmentAllowedRole.createMany({ - data: diff.added.map(roleId => ({ assignmentId, roleId, asKind, congregationId })), + await db.eventPartAllowedRole.createMany({ + data: diff.added.map(roleId => ({ eventPartId, roleId, asKind, congregationId })), skipDuplicates: true, }) } return diff } -export async function setTemplateServiceRoleAllowedRoles( +export async function setTemplateServicePartAllowedRoles( db: TransactionClient, - serviceRoleId: number, + servicePartId: number, desiredRoleIds: number[], congregationId: number, ): Promise { - const previous = await getTemplateServiceRoleAllowedRoleIds(db, serviceRoleId, congregationId) + const previous = await getTemplateServicePartAllowedRoleIds(db, servicePartId, congregationId) const diff = diffRoleIds(previous, desiredRoleIds) if (diff.added.length === 0 && diff.removed.length === 0) return diff if (diff.removed.length > 0) { - await db.programmeTemplateServiceRoleAllowedRole.deleteMany({ - where: { serviceRoleId, congregationId, roleId: { in: diff.removed } }, + await db.templateServicePartAllowedRole.deleteMany({ + where: { servicePartId, congregationId, roleId: { in: diff.removed } }, }) } if (diff.added.length > 0) { - await db.programmeTemplateServiceRoleAllowedRole.createMany({ - data: diff.added.map(roleId => ({ serviceRoleId, roleId, congregationId })), + await db.templateServicePartAllowedRole.createMany({ + data: diff.added.map(roleId => ({ servicePartId, roleId, congregationId })), skipDuplicates: true, }) } return diff } -export async function setServiceRoleAssignmentAllowedRoles( +export async function setServicePartAssignmentAllowedRoles( db: TransactionClient, - assignmentId: number, + eventServicePartId: number, desiredRoleIds: number[], congregationId: number, ): Promise { - const previous = await getServiceRoleAssignmentAllowedRoleIds(db, assignmentId, congregationId) + const previous = await getServicePartAssignmentAllowedRoleIds(db, eventServicePartId, congregationId) const diff = diffRoleIds(previous, desiredRoleIds) if (diff.added.length === 0 && diff.removed.length === 0) return diff if (diff.removed.length > 0) { - await db.programmeServiceRoleAssignmentAllowedRole.deleteMany({ - where: { assignmentId, congregationId, roleId: { in: diff.removed } }, + await db.eventServicePartAllowedRole.deleteMany({ + where: { eventServicePartId, congregationId, roleId: { in: diff.removed } }, }) } if (diff.added.length > 0) { - await db.programmeServiceRoleAssignmentAllowedRole.createMany({ - data: diff.added.map(roleId => ({ assignmentId, roleId, congregationId })), + await db.eventServicePartAllowedRole.createMany({ + data: diff.added.map(roleId => ({ eventServicePartId, roleId, congregationId })), skipDuplicates: true, }) } diff --git a/app/features/events/server/days-off.server.test.ts b/app/features/events/server/days-off.server.test.ts index 4b8df05c..0fa7b29b 100644 --- a/app/features/events/server/days-off.server.test.ts +++ b/app/features/events/server/days-off.server.test.ts @@ -3,9 +3,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { event: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), delete: vi.fn() }, - programmeTemplate: { findFirst: vi.fn() }, - programmePartAssignment: { updateMany: vi.fn() }, - programmeServiceRoleAssignment: { updateMany: vi.fn() }, + eventTemplate: { findFirst: vi.fn() }, + eventPart: { updateMany: vi.fn() }, + eventServicePart: { updateMany: vi.fn() }, }, })) @@ -41,7 +41,7 @@ describe('createDayOff', () => { it('creates an event when dates are valid', async () => { const fakeEvent = { id: 1, name: 'Absence' } - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ id: 5, key: 'day-off' } as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 5, key: 'day-off' } as never) vi.mocked(db.event.create).mockResolvedValue(fakeEvent as never) const result = await createDayOff(db, 1, 1, new Date(2025, 3, 8), new Date(2025, 3, 10), 1) @@ -52,7 +52,7 @@ describe('createDayOff', () => { // live immediately, otherwise the absence-overlap query silently ignores them // and the whole conflict-awareness pipeline breaks. it('writes status "released" on the created event', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ id: 5, key: 'day-off' } as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 5, key: 'day-off' } as never) vi.mocked(db.event.create).mockResolvedValue({ id: 1, name: 'Absence' } as never) await createDayOff(db, 1, 1, new Date(2025, 3, 8), new Date(2025, 3, 10), 1) @@ -65,7 +65,7 @@ describe('createDayOff', () => { it('creates an event when startDate == endDate', async () => { const sameDate = new Date(2025, 3, 8) const fakeEvent = { id: 2, name: 'Absence' } - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ id: 5, key: 'day-off' } as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 5, key: 'day-off' } as never) vi.mocked(db.event.create).mockResolvedValue(fakeEvent as never) const result = await createDayOff(db, 1, 1, sameDate, sameDate, 1) @@ -73,7 +73,7 @@ describe('createDayOff', () => { }) it('throws NotFoundError when the day-off system template is missing', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(null as never) await expect(createDayOff(db, 1, 1, new Date(2025, 3, 8), new Date(2025, 3, 10), 1)).rejects.toThrow( 'Day-off template', @@ -82,12 +82,12 @@ describe('createDayOff', () => { }) it('connects the event to the day-off template when it exists', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ id: 42, key: 'day-off' } as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 42, key: 'day-off' } as never) vi.mocked(db.event.create).mockResolvedValue({ id: 1 } as never) await createDayOff(db, 1, 1, new Date(2025, 3, 8), new Date(2025, 3, 10), 7) - expect(db.programmeTemplate.findFirst).toHaveBeenCalledWith({ + expect(db.eventTemplate.findFirst).toHaveBeenCalledWith({ where: { key: 'day-off', congregationId: 7 }, }) expect(db.event.create).toHaveBeenCalledWith( @@ -102,7 +102,7 @@ describe('createDayOff', () => { // can be no assignments to flag. it('skips refreshConflictFlags when memberId is null', async () => { const fakeEvent = { id: 4, name: 'Absence' } - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ id: 5, key: 'day-off' } as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 5, key: 'day-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) diff --git a/app/features/events/server/days-off.server.ts b/app/features/events/server/days-off.server.ts index c4508c73..9c7910c7 100644 --- a/app/features/events/server/days-off.server.ts +++ b/app/features/events/server/days-off.server.ts @@ -1,6 +1,6 @@ import { EventStatus } from '~/features/events/model/event-status.type' -import { ProgrammeTemplateKey } from '~/features/events/model/programme-template.type' -import { refreshConflictFlags } from '~/features/events/server/programme-assignments.server' +import { EventTemplateKey } from '~/features/events/model/event-template.type' +import { refreshConflictFlags } from '~/features/events/server/event-part-assignments.server' import * as m from '~/i18n/paraglide/messages' import { NotFoundError } from '~/shared/errors/app-error.server' import type { TransactionClient } from '~/shared/infra/db.server' @@ -11,7 +11,7 @@ export function getNextDaysOffs(db: TransactionClient, userId: number, congregat congregationId, createdBy: { id: userId }, template: { - key: ProgrammeTemplateKey.DayOff, + key: EventTemplateKey.DayOff, }, OR: [{ startDate: { lte: new Date() }, endDate: { gte: new Date() } }, { endDate: { gte: new Date() } }], }, @@ -46,8 +46,8 @@ export async function createDayOff( // with a null templateId would create a ghost row invisible to every // downstream query — including the /me/days-off delete guard. Fail loudly // so ops sees the incident instead of a silent success flash. - const dayOffTemplate = await db.programmeTemplate.findFirst({ - where: { key: ProgrammeTemplateKey.DayOff, congregationId }, + const dayOffTemplate = await db.eventTemplate.findFirst({ + where: { key: EventTemplateKey.DayOff, congregationId }, }) if (!dayOffTemplate) throw new NotFoundError('Day-off template') diff --git a/app/features/events/server/draft-conflict-flow.integration.test.ts b/app/features/events/server/draft-conflict-flow.integration.test.ts index 696d1f61..ad3ffee9 100644 --- a/app/features/events/server/draft-conflict-flow.integration.test.ts +++ b/app/features/events/server/draft-conflict-flow.integration.test.ts @@ -13,8 +13,8 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' import { createDayOff } from '~/features/events/server/days-off.server' +import { refreshConflictFlags } from '~/features/events/server/event-part-assignments.server' import { releaseEvent } from '~/features/events/server/event-status.server' -import { refreshConflictFlags } from '~/features/events/server/programme-assignments.server' const adapter = new PrismaPg({ connectionString: process.env.DB_RUNTIME_URL ?? process.env.DB_URL, @@ -50,7 +50,7 @@ async function provisionCongregation(suffix: string) { // event is left with `templateId: null`, mirroring the older-legacy case // that surfaced the bug (Prisma's inner-join filter would silently drop // null-template events from the refresh loop). - await tx.programmeTemplate.create({ + await tx.eventTemplate.create({ data: { name: 'Day off', key: 'day-off', @@ -96,7 +96,7 @@ async function provisionCongregation(suffix: string) { }, }) - const part = await tx.programmePartAssignment.create({ + const part = await tx.eventPart.create({ data: { eventId: event.id, assigneeId: bobMember.id, @@ -123,9 +123,9 @@ async function provisionCongregation(suffix: string) { // which describe block created it. afterAll(async () => { for (const congId of createdCongIds) { - await testDb.programmePartAssignment.deleteMany({ where: { congregationId: congId } }) + await testDb.eventPart.deleteMany({ where: { congregationId: congId } }) await testDb.event.deleteMany({ where: { congregationId: congId } }) - await testDb.programmeTemplate.deleteMany({ where: { congregationId: congId } }) + await testDb.eventTemplate.deleteMany({ where: { congregationId: congId } }) await testDb.userAccount.deleteMany({ where: { congregationId: congId } }) await testDb.member.deleteMany({ where: { congregationId: congId } }) await testDb.congregation.delete({ where: { id: congId } }) @@ -152,7 +152,7 @@ describe('refreshConflictFlags co-participant preservation (integration)', () => }, }) // Assign both Alice (speaker) and Bob (reader) to the same part row. - await tx.programmePartAssignment.update({ + await tx.eventPart.update({ where: { id_congregationId: { id: partAssignmentId, congregationId: congId } }, data: { assigneeId: alice.id, assistantId: bobMemberId }, }) @@ -175,7 +175,7 @@ describe('refreshConflictFlags co-participant preservation (integration)', () => // A refresh keyed on Alice must NOT clear the flag that Bob's absence // still legitimately owns. await withScope(congId, async tx => { - const template = await tx.programmeTemplate.findFirstOrThrow({ + const template = await tx.eventTemplate.findFirstOrThrow({ where: { key: 'day-off', congregationId: congId }, }) const aliceAccount = await tx.userAccount.findFirstOrThrow({ @@ -203,7 +203,7 @@ describe('refreshConflictFlags co-participant preservation (integration)', () => }) const still = await withScope(congId, tx => - tx.programmePartAssignment.findFirstOrThrow({ + tx.eventPart.findFirstOrThrow({ where: { id: partAssignmentId, congregationId: congId }, select: { hasConflict: true }, }), @@ -228,7 +228,7 @@ describe('Draft-event conflict flow (integration)', () => { ) const refreshed = await withScope(congId, tx => - tx.programmePartAssignment.findFirstOrThrow({ + tx.eventPart.findFirstOrThrow({ where: { id: partAssignmentId, congregationId: congId }, select: { hasConflict: true }, }), diff --git a/app/features/events/server/programme-export.server.test.ts b/app/features/events/server/event-export.server.test.ts similarity index 59% rename from app/features/events/server/programme-export.server.test.ts rename to app/features/events/server/event-export.server.test.ts index 4ee44c2c..4fe62e4d 100644 --- a/app/features/events/server/programme-export.server.test.ts +++ b/app/features/events/server/event-export.server.test.ts @@ -6,27 +6,27 @@ vi.mock('~/shared/infra/db.server', () => ({ }, })) -const { getEventsForExport, programmeExportInclude } = await import('./programme-export.server') +const { getEventsForExport, programmeExportInclude } = await import('./event-export.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') describe('getEventsForExport', () => { - it('includes the externalSpeaker relation on partAssignments', () => { + it('includes the externalSpeaker relation on parts', () => { vi.mocked(db.event.findMany).mockResolvedValue([] as never) getEventsForExport(db, [1], new Date('2026-01-01'), new Date('2026-01-31')) const call = vi.mocked(db.event.findMany).mock.calls[0]?.[0] - expect(call?.include?.partAssignments).toBeDefined() - const partAssignments = call?.include?.partAssignments as { include: Record } - expect(partAssignments.include.externalSpeaker).toBe(true) - expect(partAssignments.include.assignee).toBe(true) - expect(partAssignments.include.assistant).toBe(true) + expect(call?.include?.eventParts).toBeDefined() + const parts = call?.include?.eventParts as { include: Record } + expect(parts.include.externalSpeaker).toBe(true) + expect(parts.include.assignee).toBe(true) + expect(parts.include.assistant).toBe(true) }) }) describe('programmeExportInclude', () => { it('is the canonical include shape for export queries', () => { - expect(programmeExportInclude.partAssignments.include).toEqual({ + expect(programmeExportInclude.eventParts.include).toEqual({ assignee: true, assistant: true, externalSpeaker: true, diff --git a/app/features/events/server/programme-export.server.ts b/app/features/events/server/event-export.server.ts similarity index 97% rename from app/features/events/server/programme-export.server.ts rename to app/features/events/server/event-export.server.ts index bb52f4b6..3d395e65 100644 --- a/app/features/events/server/programme-export.server.ts +++ b/app/features/events/server/event-export.server.ts @@ -32,11 +32,11 @@ export function parseExportConfigs(raw: string): TemplateExportConfig[] { export const programmeExportInclude = { template: true, - partAssignments: { + eventParts: { include: { assignee: true, assistant: true, externalSpeaker: true }, orderBy: [{ order: 'asc' }, { trackOrder: { sort: 'asc', nulls: 'last' } }], }, - serviceRoleAssignments: { + eventServiceParts: { include: { assignee: true }, orderBy: { name: 'asc' }, }, diff --git a/app/features/events/server/event-filters.server.test.ts b/app/features/events/server/event-filters.server.test.ts index ff3ec638..16c37c36 100644 --- a/app/features/events/server/event-filters.server.test.ts +++ b/app/features/events/server/event-filters.server.test.ts @@ -102,10 +102,7 @@ describe('computeFilters', () => { expect(result.AND).toEqual([ { - OR: [ - { partAssignments: { some: { hasConflict: true } } }, - { serviceRoleAssignments: { some: { hasConflict: true } } }, - ], + OR: [{ eventParts: { some: { hasConflict: true } } }, { eventServiceParts: { some: { hasConflict: true } } }], }, ]) }) @@ -168,10 +165,7 @@ describe('computeFilters', () => { expect(result.createdById).toBe(42) expect(result.AND).toEqual([ { - OR: [ - { partAssignments: { some: { hasConflict: true } } }, - { serviceRoleAssignments: { some: { hasConflict: true } } }, - ], + OR: [{ eventParts: { some: { hasConflict: true } } }, { eventServiceParts: { some: { hasConflict: true } } }], }, ]) }) diff --git a/app/features/events/server/event-filters.server.ts b/app/features/events/server/event-filters.server.ts index 0f1444fd..69a66195 100644 --- a/app/features/events/server/event-filters.server.ts +++ b/app/features/events/server/event-filters.server.ts @@ -76,10 +76,7 @@ function applyHasConflictsFilter(filters: Prisma.EventWhereInput, params: URLSea AND: [ ...existingAnd, { - OR: [ - { partAssignments: { some: { hasConflict: true } } }, - { serviceRoleAssignments: { some: { hasConflict: true } } }, - ], + OR: [{ eventParts: { some: { hasConflict: true } } }, { eventServiceParts: { some: { hasConflict: true } } }], }, ], } diff --git a/app/features/events/server/programme-assignments.server.test.ts b/app/features/events/server/event-part-assignments.server.test.ts similarity index 69% rename from app/features/events/server/programme-assignments.server.test.ts rename to app/features/events/server/event-part-assignments.server.test.ts index cd055f70..0e0e3aa4 100644 --- a/app/features/events/server/programme-assignments.server.test.ts +++ b/app/features/events/server/event-part-assignments.server.test.ts @@ -1,11 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ProgrammeTemplateKey } from '~/features/events/model/programme-template.type' +import { EventTemplateKey } from '~/features/events/model/event-template.type' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { event: { findFirst: vi.fn(), findMany: vi.fn() }, - programmePartAssignment: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, - programmeServiceRoleAssignment: { + eventPart: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, + eventServicePart: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), @@ -19,12 +19,12 @@ vi.mock('~/shared/infra/db.server', () => ({ vi.mock('~/features/events/server/allowed-roles.server', () => ({ getPartAssignmentAllowedRoleIds: vi.fn().mockResolvedValue([]), - getServiceRoleAssignmentAllowedRoleIds: vi.fn().mockResolvedValue([]), + getServicePartAssignmentAllowedRoleIds: vi.fn().mockResolvedValue([]), resolveEligibleUserIds: vi.fn().mockResolvedValue([5]), })) -const { assignPart, assignServiceRole, unassignPart, unassignServiceRole, checkDayOffConflict, refreshConflictFlags } = - await import('./programme-assignments.server') +const { assignPart, assignServicePart, unassignPart, unassignServicePart, checkDayOffConflict, refreshConflictFlags } = + await import('./event-part-assignments.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') const allowedRoles = await import('~/features/events/server/allowed-roles.server') @@ -36,7 +36,7 @@ const DAY_OFF_MESSAGE_PATTERN = /absence/i beforeEach(() => { vi.resetAllMocks() vi.mocked(allowedRoles.getPartAssignmentAllowedRoleIds).mockResolvedValue([]) - vi.mocked(allowedRoles.getServiceRoleAssignmentAllowedRoleIds).mockResolvedValue([]) + vi.mocked(allowedRoles.getServicePartAssignmentAllowedRoleIds).mockResolvedValue([]) // Default eligibility list contains user 5 — the user used by most existing tests. vi.mocked(allowedRoles.resolveEligibleUserIds).mockResolvedValue([5]) }) @@ -75,25 +75,25 @@ describe('checkDayOffConflict', () => { describe('assignPart', () => { it('returns error when assignment not found', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventPart.findFirst).mockResolvedValue(null as never) const result = await assignPart(db, 999, 5, null, null, 'Topic', 1) expect(result).toHaveProperty('error') }) it('acquires a row lock on the assignment before reading it', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1 } as never) await assignPart(db, 1, 5, null, null, 'Topic', 1) // $executeRaw MUST fire before findFirst — the lock is the whole point. // Assert order via mock.invocationCallOrder. const lockOrder = vi.mocked(db.$executeRaw).mock.invocationCallOrder[0] - const findOrder = vi.mocked(db.programmePartAssignment.findFirst).mock.invocationCallOrder[0] + const findOrder = vi.mocked(db.eventPart.findFirst).mock.invocationCallOrder[0] expect(lockOrder).toBeLessThan(findOrder) }) @@ -101,23 +101,23 @@ describe('assignPart', () => { // to draft a schedule freely. The conflict surfaces as hasConflict=true on // the assignment and blocks the event's release step downstream. it('saves assignment with hasConflict=true when assignee has a day-off conflict', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) // day-off found - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) const result = await assignPart(db, 1, 5, null, null, 'Topic', 1) expect(result).toHaveProperty('assignment') - const updateCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventPart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ hasConflict: true }) }) // Symmetric — assistant absent, speaker fine → still saves with hasConflict=true. it('saves assignment with hasConflict=true when assistant has a day-off conflict', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) @@ -128,25 +128,25 @@ describe('assignPart', () => { vi.mocked(db.event.findFirst) .mockResolvedValueOnce(null as never) .mockResolvedValueOnce({ id: 99 } as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1 } as never) await assignPart(db, 1, 5, 7, null, 'Topic', 1) - const updateCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventPart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ hasConflict: true }) }) it('updates assignment with hasConflict=false when no conflict', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { 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.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) const result = await assignPart(db, 1, 5, null, null, 'Topic', 1) expect(result).toHaveProperty('assignment') - const updateCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventPart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ hasConflict: false }) }) @@ -154,7 +154,7 @@ describe('assignPart', () => { // is still being built); released events must NOT — a manager scheduling // over a known absence on a public event is silent scheduling breakage. it('BLOCKS assignPart on a RELEASED event when the speaker has a day-off conflict', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { status: 'released', startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) @@ -163,11 +163,11 @@ describe('assignPart', () => { const result = await assignPart(db, 1, 5, null, null, 'Topic', 1) expect(result).toEqual({ error: expect.stringMatching(DAY_OFF_MESSAGE_PATTERN) }) - expect(db.programmePartAssignment.update).not.toHaveBeenCalled() + expect(db.eventPart.update).not.toHaveBeenCalled() }) it('BLOCKS assignPart on a RELEASED event when the reader has a day-off conflict', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { status: 'released', startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) @@ -180,21 +180,21 @@ describe('assignPart', () => { const result = await assignPart(db, 1, 5, 7, null, 'Topic', 1) expect(result).toEqual({ error: expect.stringMatching(DAY_OFF_MESSAGE_PATTERN) }) - expect(db.programmePartAssignment.update).not.toHaveBeenCalled() + expect(db.eventPart.update).not.toHaveBeenCalled() }) it('still saves with hasConflict=true on a DRAFT event when a day-off conflict exists', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { status: 'draft', startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) const result = await assignPart(db, 1, 5, null, null, 'Topic', 1) expect(result).toHaveProperty('assignment') - const updateCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventPart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ hasConflict: true }) }) @@ -202,46 +202,46 @@ describe('assignPart', () => { // the new one to decide who to notify. Returning the previous IDs alongside // the new assignment keeps the diff logic out of the route. it('returns the previous assigneeId and assistantId on success', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, assigneeId: 8, assistantId: 9, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) const result = await assignPart(db, 1, 5, null, null, 'Topic', 1) expect(result).toMatchObject({ previousAssigneeId: 8, previousAssistantId: 9 }) }) it('returns null previous IDs when the assignment had no prior assignee or assistant', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, assigneeId: null, assistantId: null, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) const result = await assignPart(db, 1, 5, null, null, 'Topic', 1) expect(result).toMatchObject({ previousAssigneeId: null, previousAssistantId: null }) }) it('allows null assigneeId', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1, assigneeId: null } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1, assigneeId: null } as never) const result = await assignPart(db, 1, null, null, null, '', 1) expect(result).toHaveProperty('assignment') }) it('rejects when speaker is not in the eligible role set', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) @@ -250,11 +250,11 @@ describe('assignPart', () => { const result = await assignPart(db, 1, 5, null, null, 'Topic', 1) expect(result).toHaveProperty('error') - expect(db.programmePartAssignment.update).not.toHaveBeenCalled() + expect(db.eventPart.update).not.toHaveBeenCalled() }) it('rejects when reader is not in the eligible role set', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) @@ -269,7 +269,7 @@ describe('assignPart', () => { }) it('skips eligibility checks for external speakers', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) @@ -277,7 +277,7 @@ describe('assignPart', () => { id: 99, name: 'External Bob', } as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1, externalSpeakerId: 99 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1, externalSpeakerId: 99 } as never) const result = await assignPart(db, 1, null, null, 99, 'Topic', 1) @@ -289,58 +289,58 @@ describe('assignPart', () => { // of a specific part without opening the template editor. The value is // always written through — `null` clears the row's duration. it('persists durationMin on the internal-speaker update when provided', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1 } as never) await assignPart(db, 1, 5, null, null, 'Topic', 1, 12) - const updateCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventPart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ durationMin: 12 }) }) it('persists durationMin on the external-speaker update when provided', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.externalSpeaker.findFirst).mockResolvedValue({ id: 99, name: 'External Bob' } as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1 } as never) await assignPart(db, 1, null, null, 99, 'Topic', 1, 45) - const updateCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventPart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ durationMin: 45 }) }) it('clears durationMin when null is passed', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1 } as never) await assignPart(db, 1, 5, null, null, 'Topic', 1, null) - const updateCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventPart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ durationMin: null }) }) it('defaults durationMin to null when the caller omits it (clears the row)', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1 } as never) await assignPart(db, 1, 5, null, null, 'Topic', 1) - const updateCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventPart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ durationMin: null }) }) @@ -350,7 +350,7 @@ describe('assignPart', () => { // validated independently for role eligibility and day-off conflict but // never compared to the other. it('rejects when the same person is assigned as both speaker and reader', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) @@ -360,29 +360,29 @@ describe('assignPart', () => { const result = await assignPart(db, 1, 5, 5, null, 'Topic', 1) expect(result).toHaveProperty('error') - expect(db.programmePartAssignment.update).not.toHaveBeenCalled() + expect(db.eventPart.update).not.toHaveBeenCalled() }) }) -describe('assignServiceRole', () => { +describe('assignServicePart', () => { it('returns error when assignment not found', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue(null as never) - const result = await assignServiceRole(db, 999, 5, 1) + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue(null as never) + const result = await assignServicePart(db, 999, 5, 1) expect(result).toHaveProperty('error') }) it('acquires a row lock on the service-role assignment before reading it', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue({ id: 1 } as never) - await assignServiceRole(db, 1, 5, 1) + await assignServicePart(db, 1, 5, 1) const lockOrder = vi.mocked(db.$executeRaw).mock.invocationCallOrder[0] - const findOrder = vi.mocked(db.programmeServiceRoleAssignment.findFirst).mock.invocationCallOrder[0] + const findOrder = vi.mocked(db.eventServicePart.findFirst).mock.invocationCallOrder[0] expect(lockOrder).toBeLessThan(findOrder) }) @@ -390,191 +390,191 @@ describe('assignServiceRole', () => { // no longer block the save; they surface via hasConflict=true and block // release downstream. it('saves assignment with hasConflict=true when assignee has a day-off conflict', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) - const result = await assignServiceRole(db, 1, 5, 1) + const result = await assignServicePart(db, 1, 5, 1) expect(result).toHaveProperty('assignment') - const updateCall = vi.mocked(db.programmeServiceRoleAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventServicePart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ hasConflict: true }) }) it('updates assignment with hasConflict=false when no conflict', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) - const result = await assignServiceRole(db, 1, 5, 1) + const result = await assignServicePart(db, 1, 5, 1) expect(result).toHaveProperty('assignment') - const updateCall = vi.mocked(db.programmeServiceRoleAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventServicePart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ hasConflict: false }) }) // Regression pin — same rule as assignPart: released events must block. - it('BLOCKS assignServiceRole on a RELEASED event when the assignee has a day-off conflict', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + it('BLOCKS assignServicePart on a RELEASED event when the assignee has a day-off conflict', async () => { + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, event: { status: 'released', startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) - const result = await assignServiceRole(db, 1, 5, 1) + const result = await assignServicePart(db, 1, 5, 1) expect(result).toEqual({ error: expect.stringMatching(DAY_OFF_MESSAGE_PATTERN) }) - expect(db.programmeServiceRoleAssignment.update).not.toHaveBeenCalled() + expect(db.eventServicePart.update).not.toHaveBeenCalled() }) it('still saves with hasConflict=true on a DRAFT event when a day-off conflict exists', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, event: { status: 'draft', startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) - const result = await assignServiceRole(db, 1, 5, 1) + const result = await assignServicePart(db, 1, 5, 1) expect(result).toHaveProperty('assignment') - const updateCall = vi.mocked(db.programmeServiceRoleAssignment.update).mock.calls[0][0] + const updateCall = vi.mocked(db.eventServicePart.update).mock.calls[0][0] expect(updateCall?.data).toMatchObject({ hasConflict: true }) }) it('returns the previous assigneeId on success', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, assigneeId: 8, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) - const result = await assignServiceRole(db, 1, 5, 1) + const result = await assignServicePart(db, 1, 5, 1) expect(result).toMatchObject({ previousAssigneeId: 8 }) }) it('returns null previous ID when the service role had no prior assignee', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, assigneeId: null, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue({ id: 1, assigneeId: 5 } as never) - const result = await assignServiceRole(db, 1, 5, 1) + const result = await assignServicePart(db, 1, 5, 1) expect(result).toMatchObject({ previousAssigneeId: null }) }) it('rejects when assignee is not in the eligible role set', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, event: { startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, } as never) - vi.mocked(allowedRoles.getServiceRoleAssignmentAllowedRoleIds).mockResolvedValueOnce([99]) + vi.mocked(allowedRoles.getServicePartAssignmentAllowedRoleIds).mockResolvedValueOnce([99]) vi.mocked(allowedRoles.resolveEligibleUserIds).mockResolvedValueOnce([42]) - const result = await assignServiceRole(db, 1, 5, 1) + const result = await assignServicePart(db, 1, 5, 1) expect(result).toHaveProperty('error') - expect(db.programmeServiceRoleAssignment.update).not.toHaveBeenCalled() + expect(db.eventServicePart.update).not.toHaveBeenCalled() }) }) describe('unassignPart', () => { it('resets assignee to null and returns the updated assignment', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, assigneeId: 8, assistantId: 9, } as never) const updated = { id: 1, assigneeId: null, hasConflict: false } - vi.mocked(db.programmePartAssignment.update).mockResolvedValue(updated as never) + vi.mocked(db.eventPart.update).mockResolvedValue(updated as never) const result = await unassignPart(db, 1, 1) expect(result).toMatchObject({ assignment: updated }) }) it('returns the previous assigneeId and assistantId so the route can notify them', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, assigneeId: 8, assistantId: 9, } as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1 } as never) const result = await unassignPart(db, 1, 1) expect(result).toMatchObject({ previousAssigneeId: 8, previousAssistantId: 9 }) }) it('returns null previous IDs when the assignment was already empty', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ id: 1, assigneeId: null, assistantId: null, } as never) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 1 } as never) const result = await unassignPart(db, 1, 1) expect(result).toMatchObject({ previousAssigneeId: null, previousAssistantId: null }) }) it('returns null when the assignment does not exist', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventPart.findFirst).mockResolvedValue(null as never) const result = await unassignPart(db, 999, 1) expect(result).toBeNull() - expect(db.programmePartAssignment.update).not.toHaveBeenCalled() + expect(db.eventPart.update).not.toHaveBeenCalled() }) }) -describe('unassignServiceRole', () => { +describe('unassignServicePart', () => { it('resets assignee to null and returns the updated assignment', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, assigneeId: 8, } as never) const updated = { id: 1, assigneeId: null, hasConflict: false } - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue(updated as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue(updated as never) - const result = await unassignServiceRole(db, 1, 1) + const result = await unassignServicePart(db, 1, 1) expect(result).toMatchObject({ assignment: updated }) }) it('returns the previous assigneeId so the route can notify them', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, assigneeId: 8, } as never) - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue({ id: 1 } as never) - const result = await unassignServiceRole(db, 1, 1) + const result = await unassignServicePart(db, 1, 1) expect(result).toMatchObject({ previousAssigneeId: 8 }) }) it('returns null previous ID when the service role was already empty', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ id: 1, assigneeId: null, } as never) - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue({ id: 1 } as never) - const result = await unassignServiceRole(db, 1, 1) + const result = await unassignServicePart(db, 1, 1) expect(result).toMatchObject({ previousAssigneeId: null }) }) it('returns null when the assignment does not exist', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue(null as never) - const result = await unassignServiceRole(db, 999, 1) + const result = await unassignServicePart(db, 999, 1) expect(result).toBeNull() - expect(db.programmeServiceRoleAssignment.update).not.toHaveBeenCalled() + expect(db.eventServicePart.update).not.toHaveBeenCalled() }) }) @@ -595,18 +595,16 @@ describe('refreshConflictFlags', () => { 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.programmePartAssignment.findMany).mockResolvedValue([ - { id: 100, assigneeId: 5, assistantId: null }, - ] as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([{ id: 200, assigneeId: 5 }] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([{ id: 100, assigneeId: 5, assistantId: null }] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([{ id: 200, assigneeId: 5 }] as never) stubAbsencesFor([5]) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 100 } as never) - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 200 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 100 } as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue({ id: 200 } as never) await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 15), 1) - const partCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] - const serviceCall = vi.mocked(db.programmeServiceRoleAssignment.update).mock.calls[0][0] + const partCall = vi.mocked(db.eventPart.update).mock.calls[0][0] + const serviceCall = vi.mocked(db.eventServicePart.update).mock.calls[0][0] expect(partCall?.data).toEqual({ hasConflict: true }) expect(serviceCall?.data).toEqual({ hasConflict: true }) }) @@ -615,18 +613,16 @@ describe('refreshConflictFlags', () => { 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.programmePartAssignment.findMany).mockResolvedValue([ - { id: 100, assigneeId: 5, assistantId: null }, - ] as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([{ id: 200, assigneeId: 5 }] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([{ id: 100, assigneeId: 5, assistantId: null }] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([{ id: 200, assigneeId: 5 }] as never) stubAbsencesFor([]) // no absences overlap - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 100 } as never) - vi.mocked(db.programmeServiceRoleAssignment.update).mockResolvedValue({ id: 200 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 100 } as never) + vi.mocked(db.eventServicePart.update).mockResolvedValue({ id: 200 } as never) await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 15), 1) - const partCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] - const serviceCall = vi.mocked(db.programmeServiceRoleAssignment.update).mock.calls[0][0] + const partCall = vi.mocked(db.eventPart.update).mock.calls[0][0] + const serviceCall = vi.mocked(db.eventServicePart.update).mock.calls[0][0] expect(partCall?.data).toEqual({ hasConflict: false }) expect(serviceCall?.data).toEqual({ hasConflict: false }) }) @@ -642,18 +638,16 @@ describe('refreshConflictFlags', () => { { id: 1, startDate: new Date(2026, 3, 14), endDate: new Date(2026, 3, 14) }, ] as never) // Alice(5) = speaker (assigneeId), Bob(6) = reader (assistantId). - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ - { id: 100, assigneeId: 5, assistantId: 6 }, - ] as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([{ id: 100, assigneeId: 5, assistantId: 6 }] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] as never) // Alice: no absence. Bob: absence present. stubAbsencesFor([6]) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 100 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 100 } as never) // Refresh for Alice (her absence just got cleared). await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 15), 1) - const partCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + const partCall = vi.mocked(db.eventPart.update).mock.calls[0][0] expect(partCall?.data).toEqual({ hasConflict: true }) }) @@ -663,16 +657,14 @@ describe('refreshConflictFlags', () => { 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.programmePartAssignment.findMany).mockResolvedValue([ - { id: 100, assigneeId: 5, assistantId: 6 }, - ] as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([{ id: 100, assigneeId: 5, assistantId: 6 }] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] as never) stubAbsencesFor([]) // nobody has an absence - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 100 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 100 } as never) await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 15), 1) - const partCall = vi.mocked(db.programmePartAssignment.update).mock.calls[0][0] + const partCall = vi.mocked(db.eventPart.update).mock.calls[0][0] expect(partCall?.data).toEqual({ hasConflict: false }) }) @@ -681,8 +673,8 @@ describe('refreshConflictFlags', () => { await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 15), 1) - expect(db.programmePartAssignment.update).not.toHaveBeenCalled() - expect(db.programmePartAssignment.findMany).not.toHaveBeenCalled() + expect(db.eventPart.update).not.toHaveBeenCalled() + expect(db.eventPart.findMany).not.toHaveBeenCalled() }) // Every overlapping event must be reconciled independently. A regression @@ -695,18 +687,18 @@ describe('refreshConflictFlags', () => { { id: 3, startDate: new Date(2026, 3, 16), endDate: new Date(2026, 3, 16) }, ] as never) // biome-ignore lint/suspicious/noExplicitAny: mock signature needs to match Prisma's generated overloads - vi.mocked(db.programmePartAssignment.findMany).mockImplementation(((args: any) => { + vi.mocked(db.eventPart.findMany).mockImplementation(((args: any) => { const eventId = args?.where?.eventId as number return Promise.resolve([{ id: eventId * 10, assigneeId: 5, assistantId: null }]) }) as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] as never) stubAbsencesFor([5]) - vi.mocked(db.programmePartAssignment.update).mockResolvedValue({ id: 0 } as never) + vi.mocked(db.eventPart.update).mockResolvedValue({ id: 0 } as never) await refreshConflictFlags(db, 5, new Date(2026, 3, 13), new Date(2026, 3, 17), 1) - expect(vi.mocked(db.programmePartAssignment.findMany).mock.calls).toHaveLength(3) - expect(vi.mocked(db.programmePartAssignment.update).mock.calls).toHaveLength(3) + expect(vi.mocked(db.eventPart.findMany).mock.calls).toHaveLength(3) + expect(vi.mocked(db.eventPart.update).mock.calls).toHaveLength(3) }) // Regression pin — participants are Members (`assigneeId`, `assistantId` @@ -716,17 +708,17 @@ describe('refreshConflictFlags', () => { 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.programmePartAssignment.findMany).mockResolvedValue([] as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] 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.findMany).mock.calls[0][0] + const partCall = vi.mocked(db.eventPart.findMany).mock.calls[0][0] const partWhere = partCall?.where as Record expect(partWhere.OR).toEqual([{ assigneeId: memberId }, { assistantId: memberId }]) - const serviceCall = vi.mocked(db.programmeServiceRoleAssignment.findMany).mock.calls[0][0] + const serviceCall = vi.mocked(db.eventServicePart.findMany).mock.calls[0][0] const serviceWhere = serviceCall?.where as Record expect(serviceWhere.assigneeId).toBe(memberId) }) @@ -760,7 +752,7 @@ describe('refreshConflictFlags', () => { const call = vi.mocked(db.event.findMany).mock.calls[0][0] const where = call?.where as Record - expect(where.NOT).toEqual({ template: { key: ProgrammeTemplateKey.DayOff } }) + expect(where.NOT).toEqual({ template: { key: EventTemplateKey.DayOff } }) expect(where).not.toHaveProperty('template') }) }) diff --git a/app/features/events/server/programme-assignments.server.ts b/app/features/events/server/event-part-assignments.server.ts similarity index 86% rename from app/features/events/server/programme-assignments.server.ts rename to app/features/events/server/event-part-assignments.server.ts index aeb47c5e..fdf0e1d0 100644 --- a/app/features/events/server/programme-assignments.server.ts +++ b/app/features/events/server/event-part-assignments.server.ts @@ -1,8 +1,8 @@ import { EventStatus } from '~/features/events/model/event-status.type' -import { ProgrammeTemplateKey } from '~/features/events/model/programme-template.type' +import { EventTemplateKey } from '~/features/events/model/event-template.type' import { getPartAssignmentAllowedRoleIds, - getServiceRoleAssignmentAllowedRoleIds, + getServicePartAssignmentAllowedRoleIds, resolveEligibleUserIds, } from '~/features/events/server/allowed-roles.server' import { @@ -11,7 +11,7 @@ import { checkParticipantsDistinct, DAY_OFF_MESSAGE, PROGRAMME_ASSIGNMENT_ERRORS, -} from '~/features/events/server/programme-assignment.policy' +} from '~/features/events/server/event-part.policy' import type { TransactionClient } from '~/shared/infra/db.server' import { sanitizeText } from '~/shared/utils/sanitize-text' @@ -25,11 +25,11 @@ import { sanitizeText } from '~/shared/utils/sanitize-text' // SELECT on a non-existent row returns zero rows without blocking, so the // caller's `findFirst`-then-branch shape still works. async function lockPartAssignmentRow(db: TransactionClient, id: number, congregationId: number): Promise { - await db.$executeRaw`SELECT id FROM "ProgrammePartAssignment" WHERE id = ${id} AND "congregationId" = ${congregationId} FOR UPDATE` + await db.$executeRaw`SELECT id FROM "EventPart" WHERE id = ${id} AND "congregationId" = ${congregationId} FOR UPDATE` } -async function lockServiceRoleAssignmentRow(db: TransactionClient, id: number, congregationId: number): Promise { - await db.$executeRaw`SELECT id FROM "ProgrammeServiceRoleAssignment" WHERE id = ${id} AND "congregationId" = ${congregationId} FOR UPDATE` +async function lockServicePartAssignmentRow(db: TransactionClient, id: number, congregationId: number): Promise { + await db.$executeRaw`SELECT id FROM "EventServicePart" WHERE id = ${id} AND "congregationId" = ${congregationId} FOR UPDATE` } export function getEventProgramme(db: TransactionClient, eventId: number, congregationId: number) { @@ -37,7 +37,7 @@ export function getEventProgramme(db: TransactionClient, eventId: number, congre where: { id: eventId, congregationId }, include: { template: true, - partAssignments: { + eventParts: { include: { assignee: true, assistant: true, @@ -45,7 +45,7 @@ export function getEventProgramme(db: TransactionClient, eventId: number, congre }, orderBy: [{ order: 'asc' }, { trackOrder: { sort: 'asc', nulls: 'last' } }], }, - serviceRoleAssignments: { + eventServiceParts: { include: { assignee: true, }, @@ -96,7 +96,7 @@ export async function assignPart( durationMin: number | null = null, ) { await lockPartAssignmentRow(db, assignmentId, congregationId) - const existing = await db.programmePartAssignment.findFirst({ + const existing = await db.eventPart.findFirst({ where: { id: assignmentId, congregationId }, include: { event: true }, }) @@ -118,7 +118,7 @@ export async function assignPart( const invalidSpeaker = checkExternalSpeakerValid(speaker) if (invalidSpeaker) return invalidSpeaker - const assignment = await db.programmePartAssignment.update({ + const assignment = await db.eventPart.update({ where: { id_congregationId: { id: assignmentId, congregationId } }, data: { assigneeId: null, @@ -164,7 +164,7 @@ export async function assignPart( const hasConflict = speakerCheck.hasConflict || readerCheck.hasConflict - const assignment = await db.programmePartAssignment.update({ + const assignment = await db.eventPart.update({ where: { id_congregationId: { id: assignmentId, congregationId } }, data: { assigneeId, assistantId, externalSpeakerId: null, topic: cleanTopic, hasConflict, durationMin }, }) @@ -172,14 +172,14 @@ export async function assignPart( return { assignment, previousAssigneeId, previousAssistantId } } -export async function assignServiceRole( +export async function assignServicePart( db: TransactionClient, assignmentId: number, assigneeId: number | null, congregationId: number, ) { - await lockServiceRoleAssignmentRow(db, assignmentId, congregationId) - const existing = await db.programmeServiceRoleAssignment.findFirst({ + await lockServicePartAssignmentRow(db, assignmentId, congregationId) + const existing = await db.eventServicePart.findFirst({ where: { id: assignmentId, congregationId }, include: { event: true }, }) @@ -191,7 +191,7 @@ export async function assignServiceRole( let hasConflict = false if (assigneeId != null) { - const allowed = await getServiceRoleAssignmentAllowedRoleIds(db, assignmentId, congregationId) + const allowed = await getServicePartAssignmentAllowedRoleIds(db, assignmentId, congregationId) const eligible = await resolveEligibleUserIds(db, allowed, congregationId) const ineligible = checkEligibleForRole(eligible, assigneeId, 'servant') if (ineligible) return ineligible @@ -201,7 +201,7 @@ export async function assignServiceRole( } } - const assignment = await db.programmeServiceRoleAssignment.update({ + const assignment = await db.eventServicePart.update({ where: { id_congregationId: { id: assignmentId, congregationId } }, data: { assigneeId, hasConflict }, }) @@ -211,13 +211,13 @@ export async function assignServiceRole( export async function unassignPart(db: TransactionClient, assignmentId: number, congregationId: number) { await lockPartAssignmentRow(db, assignmentId, congregationId) - const existing = await db.programmePartAssignment.findFirst({ + const existing = await db.eventPart.findFirst({ where: { id: assignmentId, congregationId }, select: { assigneeId: true, assistantId: true }, }) if (!existing) return null - const assignment = await db.programmePartAssignment.update({ + const assignment = await db.eventPart.update({ where: { id_congregationId: { id: assignmentId, congregationId }, }, @@ -227,15 +227,15 @@ export async function unassignPart(db: TransactionClient, assignmentId: number, return { assignment, previousAssigneeId: existing.assigneeId, previousAssistantId: existing.assistantId } } -export async function unassignServiceRole(db: TransactionClient, assignmentId: number, congregationId: number) { - await lockServiceRoleAssignmentRow(db, assignmentId, congregationId) - const existing = await db.programmeServiceRoleAssignment.findFirst({ +export async function unassignServicePart(db: TransactionClient, assignmentId: number, congregationId: number) { + await lockServicePartAssignmentRow(db, assignmentId, congregationId) + const existing = await db.eventServicePart.findFirst({ where: { id: assignmentId, congregationId }, select: { assigneeId: true }, }) if (!existing) return null - const assignment = await db.programmeServiceRoleAssignment.update({ + const assignment = await db.eventServicePart.update({ where: { id_congregationId: { id: assignmentId, congregationId }, }, @@ -260,7 +260,7 @@ export async function checkDayOffConflict( where: { congregationId, createdBy: { memberId }, - template: { key: ProgrammeTemplateKey.DayOff }, + template: { key: EventTemplateKey.DayOff }, startDate: { lte: endDate }, endDate: { gte: startDate }, }, @@ -288,7 +288,7 @@ export async function refreshConflictFlags( const overlappingEvents = await db.event.findMany({ where: { congregationId, - NOT: { template: { key: ProgrammeTemplateKey.DayOff } }, + NOT: { template: { key: EventTemplateKey.DayOff } }, startDate: { lte: endDate }, endDate: { gte: startDate }, }, @@ -302,7 +302,7 @@ export async function refreshConflictFlags( // co-participant still owns — e.g. Alice removes her absence but Bob is // still absent on the same part. Recompute per row as // (assigneeConflict OR assistantConflict). - const parts = await db.programmePartAssignment.findMany({ + const parts = await db.eventPart.findMany({ where: { eventId: event.id, congregationId, @@ -319,7 +319,7 @@ export async function refreshConflictFlags( part.assistantId != null && (await checkDayOffConflict(db, part.assistantId, event.startDate, event.endDate, congregationId)) - await db.programmePartAssignment.update({ + await db.eventPart.update({ where: { id_congregationId: { id: part.id, congregationId } }, data: { hasConflict: assigneeConflict || assistantConflict }, }) @@ -327,7 +327,7 @@ export async function refreshConflictFlags( // Service-role rows have a single assignee, so a plain per-row recompute // is enough — no clobber scenario. - const services = await db.programmeServiceRoleAssignment.findMany({ + const services = await db.eventServicePart.findMany({ where: { eventId: event.id, assigneeId: memberId, congregationId }, select: { id: true, assigneeId: true }, }) @@ -336,7 +336,7 @@ export async function refreshConflictFlags( const hasConflict = service.assigneeId != null && (await checkDayOffConflict(db, service.assigneeId, event.startDate, event.endDate, congregationId)) - await db.programmeServiceRoleAssignment.update({ + await db.eventServicePart.update({ where: { id_congregationId: { id: service.id, congregationId } }, data: { hasConflict }, }) diff --git a/app/features/events/server/programme-assignment.policy.test.ts b/app/features/events/server/event-part.policy.test.ts similarity index 98% rename from app/features/events/server/programme-assignment.policy.test.ts rename to app/features/events/server/event-part.policy.test.ts index 74a61a00..c5e1caf9 100644 --- a/app/features/events/server/programme-assignment.policy.test.ts +++ b/app/features/events/server/event-part.policy.test.ts @@ -7,7 +7,7 @@ import { checkExternalSpeakerValid, checkParticipantsDistinct, PROGRAMME_ASSIGNMENT_ERRORS, -} from './programme-assignment.policy' +} from './event-part.policy' describe('areParticipantsDistinct', () => { it('returns true when both null', () => { diff --git a/app/features/events/server/programme-assignment.policy.ts b/app/features/events/server/event-part.policy.ts similarity index 97% rename from app/features/events/server/programme-assignment.policy.ts rename to app/features/events/server/event-part.policy.ts index 9a9139cc..17ba5745 100644 --- a/app/features/events/server/programme-assignment.policy.ts +++ b/app/features/events/server/event-part.policy.ts @@ -6,11 +6,11 @@ import { ConflictError } from '~/shared/errors/app-error.server' // • assert* — throws AppError subclasses. Kept for callers that prefer // throw semantics (Wave 1 pattern). // • check* — returns `{ error } | null`. Used by writers in -// programme-assignments.server.ts that surface errors via +// event-part-assignments.server.ts that surface errors via // the existing `{ error } | { assignment }` return type. // // Every user-visible error string flows through PROGRAMME_ASSIGNMENT_ERRORS -// so message drift between the two writers (assignPart / assignServiceRole) +// so message drift between the two writers (assignPart / assignServicePart) // is impossible. export const PROGRAMME_ASSIGNMENT_ERRORS = { diff --git a/app/features/events/server/programme-events.server.test.ts b/app/features/events/server/event-parts.server.test.ts similarity index 77% rename from app/features/events/server/programme-events.server.test.ts rename to app/features/events/server/event-parts.server.test.ts index 6e81ec0d..8c989765 100644 --- a/app/features/events/server/programme-events.server.test.ts +++ b/app/features/events/server/event-parts.server.test.ts @@ -6,7 +6,7 @@ vi.mock('~/shared/infra/logger.server', () => ({ vi.mock('~/features/events/server/allowed-roles.server', () => ({ setPartAssignmentAllowedRoles: vi.fn().mockResolvedValue({ added: [], removed: [] }), - setServiceRoleAssignmentAllowedRoles: vi.fn().mockResolvedValue({ added: [], removed: [] }), + setServicePartAssignmentAllowedRoles: vi.fn().mockResolvedValue({ added: [], removed: [] }), })) vi.mock('~/shared/domain/audit.server', () => ({ @@ -14,7 +14,7 @@ vi.mock('~/shared/domain/audit.server', () => ({ auditInTransaction: vi.fn(), AuditAction: { PartAllowedRolesChanged: 'part.allowed_roles.changed', - ServiceRoleAllowedRolesChanged: 'service_role.allowed_roles.changed', + ServicePartAllowedRolesChanged: 'service_role.allowed_roles.changed', EventDeleted: 'event.deleted', EventUpdated: 'event.updated', }, @@ -27,11 +27,11 @@ const { addPartAssignment, updatePartAssignment, deletePartAssignment, - addServiceRoleAssignment, - deleteServiceRoleAssignment, + addServicePartAssignment, + deleteServicePartAssignment, applyTemplateToEvent, bulkDeleteEvents, -} = await import('./programme-events.server') +} = await import('./event-parts.server') const mockDb = { event: { @@ -40,22 +40,22 @@ const mockDb = { deleteMany: vi.fn(), update: vi.fn(), }, - programmePartAssignment: { + eventPart: { create: vi.fn(), delete: vi.fn(), update: vi.fn(), }, - programmeServiceRoleAssignment: { + eventServicePart: { create: vi.fn(), delete: vi.fn(), }, - programmePartAssignmentAllowedRole: { + eventPartAllowedRole: { createMany: vi.fn(), }, - programmeServiceRoleAssignmentAllowedRole: { + eventServicePartAllowedRole: { createMany: vi.fn(), }, - programmeTemplate: { + eventTemplate: { findFirst: vi.fn(), }, } @@ -66,7 +66,7 @@ const auditModule = await import('~/shared/domain/audit.server') beforeEach(() => { vi.resetAllMocks() vi.mocked(allowedRoles.setPartAssignmentAllowedRoles).mockResolvedValue({ added: [], removed: [] }) - vi.mocked(allowedRoles.setServiceRoleAssignmentAllowedRoles).mockResolvedValue({ added: [], removed: [] }) + vi.mocked(allowedRoles.setServicePartAssignmentAllowedRoles).mockResolvedValue({ added: [], removed: [] }) }) describe('createFreeformEvent', () => { @@ -78,14 +78,14 @@ describe('createFreeformEvent', () => { createdById: 1, congregationId: 10, } - mockDb.programmeTemplate.findFirst.mockResolvedValue({ id: 99, key: 'freeform' }) + mockDb.eventTemplate.findFirst.mockResolvedValue({ id: 99, key: 'freeform' }) const expected = { id: 1, ...data } mockDb.event.create.mockResolvedValue(expected) const result = await createFreeformEvent(mockDb as never, data) expect(result).toEqual(expected) - expect(mockDb.programmeTemplate.findFirst).toHaveBeenCalledWith({ + expect(mockDb.eventTemplate.findFirst).toHaveBeenCalledWith({ where: { key: 'freeform', congregationId: 10 }, }) expect(mockDb.event.create).toHaveBeenCalledWith({ @@ -107,7 +107,7 @@ describe('createFreeformEvent', () => { createdById: 1, congregationId: 10, } - mockDb.programmeTemplate.findFirst.mockResolvedValue(null) + mockDb.eventTemplate.findFirst.mockResolvedValue(null) await expect(createFreeformEvent(mockDb as never, data)).rejects.toThrow('Freeform template') expect(mockDb.event.create).not.toHaveBeenCalled() @@ -253,16 +253,16 @@ describe('addPartAssignment', () => { } const { allowedSpeakerRoleIds: _s, allowedReaderRoleIds: _r, ...createData } = data const expected = { id: 1, ...createData } - mockDb.programmePartAssignment.create.mockResolvedValue(expected) + mockDb.eventPart.create.mockResolvedValue(expected) const result = await addPartAssignment(mockDb as never, data, 99) expect(result).toEqual(expected) - expect(mockDb.programmePartAssignment.create).toHaveBeenCalledWith({ data: createData }) + expect(mockDb.eventPart.create).toHaveBeenCalledWith({ data: createData }) }) it('passes speakerLabel and readerLabel to create when supplied (Layer 5)', async () => { - mockDb.programmePartAssignment.create.mockResolvedValue({ id: 1 }) + mockDb.eventPart.create.mockResolvedValue({ id: 1 }) await addPartAssignment( mockDb as never, @@ -283,7 +283,7 @@ describe('addPartAssignment', () => { 99, ) - expect(mockDb.programmePartAssignment.create).toHaveBeenCalledWith({ + expect(mockDb.eventPart.create).toHaveBeenCalledWith({ data: expect.objectContaining({ speakerLabel: 'STUDENT-SENTINEL', readerLabel: 'HOUSEHOLDER-SENTINEL' }), }) }) @@ -291,12 +291,12 @@ describe('addPartAssignment', () => { describe('deletePartAssignment', () => { it('deletes a part assignment using compound key', async () => { - mockDb.programmePartAssignment.delete.mockResolvedValue({ id: 5 }) + mockDb.eventPart.delete.mockResolvedValue({ id: 5 }) const result = await deletePartAssignment(mockDb as never, 5, 10) expect(result).toEqual({ id: 5 }) - expect(mockDb.programmePartAssignment.delete).toHaveBeenCalledWith({ + expect(mockDb.eventPart.delete).toHaveBeenCalledWith({ where: { id_congregationId: { id: 5, congregationId: 10 } }, }) }) @@ -304,7 +304,7 @@ describe('deletePartAssignment', () => { describe('updatePartAssignment', () => { it('passes speakerLabel and readerLabel to the update when supplied (Layer 5)', async () => { - mockDb.programmePartAssignment.update.mockResolvedValue({ id: 5 }) + mockDb.eventPart.update.mockResolvedValue({ id: 5 }) await updatePartAssignment( mockDb as never, @@ -325,35 +325,35 @@ describe('updatePartAssignment', () => { 99, ) - expect(mockDb.programmePartAssignment.update).toHaveBeenCalledWith({ + expect(mockDb.eventPart.update).toHaveBeenCalledWith({ where: { id_congregationId: { id: 5, congregationId: 10 } }, data: expect.objectContaining({ speakerLabel: 'STUDENT-SENTINEL', readerLabel: 'HOUSEHOLDER-SENTINEL' }), }) }) }) -describe('addServiceRoleAssignment', () => { +describe('addServicePartAssignment', () => { it('creates a service role assignment', async () => { const data = { eventId: 1, name: 'Son', allowedRoleIds: [], congregationId: 10 } const { allowedRoleIds: _a, ...createData } = data const expected = { id: 1, ...createData } - mockDb.programmeServiceRoleAssignment.create.mockResolvedValue(expected) + mockDb.eventServicePart.create.mockResolvedValue(expected) - const result = await addServiceRoleAssignment(mockDb as never, data, 99) + const result = await addServicePartAssignment(mockDb as never, data, 99) expect(result).toEqual(expected) - expect(mockDb.programmeServiceRoleAssignment.create).toHaveBeenCalledWith({ data: createData }) + expect(mockDb.eventServicePart.create).toHaveBeenCalledWith({ data: createData }) }) }) -describe('deleteServiceRoleAssignment', () => { +describe('deleteServicePartAssignment', () => { it('deletes a service role assignment using compound key', async () => { - mockDb.programmeServiceRoleAssignment.delete.mockResolvedValue({ id: 8 }) + mockDb.eventServicePart.delete.mockResolvedValue({ id: 8 }) - const result = await deleteServiceRoleAssignment(mockDb as never, 8, 10) + const result = await deleteServicePartAssignment(mockDb as never, 8, 10) expect(result).toEqual({ id: 8 }) - expect(mockDb.programmeServiceRoleAssignment.delete).toHaveBeenCalledWith({ + expect(mockDb.eventServicePart.delete).toHaveBeenCalledWith({ where: { id_congregationId: { id: 8, congregationId: 10 } }, }) }) @@ -361,7 +361,7 @@ describe('deleteServiceRoleAssignment', () => { describe('applyTemplateToEvent', () => { it('returns null when template is not found', async () => { - mockDb.programmeTemplate.findFirst.mockResolvedValue(null) + mockDb.eventTemplate.findFirst.mockResolvedValue(null) const result = await applyTemplateToEvent(mockDb as never, 1, 99, 10, 1) @@ -397,12 +397,12 @@ describe('applyTemplateToEvent', () => { allowedRoles: [], }, ], - serviceRoles: [{ id: 20, name: 'Son', allowedRoles: [] }], + serviceParts: [{ id: 20, name: 'Son', allowedRoles: [] }], } - mockDb.programmeTemplate.findFirst.mockResolvedValue(template) + mockDb.eventTemplate.findFirst.mockResolvedValue(template) mockDb.event.update.mockResolvedValue({}) - mockDb.programmePartAssignment.create.mockResolvedValue({ id: 999 }) - mockDb.programmeServiceRoleAssignment.create.mockResolvedValue({ id: 998 }) + mockDb.eventPart.create.mockResolvedValue({ id: 999 }) + mockDb.eventServicePart.create.mockResolvedValue({ id: 998 }) const result = await applyTemplateToEvent(mockDb as never, 1, 5, 10, 42) @@ -411,8 +411,8 @@ describe('applyTemplateToEvent', () => { where: { id_congregationId: { id: 1, congregationId: 10 } }, data: { templateId: 5 }, }) - expect(mockDb.programmePartAssignment.create).toHaveBeenCalledTimes(2) - expect(mockDb.programmePartAssignment.create).toHaveBeenCalledWith({ + expect(mockDb.eventPart.create).toHaveBeenCalledTimes(2) + expect(mockDb.eventPart.create).toHaveBeenCalledWith({ data: { eventId: 1, partId: 10, @@ -426,8 +426,8 @@ describe('applyTemplateToEvent', () => { congregationId: 10, }, }) - expect(mockDb.programmeServiceRoleAssignment.create).toHaveBeenCalledWith({ - data: { eventId: 1, serviceRoleId: 20, name: 'Son', congregationId: 10 }, + expect(mockDb.eventServicePart.create).toHaveBeenCalledWith({ + data: { eventId: 1, servicePartId: 20, name: 'Son', congregationId: 10 }, }) }) @@ -465,15 +465,15 @@ describe('applyTemplateToEvent', () => { allowedRoles: [], }, ], - serviceRoles: [], + serviceParts: [], } - mockDb.programmeTemplate.findFirst.mockResolvedValue(template) + mockDb.eventTemplate.findFirst.mockResolvedValue(template) mockDb.event.update.mockResolvedValue({}) - mockDb.programmePartAssignment.create.mockResolvedValue({ id: 999 }) + mockDb.eventPart.create.mockResolvedValue({ id: 999 }) await applyTemplateToEvent(mockDb as never, 1, 5, 10, 42) - const calls = mockDb.programmePartAssignment.create.mock.calls + const calls = mockDb.eventPart.create.mock.calls expect(calls.length).toBe(2) expect(calls[0][0].data).toMatchObject({ speakerLabel: 'STUDENT-SENTINEL-P1', readerLabel: null }) expect(calls[1][0].data).toMatchObject({ @@ -503,29 +503,29 @@ describe('applyTemplateToEvent', () => { ], }, ], - serviceRoles: [{ id: 20, name: 'Son', allowedRoles: [{ roleId: 300 }, { roleId: 301 }] }], + serviceParts: [{ id: 20, name: 'Son', allowedRoles: [{ roleId: 300 }, { roleId: 301 }] }], } - mockDb.programmeTemplate.findFirst.mockResolvedValue(template) + mockDb.eventTemplate.findFirst.mockResolvedValue(template) mockDb.event.update.mockResolvedValue({}) - mockDb.programmePartAssignment.create.mockResolvedValue({ id: 555 }) - mockDb.programmeServiceRoleAssignment.create.mockResolvedValue({ id: 666 }) - mockDb.programmePartAssignmentAllowedRole.createMany.mockResolvedValue({ count: 3 }) - mockDb.programmeServiceRoleAssignmentAllowedRole.createMany.mockResolvedValue({ count: 2 }) + mockDb.eventPart.create.mockResolvedValue({ id: 555 }) + mockDb.eventServicePart.create.mockResolvedValue({ id: 666 }) + mockDb.eventPartAllowedRole.createMany.mockResolvedValue({ count: 3 }) + mockDb.eventServicePartAllowedRole.createMany.mockResolvedValue({ count: 2 }) await applyTemplateToEvent(mockDb as never, 1, 5, 10, 42) - expect(mockDb.programmePartAssignmentAllowedRole.createMany).toHaveBeenCalledWith({ + expect(mockDb.eventPartAllowedRole.createMany).toHaveBeenCalledWith({ data: [ - { assignmentId: 555, roleId: 100, asKind: 'speaker', congregationId: 10 }, - { assignmentId: 555, roleId: 101, asKind: 'speaker', congregationId: 10 }, - { assignmentId: 555, roleId: 200, asKind: 'reader', congregationId: 10 }, + { eventPartId: 555, roleId: 100, asKind: 'speaker', congregationId: 10 }, + { eventPartId: 555, roleId: 101, asKind: 'speaker', congregationId: 10 }, + { eventPartId: 555, roleId: 200, asKind: 'reader', congregationId: 10 }, ], skipDuplicates: true, }) - expect(mockDb.programmeServiceRoleAssignmentAllowedRole.createMany).toHaveBeenCalledWith({ + expect(mockDb.eventServicePartAllowedRole.createMany).toHaveBeenCalledWith({ data: [ - { assignmentId: 666, roleId: 300, congregationId: 10 }, - { assignmentId: 666, roleId: 301, congregationId: 10 }, + { eventServicePartId: 666, roleId: 300, congregationId: 10 }, + { eventServicePartId: 666, roleId: 301, congregationId: 10 }, ], skipDuplicates: true, }) @@ -548,23 +548,23 @@ describe('applyTemplateToEvent', () => { allowedRoles: [], }, ], - serviceRoles: [{ id: 20, name: 'Son', allowedRoles: [] }], + serviceParts: [{ id: 20, name: 'Son', allowedRoles: [] }], } - mockDb.programmeTemplate.findFirst.mockResolvedValue(template) + mockDb.eventTemplate.findFirst.mockResolvedValue(template) mockDb.event.update.mockResolvedValue({}) - mockDb.programmePartAssignment.create.mockResolvedValue({ id: 555 }) - mockDb.programmeServiceRoleAssignment.create.mockResolvedValue({ id: 666 }) + mockDb.eventPart.create.mockResolvedValue({ id: 555 }) + mockDb.eventServicePart.create.mockResolvedValue({ id: 666 }) await applyTemplateToEvent(mockDb as never, 1, 5, 10, 42) - expect(mockDb.programmePartAssignmentAllowedRole.createMany).not.toHaveBeenCalled() - expect(mockDb.programmeServiceRoleAssignmentAllowedRole.createMany).not.toHaveBeenCalled() + expect(mockDb.eventPartAllowedRole.createMany).not.toHaveBeenCalled() + expect(mockDb.eventServicePartAllowedRole.createMany).not.toHaveBeenCalled() }) }) describe('addPartAssignment audit firing', () => { it('fires PartAllowedRolesChanged when role lists change', async () => { - mockDb.programmePartAssignment.create.mockResolvedValue({ id: 100 }) + mockDb.eventPart.create.mockResolvedValue({ id: 100 }) vi.mocked(allowedRoles.setPartAssignmentAllowedRoles).mockResolvedValueOnce({ added: [5], removed: [] }) vi.mocked(allowedRoles.setPartAssignmentAllowedRoles).mockResolvedValueOnce({ added: [], removed: [] }) @@ -588,7 +588,7 @@ describe('addPartAssignment audit firing', () => { expect(vi.mocked(auditModule.audit)).toHaveBeenCalledWith( expect.objectContaining({ action: 'part.allowed_roles.changed', - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: 100, actorId: 42, }), @@ -596,7 +596,7 @@ describe('addPartAssignment audit firing', () => { }) it('does not fire audit when role lists do not change', async () => { - mockDb.programmePartAssignment.create.mockResolvedValue({ id: 100 }) + mockDb.eventPart.create.mockResolvedValue({ id: 100 }) vi.mocked(allowedRoles.setPartAssignmentAllowedRoles).mockResolvedValue({ added: [], removed: [] }) await addPartAssignment( @@ -620,12 +620,12 @@ describe('addPartAssignment audit firing', () => { }) }) -describe('addServiceRoleAssignment audit firing', () => { - it('fires ServiceRoleAllowedRolesChanged when role list changes', async () => { - mockDb.programmeServiceRoleAssignment.create.mockResolvedValue({ id: 200 }) - vi.mocked(allowedRoles.setServiceRoleAssignmentAllowedRoles).mockResolvedValueOnce({ added: [7], removed: [] }) +describe('addServicePartAssignment audit firing', () => { + it('fires ServicePartAllowedRolesChanged when role list changes', async () => { + mockDb.eventServicePart.create.mockResolvedValue({ id: 200 }) + vi.mocked(allowedRoles.setServicePartAssignmentAllowedRoles).mockResolvedValueOnce({ added: [7], removed: [] }) - await addServiceRoleAssignment( + await addServicePartAssignment( mockDb as never, { eventId: 1, name: 'Son', allowedRoleIds: [7], congregationId: 10 }, 42, @@ -634,7 +634,7 @@ describe('addServiceRoleAssignment audit firing', () => { expect(vi.mocked(auditModule.audit)).toHaveBeenCalledWith( expect.objectContaining({ action: 'service_role.allowed_roles.changed', - entityType: 'ProgrammeServiceRoleAssignment', + entityType: 'EventServicePart', entityId: 200, actorId: 42, }), @@ -642,10 +642,10 @@ describe('addServiceRoleAssignment audit firing', () => { }) it('does not fire audit when role list does not change', async () => { - mockDb.programmeServiceRoleAssignment.create.mockResolvedValue({ id: 200 }) - vi.mocked(allowedRoles.setServiceRoleAssignmentAllowedRoles).mockResolvedValue({ added: [], removed: [] }) + mockDb.eventServicePart.create.mockResolvedValue({ id: 200 }) + vi.mocked(allowedRoles.setServicePartAssignmentAllowedRoles).mockResolvedValue({ added: [], removed: [] }) - await addServiceRoleAssignment( + await addServicePartAssignment( mockDb as never, { eventId: 1, name: 'Son', allowedRoleIds: [], congregationId: 10 }, 42, diff --git a/app/features/events/server/programme-events.server.ts b/app/features/events/server/event-parts.server.ts similarity index 81% rename from app/features/events/server/programme-events.server.ts rename to app/features/events/server/event-parts.server.ts index f24b9c32..8dc0be6a 100644 --- a/app/features/events/server/programme-events.server.ts +++ b/app/features/events/server/event-parts.server.ts @@ -1,7 +1,7 @@ -import { ProgrammeTemplateKey } from '~/features/events/model/programme-template.type' +import { EventTemplateKey } from '~/features/events/model/event-template.type' import { setPartAssignmentAllowedRoles, - setServiceRoleAssignmentAllowedRoles, + setServicePartAssignmentAllowedRoles, } from '~/features/events/server/allowed-roles.server' import { AuditAction, audit, auditInTransaction } from '~/shared/domain/audit.server' import { NotFoundError } from '~/shared/errors/app-error.server' @@ -21,8 +21,8 @@ export async function createFreeformEvent( // Same reasoning as createDayOff: freeform events are identified by // `template.key = 'freeform'`. A null templateId here silently drops the // event from cadence, board links, and the events list. - const freeformTemplate = await db.programmeTemplate.findFirst({ - where: { key: ProgrammeTemplateKey.Freeform, congregationId: data.congregationId }, + const freeformTemplate = await db.eventTemplate.findFirst({ + where: { key: EventTemplateKey.Freeform, congregationId: data.congregationId }, }) if (!freeformTemplate) throw new NotFoundError('Freeform template') @@ -111,7 +111,7 @@ export async function addPartAssignment( actorId: number, ) { const { allowedSpeakerRoleIds, allowedReaderRoleIds, ...createData } = data - const assignment = await db.programmePartAssignment.create({ data: createData }) + const assignment = await db.eventPart.create({ data: createData }) const speakerDiff = await setPartAssignmentAllowedRoles( db, @@ -138,7 +138,7 @@ export async function addPartAssignment( action: AuditAction.PartAllowedRolesChanged, congregationId: data.congregationId, actorId, - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: assignment.id, metadata: { speaker: speakerDiff, reader: readerDiff }, }) @@ -148,14 +148,14 @@ export async function addPartAssignment( } export function deletePartAssignment(db: TransactionClient, id: number, congregationId: number) { - return db.programmePartAssignment.delete({ + return db.eventPart.delete({ where: { id_congregationId: { id, congregationId }, }, }) } -export async function addServiceRoleAssignment( +export async function addServicePartAssignment( db: TransactionClient, data: { eventId: number @@ -166,15 +166,15 @@ export async function addServiceRoleAssignment( actorId: number, ) { const { allowedRoleIds, ...createData } = data - const assignment = await db.programmeServiceRoleAssignment.create({ data: createData }) + const assignment = await db.eventServicePart.create({ data: createData }) - const diff = await setServiceRoleAssignmentAllowedRoles(db, assignment.id, allowedRoleIds, data.congregationId) + const diff = await setServicePartAssignmentAllowedRoles(db, assignment.id, allowedRoleIds, data.congregationId) if (diff.added.length > 0 || diff.removed.length > 0) { audit({ - action: AuditAction.ServiceRoleAllowedRolesChanged, + action: AuditAction.ServicePartAllowedRolesChanged, congregationId: data.congregationId, actorId, - entityType: 'ProgrammeServiceRoleAssignment', + entityType: 'EventServicePart', entityId: assignment.id, metadata: { added: diff.added, removed: diff.removed }, }) @@ -203,7 +203,7 @@ export async function updatePartAssignment( actorId: number, ) { const { allowedSpeakerRoleIds, allowedReaderRoleIds, ...updateData } = data - const assignment = await db.programmePartAssignment.update({ + const assignment = await db.eventPart.update({ where: { id_congregationId: { id, congregationId } }, data: updateData, }) @@ -221,7 +221,7 @@ export async function updatePartAssignment( action: AuditAction.PartAllowedRolesChanged, congregationId, actorId, - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: id, metadata: { speaker: speakerDiff, reader: readerDiff }, }) @@ -230,25 +230,25 @@ export async function updatePartAssignment( return assignment } -export async function updateServiceRoleAssignment( +export async function updateServicePartAssignment( db: TransactionClient, id: number, data: { name: string; allowedRoleIds: number[] }, congregationId: number, actorId: number, ) { - const assignment = await db.programmeServiceRoleAssignment.update({ + const assignment = await db.eventServicePart.update({ where: { id_congregationId: { id, congregationId } }, data: { name: data.name }, }) - const diff = await setServiceRoleAssignmentAllowedRoles(db, id, data.allowedRoleIds, congregationId) + const diff = await setServicePartAssignmentAllowedRoles(db, id, data.allowedRoleIds, congregationId) if (diff.added.length > 0 || diff.removed.length > 0) { audit({ - action: AuditAction.ServiceRoleAllowedRolesChanged, + action: AuditAction.ServicePartAllowedRolesChanged, congregationId, actorId, - entityType: 'ProgrammeServiceRoleAssignment', + entityType: 'EventServicePart', entityId: id, metadata: { added: diff.added, removed: diff.removed }, }) @@ -257,8 +257,8 @@ export async function updateServiceRoleAssignment( return assignment } -export function deleteServiceRoleAssignment(db: TransactionClient, id: number, congregationId: number) { - return db.programmeServiceRoleAssignment.delete({ +export function deleteServicePartAssignment(db: TransactionClient, id: number, congregationId: number) { + return db.eventServicePart.delete({ where: { id_congregationId: { id, congregationId }, }, @@ -273,7 +273,7 @@ export async function reorderPartAssignments( await db.$executeRawUnsafe('SELECT pg_advisory_xact_lock($1, $2)', 1_000_003, congregationId) for (let i = 0; i < orderedIds.length; i++) { - await db.programmePartAssignment.update({ + await db.eventPart.update({ where: { id_congregationId: { id: orderedIds[i], congregationId }, }, @@ -289,14 +289,14 @@ export async function applyTemplateToEvent( congregationId: number, userId: number, ) { - const template = await db.programmeTemplate.findFirst({ + const template = await db.eventTemplate.findFirst({ where: { id: templateId, congregationId }, include: { parts: { orderBy: { order: 'asc' }, include: { allowedRoles: true }, }, - serviceRoles: { include: { allowedRoles: true } }, + serviceParts: { include: { allowedRoles: true } }, }, }) if (!template) return null @@ -309,7 +309,7 @@ export async function applyTemplateToEvent( }) for (const part of template.parts) { - const assignment = await db.programmePartAssignment.create({ + const assignment = await db.eventPart.create({ data: { eventId, partId: part.id, @@ -325,9 +325,9 @@ export async function applyTemplateToEvent( }, }) if (part.allowedRoles.length > 0) { - await db.programmePartAssignmentAllowedRole.createMany({ + await db.eventPartAllowedRole.createMany({ data: part.allowedRoles.map(r => ({ - assignmentId: assignment.id, + eventPartId: assignment.id, roleId: r.roleId, asKind: r.asKind, congregationId, @@ -337,14 +337,14 @@ export async function applyTemplateToEvent( } } - for (const role of template.serviceRoles) { - const assignment = await db.programmeServiceRoleAssignment.create({ - data: { eventId, serviceRoleId: role.id, name: role.name, congregationId }, + for (const role of template.serviceParts) { + const assignment = await db.eventServicePart.create({ + data: { eventId, servicePartId: role.id, name: role.name, congregationId }, }) if (role.allowedRoles.length > 0) { - await db.programmeServiceRoleAssignmentAllowedRole.createMany({ + await db.eventServicePartAllowedRole.createMany({ data: role.allowedRoles.map(r => ({ - assignmentId: assignment.id, + eventServicePartId: assignment.id, roleId: r.roleId, congregationId, })), diff --git a/app/features/events/server/event-status-bulk.server.test.ts b/app/features/events/server/event-status-bulk.server.test.ts index 0a66ee53..55c79654 100644 --- a/app/features/events/server/event-status-bulk.server.test.ts +++ b/app/features/events/server/event-status-bulk.server.test.ts @@ -36,8 +36,8 @@ const draftEvent = { status: 'draft', startDate: new Date(2026, 3, 14), templateId: 7, - partAssignments: [] as unknown[], - serviceRoleAssignments: [] as unknown[], + eventParts: [] as unknown[], + eventServiceParts: [] as unknown[], } const releasedEvent = { ...draftEvent, status: 'released' } @@ -56,8 +56,8 @@ describe('bulkReleaseEvents (per-event scope + post-tx notifications)', () => { return Promise.resolve({ ...draftEvent, id: 20, - partAssignments: [{ id: 100, name: 'Part', hasConflict: true, assigneeId: 5, assistantId: null }], - serviceRoleAssignments: [], + eventParts: [{ id: 100, name: 'Part', hasConflict: true, assigneeId: 5, assistantId: null }], + eventServiceParts: [], }) } return Promise.resolve({ ...draftEvent, id: 30 }) @@ -98,8 +98,8 @@ describe('bulkReleaseEvents (per-event scope + post-tx notifications)', () => { it('fires notifyAssignment for each target of every released event', async () => { vi.mocked(db.event.findFirst).mockResolvedValue({ ...draftEvent, - partAssignments: [{ id: 100, name: 'Perle', hasConflict: false, assigneeId: 5, assistantId: null }], - serviceRoleAssignments: [], + eventParts: [{ id: 100, name: 'Perle', hasConflict: false, assigneeId: 5, assistantId: null }], + eventServiceParts: [], } as never) vi.mocked(db.event.update).mockResolvedValue(releasedEvent as never) @@ -122,7 +122,7 @@ describe('bulkUnreleaseEvents (per-event scope)', () => { vi.mocked(db.event.findFirst).mockImplementation(((args: any) => { const id = args?.where?.id as number if (id === 10) return Promise.resolve(null) - return Promise.resolve({ ...releasedEvent, id, partAssignments: [], serviceRoleAssignments: [] }) + return Promise.resolve({ ...releasedEvent, id, eventParts: [], eventServiceParts: [] }) }) as never) vi.mocked(db.event.update).mockResolvedValue(draftEvent as never) @@ -137,8 +137,8 @@ describe('bulkUnreleaseEvents (per-event scope)', () => { const { withScope } = await import('~/shared/infra/db.server') vi.mocked(db.event.findFirst).mockResolvedValue({ ...releasedEvent, - partAssignments: [], - serviceRoleAssignments: [], + eventParts: [], + eventServiceParts: [], } as never) vi.mocked(db.event.update).mockResolvedValue(draftEvent as never) vi.mocked(withScope) diff --git a/app/features/events/server/event-status.policy.test.ts b/app/features/events/server/event-status.policy.test.ts index abe98359..a8dd8691 100644 --- a/app/features/events/server/event-status.policy.test.ts +++ b/app/features/events/server/event-status.policy.test.ts @@ -4,21 +4,25 @@ import { assertCanRelease, EVENT_STATUS_ERRORS } from './event-status.policy' describe('assertCanRelease', () => { it('does not throw when there are no assignments', () => { - expect(() => assertCanRelease({ parts: [], serviceRoles: [] })).not.toThrow() + expect(() => assertCanRelease({ eventParts: [], eventServiceParts: [] })).not.toThrow() }) it('does not throw when every assignment is conflict-free', () => { expect(() => - assertCanRelease({ parts: [{ hasConflict: false }], serviceRoles: [{ hasConflict: false }] }), + assertCanRelease({ eventParts: [{ hasConflict: false }], eventServiceParts: [{ hasConflict: false }] }), ).not.toThrow() }) it('throws ConflictError when a part assignment has a conflict', () => { - expect(() => assertCanRelease({ parts: [{ hasConflict: true }], serviceRoles: [] })).toThrow(ConflictError) + expect(() => assertCanRelease({ eventParts: [{ hasConflict: true }], eventServiceParts: [] })).toThrow( + ConflictError, + ) }) it('throws ConflictError when a service role assignment has a conflict', () => { - expect(() => assertCanRelease({ parts: [], serviceRoles: [{ hasConflict: true }] })).toThrow(ConflictError) + expect(() => assertCanRelease({ eventParts: [], eventServiceParts: [{ hasConflict: true }] })).toThrow( + ConflictError, + ) }) // The error is a fixed short line. The event view page enumerates each @@ -27,7 +31,10 @@ describe('assertCanRelease', () => { // would become unreadable. it('uses the fixed release-blocked message verbatim (no name enumeration)', () => { try { - assertCanRelease({ parts: [{ hasConflict: true }, { hasConflict: true }], serviceRoles: [{ hasConflict: true }] }) + assertCanRelease({ + eventParts: [{ hasConflict: true }, { hasConflict: true }], + eventServiceParts: [{ hasConflict: true }], + }) throw new Error('expected assertCanRelease to throw') } catch (e) { expect(e).toBeInstanceOf(ConflictError) diff --git a/app/features/events/server/event-status.policy.ts b/app/features/events/server/event-status.policy.ts index 5a3f37b1..ed8604b2 100644 --- a/app/features/events/server/event-status.policy.ts +++ b/app/features/events/server/event-status.policy.ts @@ -13,15 +13,16 @@ export const EVENT_STATUS_ERRORS = { } as const export type PartReleaseAssignment = { hasConflict: boolean } -export type ServiceRoleReleaseAssignment = { hasConflict: boolean } +export type ServicePartReleaseAssignment = { hasConflict: boolean } export type ReleaseAssignments = { - parts: PartReleaseAssignment[] - serviceRoles: ServiceRoleReleaseAssignment[] + eventParts: PartReleaseAssignment[] + eventServiceParts: ServicePartReleaseAssignment[] } export function assertCanRelease(assignments: ReleaseAssignments): void { - const hasAny = assignments.parts.some(p => p.hasConflict) || assignments.serviceRoles.some(s => s.hasConflict) + const hasAny = + assignments.eventParts.some(p => p.hasConflict) || assignments.eventServiceParts.some(s => s.hasConflict) if (!hasAny) return throw new ConflictError(EVENT_STATUS_ERRORS.releaseBlockedByConflicts) } diff --git a/app/features/events/server/event-status.server.test.ts b/app/features/events/server/event-status.server.test.ts index 3a84bb06..bfba5dca 100644 --- a/app/features/events/server/event-status.server.test.ts +++ b/app/features/events/server/event-status.server.test.ts @@ -5,8 +5,8 @@ import { EVENT_STATUS_ERRORS } from './event-status.policy' vi.mock('~/shared/infra/db.server', () => { const unscopedDb = { event: { findFirst: vi.fn(), update: vi.fn() }, - programmePartAssignment: { findMany: vi.fn() }, - programmeServiceRoleAssignment: { findMany: vi.fn() }, + eventPart: { findMany: vi.fn() }, + eventServicePart: { findMany: vi.fn() }, notificationEvent: { updateMany: vi.fn() }, userAccount: { findFirst: vi.fn() }, } @@ -41,8 +41,8 @@ const draftEvent = { status: 'draft', startDate: new Date(2026, 3, 14), templateId: 7, - partAssignments: [] as unknown[], - serviceRoleAssignments: [] as unknown[], + eventParts: [] as unknown[], + eventServiceParts: [] as unknown[], } const releasedEvent = { ...draftEvent, status: 'released' } @@ -73,8 +73,8 @@ describe('releaseEvent (tx-only)', () => { it('returns an error and does not update when any assignment has a conflict', async () => { vi.mocked(db.event.findFirst).mockResolvedValue({ ...draftEvent, - partAssignments: [{ id: 100, name: 'Perle', hasConflict: true, assigneeId: 5, assistantId: null }], - serviceRoleAssignments: [], + eventParts: [{ id: 100, name: 'Perle', hasConflict: true, assigneeId: 5, assistantId: null }], + eventServiceParts: [], } as never) const result = await releaseEvent(db, 42, 1, 5) @@ -121,8 +121,8 @@ describe('releaseEvent (tx-only)', () => { it('never calls notifyAssignment (notifications happen outside the tx)', async () => { vi.mocked(db.event.findFirst).mockResolvedValue({ ...draftEvent, - partAssignments: [{ id: 100, name: 'Perle', hasConflict: false, assigneeId: 5, assistantId: 6 }], - serviceRoleAssignments: [{ id: 200, name: 'Accueil', hasConflict: false, assigneeId: 9 }], + eventParts: [{ id: 100, name: 'Perle', hasConflict: false, assigneeId: 5, assistantId: 6 }], + eventServiceParts: [{ id: 200, name: 'Accueil', hasConflict: false, assigneeId: 9 }], } as never) vi.mocked(db.event.update).mockResolvedValue(releasedEvent as never) @@ -134,8 +134,8 @@ describe('releaseEvent (tx-only)', () => { it('computes notifyTargets for every populated speaker/reader/servant slot', async () => { vi.mocked(db.event.findFirst).mockResolvedValue({ ...draftEvent, - partAssignments: [{ id: 100, name: 'Perle', hasConflict: false, assigneeId: 5, assistantId: 6 }], - serviceRoleAssignments: [{ id: 200, name: 'Accueil', hasConflict: false, assigneeId: 9 }], + eventParts: [{ id: 100, name: 'Perle', hasConflict: false, assigneeId: 5, assistantId: 6 }], + eventServiceParts: [{ id: 200, name: 'Accueil', hasConflict: false, assigneeId: 9 }], } as never) vi.mocked(db.event.update).mockResolvedValue(releasedEvent as never) @@ -143,10 +143,10 @@ describe('releaseEvent (tx-only)', () => { expect(result).toMatchObject({ notifyTargets: [ - { entityType: 'ProgrammePartAssignment', entityId: 100, assignmentName: 'Perle', memberId: 5, role: 'speaker' }, - { entityType: 'ProgrammePartAssignment', entityId: 100, assignmentName: 'Perle', memberId: 6, role: 'reader' }, + { entityType: 'EventPart', entityId: 100, assignmentName: 'Perle', memberId: 5, role: 'speaker' }, + { entityType: 'EventPart', entityId: 100, assignmentName: 'Perle', memberId: 6, role: 'reader' }, { - entityType: 'ProgrammeServiceRoleAssignment', + entityType: 'EventServicePart', entityId: 200, assignmentName: 'Accueil', memberId: 9, @@ -159,8 +159,8 @@ describe('releaseEvent (tx-only)', () => { it('excludes empty slots from notifyTargets', async () => { vi.mocked(db.event.findFirst).mockResolvedValue({ ...draftEvent, - partAssignments: [{ id: 100, name: 'Perle', hasConflict: false, assigneeId: null, assistantId: null }], - serviceRoleAssignments: [{ id: 200, name: 'Accueil', hasConflict: false, assigneeId: null }], + eventParts: [{ id: 100, name: 'Perle', hasConflict: false, assigneeId: null, assistantId: null }], + eventServiceParts: [{ id: 200, name: 'Accueil', hasConflict: false, assigneeId: null }], } as never) vi.mocked(db.event.update).mockResolvedValue(releasedEvent as never) @@ -176,7 +176,7 @@ describe('releaseEvent (tx-only)', () => { describe('fireReleaseNotifications', () => { const eventShape = { id: 42, name: 'Réunion', startDate: new Date(2026, 3, 14), templateId: 7 } const speakerTarget = { - entityType: 'ProgrammePartAssignment' as const, + entityType: 'EventPart' as const, entityId: 100, assignmentName: 'Perle', memberId: 5, @@ -219,7 +219,7 @@ describe('fireReleaseNotifications', () => { [ speakerTarget, { - entityType: 'ProgrammeServiceRoleAssignment', + entityType: 'EventServicePart', entityId: 200, assignmentName: 'Accueil', memberId: 9, @@ -234,12 +234,12 @@ describe('fireReleaseNotifications', () => { const partCtx = vi.mocked(notifyAssignment).mock.calls[0][1] const svcCtx = vi.mocked(notifyAssignment).mock.calls[1][1] expect(partCtx).toMatchObject({ - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: 100, assignmentName: 'Perle', }) expect(svcCtx).toMatchObject({ - entityType: 'ProgrammeServiceRoleAssignment', + entityType: 'EventServicePart', entityId: 200, assignmentName: 'Accueil', }) @@ -290,8 +290,8 @@ describe('unreleaseEvent', () => { it('skips the notificationEvent updateMany when the event has zero assignments', async () => { vi.mocked(db.event.findFirst).mockResolvedValue({ ...releasedEvent, - partAssignments: [], - serviceRoleAssignments: [], + eventParts: [], + eventServiceParts: [], } as never) vi.mocked(db.event.update).mockResolvedValue(draftEvent as never) @@ -303,8 +303,8 @@ describe('unreleaseEvent', () => { it('flips status back to draft and returns the updated event', async () => { vi.mocked(db.event.findFirst).mockResolvedValue({ ...releasedEvent, - partAssignments: [], - serviceRoleAssignments: [], + eventParts: [], + eventServiceParts: [], } as never) vi.mocked(db.event.update).mockResolvedValue(draftEvent as never) vi.mocked(db.notificationEvent.updateMany).mockResolvedValue({ count: 0 } as never) @@ -321,8 +321,8 @@ describe('unreleaseEvent', () => { it('cancels pending NotificationEvent rows for the event’s assignments', async () => { vi.mocked(db.event.findFirst).mockResolvedValue({ ...releasedEvent, - partAssignments: [{ id: 100 }, { id: 101 }], - serviceRoleAssignments: [{ id: 200 }], + eventParts: [{ id: 100 }, { id: 101 }], + eventServiceParts: [{ id: 200 }], } as never) vi.mocked(db.event.update).mockResolvedValue(draftEvent as never) vi.mocked(db.notificationEvent.updateMany).mockResolvedValue({ count: 2 } as never) @@ -339,8 +339,8 @@ describe('unreleaseEvent', () => { congregationId: 1, status: 'pending', OR: [ - { entityType: 'ProgrammePartAssignment', entityId: { in: [100, 101] } }, - { entityType: 'ProgrammeServiceRoleAssignment', entityId: { in: [200] } }, + { entityType: 'EventPart', entityId: { in: [100, 101] } }, + { entityType: 'EventServicePart', entityId: { in: [200] } }, ], }), data: expect.objectContaining({ status: 'cancelled' }), @@ -351,8 +351,8 @@ describe('unreleaseEvent', () => { it('audits with action EventUnreleased on success', async () => { vi.mocked(db.event.findFirst).mockResolvedValue({ ...releasedEvent, - partAssignments: [], - serviceRoleAssignments: [], + eventParts: [], + eventServiceParts: [], } as never) vi.mocked(db.event.update).mockResolvedValue(draftEvent as never) vi.mocked(db.notificationEvent.updateMany).mockResolvedValue({ count: 0 } as never) diff --git a/app/features/events/server/event-status.server.ts b/app/features/events/server/event-status.server.ts index ebbe431c..fefc2d41 100644 --- a/app/features/events/server/event-status.server.ts +++ b/app/features/events/server/event-status.server.ts @@ -19,7 +19,7 @@ export type ReleaseNotificationContext = { // notification when the event flips to released. Computed from the event // under the release tx and fired OUTSIDE the tx by fireReleaseNotifications. export type NotifyTarget = { - entityType: 'ProgrammePartAssignment' | 'ProgrammeServiceRoleAssignment' + entityType: 'EventPart' | 'EventServicePart' entityId: number assignmentName: string memberId: number @@ -42,7 +42,7 @@ export type ReleaseResult = { event: EventWithStatus; notifyTargets: NotifyTarge export type UnreleaseResult = { event: EventWithStatus } const eventWithAssignmentsInclude = { - partAssignments: { + eventParts: { select: { id: true, name: true, @@ -51,7 +51,7 @@ const eventWithAssignmentsInclude = { assistantId: true, }, }, - serviceRoleAssignments: { + eventServiceParts: { select: { id: true, name: true, @@ -62,14 +62,14 @@ const eventWithAssignmentsInclude = { } type PartRow = { id: number; name: string; hasConflict: boolean; assigneeId: number | null; assistantId: number | null } -type ServiceRoleRow = { id: number; name: string; hasConflict: boolean; assigneeId: number | null } +type ServicePartRow = { id: number; name: string; hasConflict: boolean; assigneeId: number | null } -function computeNotifyTargets(parts: PartRow[], services: ServiceRoleRow[]): NotifyTarget[] { +function computeNotifyTargets(parts: PartRow[], services: ServicePartRow[]): NotifyTarget[] { const targets: NotifyTarget[] = [] for (const part of parts) { if (part.assigneeId != null) { targets.push({ - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: part.id, assignmentName: part.name, memberId: part.assigneeId, @@ -78,7 +78,7 @@ function computeNotifyTargets(parts: PartRow[], services: ServiceRoleRow[]): Not } if (part.assistantId != null) { targets.push({ - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: part.id, assignmentName: part.name, memberId: part.assistantId, @@ -89,7 +89,7 @@ function computeNotifyTargets(parts: PartRow[], services: ServiceRoleRow[]): Not for (const service of services) { if (service.assigneeId != null) { targets.push({ - entityType: 'ProgrammeServiceRoleAssignment', + entityType: 'EventServicePart', entityId: service.id, assignmentName: service.name, memberId: service.assigneeId, @@ -122,11 +122,11 @@ export async function releaseEvent( if (event.status === EventStatus.Released) return { event, notifyTargets: [] } try { - assertCanRelease({ parts: event.partAssignments, serviceRoles: event.serviceRoleAssignments }) + assertCanRelease({ eventParts: event.eventParts, eventServiceParts: event.eventServiceParts }) } catch (e) { if (e instanceof ConflictError) { - const conflictingParts = event.partAssignments.filter(p => p.hasConflict).length - const conflictingServices = event.serviceRoleAssignments.filter(s => s.hasConflict).length + const conflictingParts = event.eventParts.filter(p => p.hasConflict).length + const conflictingServices = event.eventServiceParts.filter(s => s.hasConflict).length logger.warn('release blocked by conflicts', { eventId, congregationId, @@ -157,11 +157,11 @@ export async function releaseEvent( eventId, congregationId, actorId, - partCount: event.partAssignments.length, - serviceRoleCount: event.serviceRoleAssignments.length, + partCount: event.eventParts.length, + servicePartCount: event.eventServiceParts.length, }) - const notifyTargets = computeNotifyTargets(event.partAssignments, event.serviceRoleAssignments) + const notifyTargets = computeNotifyTargets(event.eventParts, event.eventServiceParts) return { event: updated, notifyTargets } } @@ -189,7 +189,7 @@ export async function fireReleaseNotifications( status: EventStatus.Released, }, assignmentName: '', - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: 0, congregationId, actorId, @@ -234,8 +234,8 @@ export async function unreleaseEvent( const event = await db.event.findFirst({ where: { id: eventId, congregationId }, include: { - partAssignments: { select: { id: true } }, - serviceRoleAssignments: { select: { id: true } }, + eventParts: { select: { id: true } }, + eventServiceParts: { select: { id: true } }, }, }) if (!event) return null @@ -247,8 +247,8 @@ export async function unreleaseEvent( data: { status: EventStatus.Draft }, }) - const partIds = event.partAssignments.map(p => p.id) - const serviceIds = event.serviceRoleAssignments.map(s => s.id) + const partIds = event.eventParts.map(p => p.id) + const serviceIds = event.eventServiceParts.map(s => s.id) // Skip the notificationEvent updateMany entirely when there are no // assignments — an empty `in: []` matches nothing under Prisma today, but @@ -261,8 +261,8 @@ export async function unreleaseEvent( congregationId, status: 'pending', OR: [ - { entityType: 'ProgrammePartAssignment', entityId: { in: partIds } }, - { entityType: 'ProgrammeServiceRoleAssignment', entityId: { in: serviceIds } }, + { entityType: 'EventPart', entityId: { in: partIds } }, + { entityType: 'EventServicePart', entityId: { in: serviceIds } }, ], }, data: { status: 'cancelled', processedAt: new Date() }, @@ -283,7 +283,7 @@ export async function unreleaseEvent( actorId, cancelledCount, partCount: partIds.length, - serviceRoleCount: serviceIds.length, + servicePartCount: serviceIds.length, }) return { event: updated } diff --git a/app/features/events/server/programme-generation.server.test.ts b/app/features/events/server/event-template-generation.server.test.ts similarity index 76% rename from app/features/events/server/programme-generation.server.test.ts rename to app/features/events/server/event-template-generation.server.test.ts index 9e4f1394..9440aea4 100644 --- a/app/features/events/server/programme-generation.server.test.ts +++ b/app/features/events/server/event-template-generation.server.test.ts @@ -3,16 +3,16 @@ import { computeDatesForWeekdayCount } from '../model/compute-dates' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { - programmeTemplate: { findFirst: vi.fn() }, + eventTemplate: { findFirst: vi.fn() }, event: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() }, - programmePartAssignment: { create: vi.fn() }, - programmeServiceRoleAssignment: { create: vi.fn() }, - programmePartAssignmentAllowedRole: { createMany: vi.fn() }, - programmeServiceRoleAssignmentAllowedRole: { createMany: vi.fn() }, + eventPart: { create: vi.fn() }, + eventServicePart: { create: vi.fn() }, + eventPartAllowedRole: { createMany: vi.fn() }, + eventServicePartAllowedRole: { createMany: vi.fn() }, }, })) -const { generateEventsFromTemplate, createSingleEventFromTemplate } = await import('./programme-generation.server') +const { generateEventsFromTemplate, createSingleEventFromTemplate } = await import('./event-template-generation.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') beforeEach(() => { @@ -55,26 +55,26 @@ const TZ = 'UTC' describe('generateEventsFromTemplate', () => { it('returns empty array when template not found', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(null as never) const result = await generateEventsFromTemplate(db, 999, 2, 1, 1, TZ) expect(result).toEqual([]) }) it('returns empty array when template has no weekDay', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 1, weekDay: null, startTime: '19:00', endTime: '21:00', parts: [], - serviceRoles: [], + serviceParts: [], } as never) const result = await generateEventsFromTemplate(db, 1, 2, 1, 1, TZ) expect(result).toEqual([]) }) it('creates events with assignments for each date', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 1, name: 'Réunion de semaine', weekDay: 2, @@ -85,7 +85,7 @@ describe('generateEventsFromTemplate', () => { { id: 10, order: 1, allowedRoles: [] }, { id: 11, order: 2, allowedRoles: [] }, ], - serviceRoles: [{ id: 20, allowedRoles: [] }], + serviceParts: [{ id: 20, allowedRoles: [] }], } as never) vi.mocked(db.event.findMany).mockResolvedValue([] as never) let eventCounter = 0 @@ -93,22 +93,22 @@ describe('generateEventsFromTemplate', () => { eventCounter++ return Promise.resolve({ id: eventCounter, name: 'Réunion de semaine' }) }) as never) - vi.mocked(db.programmePartAssignment.create).mockResolvedValue({} as never) - vi.mocked(db.programmeServiceRoleAssignment.create).mockResolvedValue({} as never) + vi.mocked(db.eventPart.create).mockResolvedValue({} as never) + vi.mocked(db.eventServicePart.create).mockResolvedValue({} as never) const result = await generateEventsFromTemplate(db, 1, 2, 1, 1, TZ) expect(result.length).toBeGreaterThan(0) }) it('links every created event to the source template', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 42, name: 'Réunion de semaine', weekDay: 2, startTime: '19:00', endTime: '21:00', parts: [], - serviceRoles: [], + serviceParts: [], } as never) vi.mocked(db.event.findMany).mockResolvedValue([] as never) vi.mocked(db.event.create).mockResolvedValue({ id: 1 } as never) @@ -123,7 +123,7 @@ describe('generateEventsFromTemplate', () => { // give a false positive. Distinct strings prove the value is threaded from // the template, not fabricated by the caller. it('copies speakerLabel and readerLabel from template parts to assignments (Layer 4)', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 1, name: 'Réunion du week-end', weekDay: 0, @@ -158,15 +158,15 @@ describe('generateEventsFromTemplate', () => { allowedRoles: [], }, ], - serviceRoles: [], + serviceParts: [], } as never) vi.mocked(db.event.findMany).mockResolvedValue([] as never) vi.mocked(db.event.create).mockResolvedValue({ id: 1 } as never) - vi.mocked(db.programmePartAssignment.create).mockResolvedValue({} as never) + vi.mocked(db.eventPart.create).mockResolvedValue({} as never) await generateEventsFromTemplate(db, 1, 1, 1, 1, TZ) - const calls = vi.mocked(db.programmePartAssignment.create).mock.calls + const calls = vi.mocked(db.eventPart.create).mock.calls expect(calls.length).toBeGreaterThanOrEqual(2) const firstData = (calls[0][0] as { data: { speakerLabel: string | null; readerLabel: string | null } }).data expect(firstData.speakerLabel).toBe('STUDENT-SENTINEL-P1') @@ -177,7 +177,7 @@ describe('generateEventsFromTemplate', () => { }) it('copies allowExternalSpeaker from template parts to assignments', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 1, name: 'Réunion du week-end', weekDay: 0, @@ -195,15 +195,15 @@ describe('generateEventsFromTemplate', () => { allowedRoles: [], }, ], - serviceRoles: [], + serviceParts: [], } as never) vi.mocked(db.event.findMany).mockResolvedValue([] as never) vi.mocked(db.event.create).mockResolvedValue({ id: 1 } as never) - vi.mocked(db.programmePartAssignment.create).mockResolvedValue({} as never) + vi.mocked(db.eventPart.create).mockResolvedValue({} as never) await generateEventsFromTemplate(db, 1, 2, 1, 1, TZ) - const calls = vi.mocked(db.programmePartAssignment.create).mock.calls + const calls = vi.mocked(db.eventPart.create).mock.calls expect(calls.length).toBeGreaterThan(0) for (const call of calls) { expect((call[0] as { data: { allowExternalSpeaker: boolean } }).data.allowExternalSpeaker).toBe(true) @@ -211,7 +211,7 @@ describe('generateEventsFromTemplate', () => { }) it('skips dates where an event already exists', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 1, name: 'Réunion de semaine', weekDay: 2, @@ -219,7 +219,7 @@ describe('generateEventsFromTemplate', () => { startTime: '19:00', endTime: '21:00', parts: [], - serviceRoles: [], + serviceParts: [], } as never) vi.mocked(db.event.findMany).mockResolvedValue([{ startDate: new Date(2026, 3, 14) }] as never) let createCount = 0 @@ -234,7 +234,7 @@ describe('generateEventsFromTemplate', () => { }) it('starts from startFrom date when provided', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 1, name: 'Réunion de semaine', weekDay: 2, @@ -242,7 +242,7 @@ describe('generateEventsFromTemplate', () => { startTime: '19:00', endTime: '21:00', parts: [], - serviceRoles: [], + serviceParts: [], } as never) vi.mocked(db.event.findMany).mockResolvedValue([] as never) const createdDates: Date[] = [] @@ -259,7 +259,7 @@ describe('generateEventsFromTemplate', () => { }) it('uses the template hours in the target timezone', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 1, name: 'Réunion de semaine', weekDay: 2, @@ -267,7 +267,7 @@ describe('generateEventsFromTemplate', () => { startTime: '17:30', endTime: '19:00', parts: [], - serviceRoles: [], + serviceParts: [], } as never) vi.mocked(db.event.findMany).mockResolvedValue([] as never) const startDates: Date[] = [] @@ -291,20 +291,20 @@ describe('generateEventsFromTemplate', () => { describe('createSingleEventFromTemplate', () => { it('returns null when template not found', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(null as never) const result = await createSingleEventFromTemplate(db, 999, new Date(2026, 3, 20), 1, 1, TZ) expect(result).toBeNull() }) it('returns null when event already exists on that date', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 3, name: 'Mémorial', kindId: null, startTime: '19:00', endTime: '21:00', parts: [], - serviceRoles: [], + serviceParts: [], } as never) vi.mocked(db.event.findFirst).mockResolvedValue({ id: 99 } as never) @@ -313,32 +313,32 @@ describe('createSingleEventFromTemplate', () => { }) it('creates a single event with assignments', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 3, name: 'Mémorial', kindId: null, startTime: '19:00', endTime: '21:00', parts: [{ id: 30, allowedRoles: [] }], - serviceRoles: [{ id: 40, allowedRoles: [] }], + serviceParts: [{ id: 40, allowedRoles: [] }], } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) vi.mocked(db.event.create).mockResolvedValue({ id: 1, name: 'Mémorial' } as never) - vi.mocked(db.programmePartAssignment.create).mockResolvedValue({} as never) - vi.mocked(db.programmeServiceRoleAssignment.create).mockResolvedValue({} as never) + vi.mocked(db.eventPart.create).mockResolvedValue({} as never) + vi.mocked(db.eventServicePart.create).mockResolvedValue({} as never) const result = await createSingleEventFromTemplate(db, 3, new Date(2026, 3, 20), 1, 1, TZ) expect(result).toEqual({ id: 1, name: 'Mémorial' }) }) it('links the created event to the source template', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 3, name: 'Mémorial', startTime: '19:00', endTime: '21:00', parts: [], - serviceRoles: [], + serviceParts: [], } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) vi.mocked(db.event.create).mockResolvedValue({ id: 1 } as never) @@ -350,7 +350,7 @@ describe('createSingleEventFromTemplate', () => { }) it('copies allowExternalSpeaker from template parts to assignments', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 3, name: 'Mémorial', kindId: null, @@ -368,20 +368,20 @@ describe('createSingleEventFromTemplate', () => { allowedRoles: [], }, ], - serviceRoles: [], + serviceParts: [], } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) vi.mocked(db.event.create).mockResolvedValue({ id: 1 } as never) - vi.mocked(db.programmePartAssignment.create).mockResolvedValue({} as never) + vi.mocked(db.eventPart.create).mockResolvedValue({} as never) await createSingleEventFromTemplate(db, 3, new Date(2026, 3, 20), 1, 1, TZ) - const call = vi.mocked(db.programmePartAssignment.create).mock.calls[0] + const call = vi.mocked(db.eventPart.create).mock.calls[0] expect((call[0] as { data: { allowExternalSpeaker: boolean } }).data.allowExternalSpeaker).toBe(true) }) it('copies non-empty allowed-role lists from template parts and service roles to assignments', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 3, name: 'Mémorial', kindId: null, @@ -403,32 +403,32 @@ describe('createSingleEventFromTemplate', () => { ], }, ], - serviceRoles: [{ id: 40, name: 'Son', allowedRoles: [{ roleId: 9 }] }], + serviceParts: [{ id: 40, name: 'Son', allowedRoles: [{ roleId: 9 }] }], } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) vi.mocked(db.event.create).mockResolvedValue({ id: 555 } as never) - vi.mocked(db.programmePartAssignment.create).mockResolvedValue({ id: 700 } as never) - vi.mocked(db.programmeServiceRoleAssignment.create).mockResolvedValue({ id: 800 } as never) - vi.mocked(db.programmePartAssignmentAllowedRole.createMany).mockResolvedValue({ count: 2 } as never) - vi.mocked(db.programmeServiceRoleAssignmentAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) + vi.mocked(db.eventPart.create).mockResolvedValue({ id: 700 } as never) + vi.mocked(db.eventServicePart.create).mockResolvedValue({ id: 800 } as never) + vi.mocked(db.eventPartAllowedRole.createMany).mockResolvedValue({ count: 2 } as never) + vi.mocked(db.eventServicePartAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) await createSingleEventFromTemplate(db, 3, new Date(2026, 3, 20), 42, 7, TZ) - expect(vi.mocked(db.programmePartAssignmentAllowedRole.createMany)).toHaveBeenCalledWith({ + expect(vi.mocked(db.eventPartAllowedRole.createMany)).toHaveBeenCalledWith({ data: [ - { assignmentId: 700, roleId: 7, asKind: 'speaker', congregationId: 7 }, - { assignmentId: 700, roleId: 8, asKind: 'reader', congregationId: 7 }, + { eventPartId: 700, roleId: 7, asKind: 'speaker', congregationId: 7 }, + { eventPartId: 700, roleId: 8, asKind: 'reader', congregationId: 7 }, ], skipDuplicates: true, }) - expect(vi.mocked(db.programmeServiceRoleAssignmentAllowedRole.createMany)).toHaveBeenCalledWith({ - data: [{ assignmentId: 800, roleId: 9, congregationId: 7 }], + expect(vi.mocked(db.eventServicePartAllowedRole.createMany)).toHaveBeenCalledWith({ + data: [{ eventServicePartId: 800, roleId: 9, congregationId: 7 }], skipDuplicates: true, }) }) it('skips allowed-role createMany when lists are empty', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 3, name: 'Mémorial', kindId: null, @@ -447,16 +447,16 @@ describe('createSingleEventFromTemplate', () => { allowedRoles: [], }, ], - serviceRoles: [{ id: 40, name: 'Son', allowedRoles: [] }], + serviceParts: [{ id: 40, name: 'Son', allowedRoles: [] }], } as never) vi.mocked(db.event.findFirst).mockResolvedValue(null as never) vi.mocked(db.event.create).mockResolvedValue({ id: 555 } as never) - vi.mocked(db.programmePartAssignment.create).mockResolvedValue({ id: 700 } as never) - vi.mocked(db.programmeServiceRoleAssignment.create).mockResolvedValue({ id: 800 } as never) + vi.mocked(db.eventPart.create).mockResolvedValue({ id: 700 } as never) + vi.mocked(db.eventServicePart.create).mockResolvedValue({ id: 800 } as never) await createSingleEventFromTemplate(db, 3, new Date(2026, 3, 20), 42, 7, TZ) - expect(vi.mocked(db.programmePartAssignmentAllowedRole.createMany)).not.toHaveBeenCalled() - expect(vi.mocked(db.programmeServiceRoleAssignmentAllowedRole.createMany)).not.toHaveBeenCalled() + expect(vi.mocked(db.eventPartAllowedRole.createMany)).not.toHaveBeenCalled() + expect(vi.mocked(db.eventServicePartAllowedRole.createMany)).not.toHaveBeenCalled() }) }) diff --git a/app/features/events/server/programme-generation.server.ts b/app/features/events/server/event-template-generation.server.ts similarity index 89% rename from app/features/events/server/programme-generation.server.ts rename to app/features/events/server/event-template-generation.server.ts index ec65dcd9..cbc0e684 100644 --- a/app/features/events/server/programme-generation.server.ts +++ b/app/features/events/server/event-template-generation.server.ts @@ -28,18 +28,18 @@ interface TemplateWithRelations { readerLabel: string | null allowedRoles: PartAllowedRoleRow[] }[] - serviceRoles: { id: number; name: string; allowedRoles: AllowedRoleRow[] }[] + serviceParts: { id: number; name: string; allowedRoles: AllowedRoleRow[] }[] } function loadTemplate(db: TransactionClient, templateId: number, congregationId: number) { - return db.programmeTemplate.findFirst({ + return db.eventTemplate.findFirst({ where: { id: templateId, congregationId }, include: { parts: { orderBy: { order: 'asc' }, include: { allowedRoles: true }, }, - serviceRoles: { include: { allowedRoles: true } }, + serviceParts: { include: { allowedRoles: true } }, }, }) } @@ -69,7 +69,7 @@ async function createEventWithAssignments( }) for (const part of template.parts) { - const assignment = await db.programmePartAssignment.create({ + const assignment = await db.eventPart.create({ data: { eventId: event.id, partId: part.id, @@ -86,9 +86,9 @@ async function createEventWithAssignments( }, }) if (part.allowedRoles.length > 0) { - await db.programmePartAssignmentAllowedRole.createMany({ + await db.eventPartAllowedRole.createMany({ data: part.allowedRoles.map(r => ({ - assignmentId: assignment.id, + eventPartId: assignment.id, roleId: r.roleId, asKind: r.asKind, congregationId, @@ -98,19 +98,19 @@ async function createEventWithAssignments( } } - for (const role of template.serviceRoles) { - const assignment = await db.programmeServiceRoleAssignment.create({ + for (const role of template.serviceParts) { + const assignment = await db.eventServicePart.create({ data: { eventId: event.id, - serviceRoleId: role.id, + servicePartId: role.id, name: role.name, congregationId, }, }) if (role.allowedRoles.length > 0) { - await db.programmeServiceRoleAssignmentAllowedRole.createMany({ + await db.eventServicePartAllowedRole.createMany({ data: role.allowedRoles.map(r => ({ - assignmentId: assignment.id, + eventServicePartId: assignment.id, roleId: r.roleId, congregationId, })), diff --git a/app/features/events/server/programme-templates.server.test.ts b/app/features/events/server/event-templates.server.test.ts similarity index 70% rename from app/features/events/server/programme-templates.server.test.ts rename to app/features/events/server/event-templates.server.test.ts index 1c28181c..79eac5fd 100644 --- a/app/features/events/server/programme-templates.server.test.ts +++ b/app/features/events/server/event-templates.server.test.ts @@ -2,25 +2,25 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { - programmeTemplate: { findMany: vi.fn(), findFirst: vi.fn(), update: vi.fn(), create: vi.fn() }, - programmeTemplatePart: { create: vi.fn(), update: vi.fn(), delete: vi.fn() }, - programmeTemplateServiceRole: { create: vi.fn(), update: vi.fn(), delete: vi.fn() }, - programmeTemplateResponsible: { upsert: vi.fn(), deleteMany: vi.fn(), findFirst: vi.fn() }, - programmeTemplatePartAllowedRole: { createMany: vi.fn() }, - programmeTemplateServiceRoleAllowedRole: { createMany: vi.fn() }, + eventTemplate: { findMany: vi.fn(), findFirst: vi.fn(), update: vi.fn(), create: vi.fn() }, + templatePart: { create: vi.fn(), update: vi.fn(), delete: vi.fn() }, + templateServicePart: { create: vi.fn(), update: vi.fn(), delete: vi.fn() }, + templateResponsible: { upsert: vi.fn(), deleteMany: vi.fn(), findFirst: vi.fn() }, + templatePartAllowedRole: { createMany: vi.fn() }, + templateServicePartAllowedRole: { createMany: vi.fn() }, }, })) vi.mock('~/features/events/server/allowed-roles.server', () => ({ setTemplatePartAllowedRoles: vi.fn().mockResolvedValue({ added: [], removed: [] }), - setTemplateServiceRoleAllowedRoles: vi.fn().mockResolvedValue({ added: [], removed: [] }), + setTemplateServicePartAllowedRoles: vi.fn().mockResolvedValue({ added: [], removed: [] }), })) vi.mock('~/shared/domain/audit.server', () => ({ audit: vi.fn(), AuditAction: { PartAllowedRolesChanged: 'part.allowed_roles.changed', - ServiceRoleAllowedRolesChanged: 'service_role.allowed_roles.changed', + ServicePartAllowedRolesChanged: 'service_role.allowed_roles.changed', }, })) @@ -30,13 +30,13 @@ const { updateTemplate, upsertTemplatePart, deleteTemplatePart, - upsertTemplateServiceRole, - deleteTemplateServiceRole, + upsertTemplateServicePart, + deleteTemplateServicePart, setTemplateResponsible, removeTemplateResponsible, isTemplateResponsible, duplicateTemplate, -} = await import('./programme-templates.server') +} = await import('./event-templates.server') const { unscopedDb: db } = await import('~/shared/infra/db.server') const allowedRoles = await import('~/features/events/server/allowed-roles.server') const auditModule = await import('~/shared/domain/audit.server') @@ -44,24 +44,24 @@ const auditModule = await import('~/shared/domain/audit.server') beforeEach(() => { vi.resetAllMocks() vi.mocked(allowedRoles.setTemplatePartAllowedRoles).mockResolvedValue({ added: [], removed: [] }) - vi.mocked(allowedRoles.setTemplateServiceRoleAllowedRoles).mockResolvedValue({ added: [], removed: [] }) + vi.mocked(allowedRoles.setTemplateServicePartAllowedRoles).mockResolvedValue({ added: [], removed: [] }) }) describe('getTemplates', () => { it('returns all templates for a congregation', async () => { const fakeTemplates = [{ id: 1, name: 'Réunion de semaine' }] - vi.mocked(db.programmeTemplate.findMany).mockResolvedValue(fakeTemplates as never) + vi.mocked(db.eventTemplate.findMany).mockResolvedValue(fakeTemplates as never) const result = await getTemplates(db, 1) expect(result).toEqual(fakeTemplates) }) it("pulls the responsible's linked member name so the list can render it", async () => { - vi.mocked(db.programmeTemplate.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventTemplate.findMany).mockResolvedValue([] as never) await getTemplates(db, 1) - const args = vi.mocked(db.programmeTemplate.findMany).mock.calls[0][0] + const args = vi.mocked(db.eventTemplate.findMany).mock.calls[0][0] expect(args?.include?.responsibles).toMatchObject({ include: { user: { include: { member: { select: { firstname: true, lastname: true } } } } }, }) @@ -70,26 +70,26 @@ describe('getTemplates', () => { describe('getTemplateById', () => { it('returns a template with parts and service roles', async () => { - const fakeTemplate = { id: 1, name: 'Réunion de semaine', parts: [], serviceRoles: [] } - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue(fakeTemplate as never) + const fakeTemplate = { id: 1, name: 'Réunion de semaine', parts: [], serviceParts: [] } + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(fakeTemplate as never) const result = await getTemplateById(db, 1, 1) expect(result).toEqual(fakeTemplate) }) it('returns null when template not found', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(null as never) const result = await getTemplateById(db, 999, 1) expect(result).toBeNull() }) it("pulls the responsible's linked member name so the view can render it", async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(null as never) await getTemplateById(db, 1, 1) - const args = vi.mocked(db.programmeTemplate.findFirst).mock.calls[0][0] + const args = vi.mocked(db.eventTemplate.findFirst).mock.calls[0][0] expect(args?.include?.responsibles).toMatchObject({ include: { user: { include: { member: { select: { firstname: true, lastname: true } } } } }, }) @@ -99,18 +99,18 @@ describe('getTemplateById', () => { describe('updateTemplate', () => { it('updates template name', async () => { const updated = { id: 1, name: 'Updated Name' } - vi.mocked(db.programmeTemplate.update).mockResolvedValue(updated as never) + vi.mocked(db.eventTemplate.update).mockResolvedValue(updated as never) const result = await updateTemplate(db, 1, { name: 'Updated Name' }, 1) expect(result).toEqual(updated) }) it('updates the colour when provided', async () => { - vi.mocked(db.programmeTemplate.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventTemplate.update).mockResolvedValue({ id: 1 } as never) await updateTemplate(db, 1, { name: 'Réunion', color: '#ff00aa' }, 1) - const call = vi.mocked(db.programmeTemplate.update).mock.calls[0] + const call = vi.mocked(db.eventTemplate.update).mock.calls[0] expect((call[0] as { data: { color: string } }).data.color).toBe('#ff00aa') }) @@ -119,29 +119,29 @@ describe('updateTemplate', () => { // silently break the feature. The server enforces that even if the form // POSTs a full payload, only the colour lands on the row. it('only writes the colour on system templates', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ key: 'day-off' } as never) - vi.mocked(db.programmeTemplate.update).mockResolvedValue({ id: 1 } as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ key: 'day-off' } as never) + vi.mocked(db.eventTemplate.update).mockResolvedValue({ id: 1 } as never) await updateTemplate(db, 1, { name: 'Nope', color: '#abcdef', weekDay: 3, startTime: '20:00', endTime: '22:00' }, 1) - const call = vi.mocked(db.programmeTemplate.update).mock.calls[0] + const call = vi.mocked(db.eventTemplate.update).mock.calls[0] const written = (call[0] as { data: Record }).data expect(written).toEqual({ color: '#abcdef' }) }) it('skips the update entirely on system templates when nothing but ignored fields is passed', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ key: 'freeform' } as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ key: 'freeform' } as never) await updateTemplate(db, 1, { name: 'Nope', weekDay: 3, startTime: '20:00', endTime: '22:00' }, 1) - expect(db.programmeTemplate.update).not.toHaveBeenCalled() + expect(db.eventTemplate.update).not.toHaveBeenCalled() }) }) describe('upsertTemplatePart', () => { it('creates a new part when no id provided', async () => { const newPart = { id: 10, name: 'New Part' } - vi.mocked(db.programmeTemplatePart.create).mockResolvedValue(newPart as never) + vi.mocked(db.templatePart.create).mockResolvedValue(newPart as never) const result = await upsertTemplatePart( db, @@ -164,7 +164,7 @@ describe('upsertTemplatePart', () => { it('updates an existing part when id is provided', async () => { const updatedPart = { id: 5, name: 'Updated Part' } - vi.mocked(db.programmeTemplatePart.update).mockResolvedValue(updatedPart as never) + vi.mocked(db.templatePart.update).mockResolvedValue(updatedPart as never) const result = await upsertTemplatePart( db, @@ -190,27 +190,27 @@ describe('upsertTemplatePart', () => { describe('deleteTemplatePart', () => { it('deletes a part', async () => { const deleted = { id: 5 } - vi.mocked(db.programmeTemplatePart.delete).mockResolvedValue(deleted as never) + vi.mocked(db.templatePart.delete).mockResolvedValue(deleted as never) const result = await deleteTemplatePart(db, 5, 1) expect(result).toEqual(deleted) }) }) -describe('upsertTemplateServiceRole', () => { +describe('upsertTemplateServicePart', () => { it('creates a new service role when no id provided', async () => { const newRole = { id: 10, name: 'Sono', key: 'sono' } - vi.mocked(db.programmeTemplateServiceRole.create).mockResolvedValue(newRole as never) + vi.mocked(db.templateServicePart.create).mockResolvedValue(newRole as never) - const result = await upsertTemplateServiceRole(db, 1, { name: 'Sono', key: 'sono', allowedRoleIds: [] }, 1, 99) + const result = await upsertTemplateServicePart(db, 1, { name: 'Sono', key: 'sono', allowedRoleIds: [] }, 1, 99) expect(result).toEqual(newRole) }) it('updates an existing service role when id is provided', async () => { const updatedRole = { id: 3, name: 'Updated' } - vi.mocked(db.programmeTemplateServiceRole.update).mockResolvedValue(updatedRole as never) + vi.mocked(db.templateServicePart.update).mockResolvedValue(updatedRole as never) - const result = await upsertTemplateServiceRole( + const result = await upsertTemplateServicePart( db, 1, { id: 3, name: 'Updated', key: 'updated', allowedRoleIds: [] }, @@ -221,12 +221,12 @@ describe('upsertTemplateServiceRole', () => { }) }) -describe('deleteTemplateServiceRole', () => { +describe('deleteTemplateServicePart', () => { it('deletes a service role', async () => { const deleted = { id: 3 } - vi.mocked(db.programmeTemplateServiceRole.delete).mockResolvedValue(deleted as never) + vi.mocked(db.templateServicePart.delete).mockResolvedValue(deleted as never) - const result = await deleteTemplateServiceRole(db, 3, 1) + const result = await deleteTemplateServicePart(db, 3, 1) expect(result).toEqual(deleted) }) }) @@ -234,7 +234,7 @@ describe('deleteTemplateServiceRole', () => { describe('setTemplateResponsible', () => { it('upserts a responsible user for a template', async () => { const responsible = { id: 1, templateId: 1, userId: 5 } - vi.mocked(db.programmeTemplateResponsible.upsert).mockResolvedValue(responsible as never) + vi.mocked(db.templateResponsible.upsert).mockResolvedValue(responsible as never) const result = await setTemplateResponsible(db, 1, 5, 1) expect(result).toEqual(responsible) @@ -243,7 +243,7 @@ describe('setTemplateResponsible', () => { describe('removeTemplateResponsible', () => { it('removes the responsible from a template', async () => { - vi.mocked(db.programmeTemplateResponsible.deleteMany).mockResolvedValue({ count: 1 } as never) + vi.mocked(db.templateResponsible.deleteMany).mockResolvedValue({ count: 1 } as never) const result = await removeTemplateResponsible(db, 1, 1) expect(result).toEqual({ count: 1 }) @@ -253,14 +253,14 @@ describe('removeTemplateResponsible', () => { describe('isTemplateResponsible', () => { it('returns the record when user is responsible', async () => { const record = { id: 1, templateId: 1, userId: 5 } - vi.mocked(db.programmeTemplateResponsible.findFirst).mockResolvedValue(record as never) + vi.mocked(db.templateResponsible.findFirst).mockResolvedValue(record as never) const result = await isTemplateResponsible(db, 1, 5, 1) expect(result).toEqual(record) }) it('returns null when user is not responsible', async () => { - vi.mocked(db.programmeTemplateResponsible.findFirst).mockResolvedValue(null as never) + vi.mocked(db.templateResponsible.findFirst).mockResolvedValue(null as never) const result = await isTemplateResponsible(db, 1, 99, 1) expect(result).toBeNull() @@ -269,7 +269,7 @@ describe('isTemplateResponsible', () => { describe('upsertTemplatePart role labels', () => { it('passes speakerLabel and readerLabel through to the create data (Layer 5)', async () => { - vi.mocked(db.programmeTemplatePart.create).mockResolvedValue({ id: 42 } as never) + vi.mocked(db.templatePart.create).mockResolvedValue({ id: 42 } as never) await upsertTemplatePart( db, @@ -290,13 +290,13 @@ describe('upsertTemplatePart role labels', () => { 99, ) - expect(vi.mocked(db.programmeTemplatePart.create)).toHaveBeenCalledWith({ + expect(vi.mocked(db.templatePart.create)).toHaveBeenCalledWith({ data: expect.objectContaining({ speakerLabel: 'STUDENT-SENTINEL', readerLabel: null }), }) }) it('passes speakerLabel and readerLabel through to the update data (Layer 5)', async () => { - vi.mocked(db.programmeTemplatePart.update).mockResolvedValue({ id: 42 } as never) + vi.mocked(db.templatePart.update).mockResolvedValue({ id: 42 } as never) await upsertTemplatePart( db, @@ -318,7 +318,7 @@ describe('upsertTemplatePart role labels', () => { 99, ) - expect(vi.mocked(db.programmeTemplatePart.update)).toHaveBeenCalledWith({ + expect(vi.mocked(db.templatePart.update)).toHaveBeenCalledWith({ where: { id_congregationId: { id: 42, congregationId: 7 } }, data: expect.objectContaining({ speakerLabel: 'STUDENT-SENTINEL', readerLabel: 'HOUSEHOLDER-SENTINEL' }), }) @@ -327,7 +327,7 @@ describe('upsertTemplatePart role labels', () => { describe('upsertTemplatePart audit firing', () => { it('fires PartAllowedRolesChanged audit when role lists change', async () => { - vi.mocked(db.programmeTemplatePart.create).mockResolvedValue({ id: 50 } as never) + vi.mocked(db.templatePart.create).mockResolvedValue({ id: 50 } as never) vi.mocked(allowedRoles.setTemplatePartAllowedRoles).mockResolvedValueOnce({ added: [10], removed: [] }) vi.mocked(allowedRoles.setTemplatePartAllowedRoles).mockResolvedValueOnce({ added: [], removed: [] }) @@ -351,7 +351,7 @@ describe('upsertTemplatePart audit firing', () => { expect(vi.mocked(auditModule.audit)).toHaveBeenCalledWith( expect.objectContaining({ action: 'part.allowed_roles.changed', - entityType: 'ProgrammeTemplatePart', + entityType: 'TemplatePart', entityId: 50, actorId: 99, }), @@ -359,7 +359,7 @@ describe('upsertTemplatePart audit firing', () => { }) it('does not fire audit when role lists do not change', async () => { - vi.mocked(db.programmeTemplatePart.create).mockResolvedValue({ id: 50 } as never) + vi.mocked(db.templatePart.create).mockResolvedValue({ id: 50 } as never) vi.mocked(allowedRoles.setTemplatePartAllowedRoles).mockResolvedValue({ added: [], removed: [] }) await upsertTemplatePart( @@ -383,17 +383,17 @@ describe('upsertTemplatePart audit firing', () => { }) }) -describe('upsertTemplateServiceRole audit firing', () => { - it('fires ServiceRoleAllowedRolesChanged audit when role list changes', async () => { - vi.mocked(db.programmeTemplateServiceRole.create).mockResolvedValue({ id: 60 } as never) - vi.mocked(allowedRoles.setTemplateServiceRoleAllowedRoles).mockResolvedValueOnce({ added: [11], removed: [] }) +describe('upsertTemplateServicePart audit firing', () => { + it('fires ServicePartAllowedRolesChanged audit when role list changes', async () => { + vi.mocked(db.templateServicePart.create).mockResolvedValue({ id: 60 } as never) + vi.mocked(allowedRoles.setTemplateServicePartAllowedRoles).mockResolvedValueOnce({ added: [11], removed: [] }) - await upsertTemplateServiceRole(db, 1, { name: 'Son', key: 'sono', allowedRoleIds: [11] }, 1, 99) + await upsertTemplateServicePart(db, 1, { name: 'Son', key: 'sono', allowedRoleIds: [11] }, 1, 99) expect(vi.mocked(auditModule.audit)).toHaveBeenCalledWith( expect.objectContaining({ action: 'service_role.allowed_roles.changed', - entityType: 'ProgrammeTemplateServiceRole', + entityType: 'TemplateServicePart', entityId: 60, actorId: 99, }), @@ -401,10 +401,10 @@ describe('upsertTemplateServiceRole audit firing', () => { }) it('does not fire audit when role list is unchanged', async () => { - vi.mocked(db.programmeTemplateServiceRole.create).mockResolvedValue({ id: 60 } as never) - vi.mocked(allowedRoles.setTemplateServiceRoleAllowedRoles).mockResolvedValue({ added: [], removed: [] }) + vi.mocked(db.templateServicePart.create).mockResolvedValue({ id: 60 } as never) + vi.mocked(allowedRoles.setTemplateServicePartAllowedRoles).mockResolvedValue({ added: [], removed: [] }) - await upsertTemplateServiceRole(db, 1, { name: 'Son', key: 'sono', allowedRoleIds: [] }, 1, 99) + await upsertTemplateServicePart(db, 1, { name: 'Son', key: 'sono', allowedRoleIds: [] }, 1, 99) expect(vi.mocked(auditModule.audit)).not.toHaveBeenCalled() }) @@ -412,30 +412,30 @@ describe('upsertTemplateServiceRole audit firing', () => { describe('duplicateTemplate', () => { it('returns null when source template not found', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(null as never) const result = await duplicateTemplate(db, 99, 1) expect(result).toBeNull() - expect(vi.mocked(db.programmeTemplate.create)).not.toHaveBeenCalled() + expect(vi.mocked(db.eventTemplate.create)).not.toHaveBeenCalled() }) // System templates are looked up by `key` at runtime. Duplicating them would // produce a row with an untethered `-copy-` suffix; the UI hides the // action but this is the server-side belt-and-suspenders check. it('returns null when the source is a system template', async () => { - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({ id: 1, key: 'day-off', name: 'Absence', parts: [], - serviceRoles: [], + serviceParts: [], } as never) const result = await duplicateTemplate(db, 1, 1) expect(result).toBeNull() - expect(vi.mocked(db.programmeTemplate.create)).not.toHaveBeenCalled() + expect(vi.mocked(db.eventTemplate.create)).not.toHaveBeenCalled() }) it('copies allowed-role lists from source parts and service roles to the duplicate', async () => { @@ -471,12 +471,12 @@ describe('duplicateTemplate', () => { allowedRoles: [], }, ], - serviceRoles: [ + serviceParts: [ { id: 20, name: 'Son', key: 'sono', allowedRoles: [{ roleId: 300 }] }, { id: 21, name: 'Stage', key: 'stage', allowedRoles: [] }, ], } - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue(source as never) + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(source as never) const duplicated = { id: 99, @@ -485,35 +485,35 @@ describe('duplicateTemplate', () => { { id: 510, order: 1 }, { id: 511, order: 2 }, ], - serviceRoles: [ + serviceParts: [ { id: 520, name: 'Son' }, { id: 521, name: 'Stage' }, ], } - vi.mocked(db.programmeTemplate.create).mockResolvedValue(duplicated as never) - vi.mocked(db.programmeTemplatePartAllowedRole.createMany).mockResolvedValue({ count: 2 } as never) - vi.mocked(db.programmeTemplateServiceRoleAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) + vi.mocked(db.eventTemplate.create).mockResolvedValue(duplicated as never) + vi.mocked(db.templatePartAllowedRole.createMany).mockResolvedValue({ count: 2 } as never) + vi.mocked(db.templateServicePartAllowedRole.createMany).mockResolvedValue({ count: 1 } as never) await duplicateTemplate(db, 5, 7) // Speaker role for the first part - expect(vi.mocked(db.programmeTemplatePartAllowedRole.createMany)).toHaveBeenCalledWith({ + expect(vi.mocked(db.templatePartAllowedRole.createMany)).toHaveBeenCalledWith({ data: [{ partId: 510, roleId: 100, asKind: 'speaker', congregationId: 7 }], skipDuplicates: true, }) // Reader role for the first part - expect(vi.mocked(db.programmeTemplatePartAllowedRole.createMany)).toHaveBeenCalledWith({ + expect(vi.mocked(db.templatePartAllowedRole.createMany)).toHaveBeenCalledWith({ data: [{ partId: 510, roleId: 200, asKind: 'reader', congregationId: 7 }], skipDuplicates: true, }) // Service-role allowed-roles for the first service role - expect(vi.mocked(db.programmeTemplateServiceRoleAllowedRole.createMany)).toHaveBeenCalledWith({ - data: [{ serviceRoleId: 520, roleId: 300, congregationId: 7 }], + expect(vi.mocked(db.templateServicePartAllowedRole.createMany)).toHaveBeenCalledWith({ + data: [{ servicePartId: 520, roleId: 300, congregationId: 7 }], skipDuplicates: true, }) // Empty lists are skipped - expect(vi.mocked(db.programmeTemplatePartAllowedRole.createMany)).toHaveBeenCalledTimes(2) - expect(vi.mocked(db.programmeTemplateServiceRoleAllowedRole.createMany)).toHaveBeenCalledTimes(1) + expect(vi.mocked(db.templatePartAllowedRole.createMany)).toHaveBeenCalledTimes(2) + expect(vi.mocked(db.templateServicePartAllowedRole.createMany)).toHaveBeenCalledTimes(1) }) // A duplicated template must carry the source's per-part role labels; without @@ -554,19 +554,19 @@ describe('duplicateTemplate', () => { allowedRoles: [], }, ], - serviceRoles: [], + serviceParts: [], } - vi.mocked(db.programmeTemplate.findFirst).mockResolvedValue(source as never) - vi.mocked(db.programmeTemplate.create).mockResolvedValue({ + vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(source as never) + vi.mocked(db.eventTemplate.create).mockResolvedValue({ id: 99, name: 'Reunion (copie)', parts: [], - serviceRoles: [], + serviceParts: [], } as never) await duplicateTemplate(db, 5, 7) - const createCall = vi.mocked(db.programmeTemplate.create).mock.calls[0][0] as { + const createCall = vi.mocked(db.eventTemplate.create).mock.calls[0][0] as { data: { parts: { create: Array<{ speakerLabel: string | null; readerLabel: string | null }> } } } const createdParts = createCall.data.parts.create diff --git a/app/features/events/server/programme-templates.server.ts b/app/features/events/server/event-templates.server.ts similarity index 79% rename from app/features/events/server/programme-templates.server.ts rename to app/features/events/server/event-templates.server.ts index e9216a57..e03662c3 100644 --- a/app/features/events/server/programme-templates.server.ts +++ b/app/features/events/server/event-templates.server.ts @@ -1,7 +1,7 @@ -import { isSystemTemplate } from '~/features/events/model/programme-template.type' +import { isSystemTemplate } from '~/features/events/model/event-template.type' import { setTemplatePartAllowedRoles, - setTemplateServiceRoleAllowedRoles, + setTemplateServicePartAllowedRoles, } from '~/features/events/server/allowed-roles.server' import { AuditAction, audit } from '~/shared/domain/audit.server' import type { TransactionClient } from '~/shared/infra/db.server' @@ -12,10 +12,10 @@ const responsibleInclude = { } as const export function getTemplates(db: TransactionClient, congregationId: number) { - return db.programmeTemplate.findMany({ + return db.eventTemplate.findMany({ where: { congregationId }, include: { - _count: { select: { parts: true, serviceRoles: true, events: true } }, + _count: { select: { parts: true, serviceParts: true, events: true } }, responsibles: responsibleInclude, }, orderBy: { name: 'asc' }, @@ -23,11 +23,11 @@ export function getTemplates(db: TransactionClient, congregationId: number) { } export function getTemplateById(db: TransactionClient, templateId: number, congregationId: number) { - return db.programmeTemplate.findFirst({ + return db.eventTemplate.findFirst({ where: { id: templateId, congregationId }, include: { parts: { orderBy: { order: 'asc' } }, - serviceRoles: { orderBy: { name: 'asc' } }, + serviceParts: { orderBy: { name: 'asc' } }, responsibles: responsibleInclude, }, }) @@ -52,14 +52,14 @@ export async function updateTemplate( // the createDayOff / createFreeformEvent lookups. The settings UI already // presents them as read-only except for the colour swatch; this is the // belt-and-suspenders check on the writer. - const existing = await db.programmeTemplate.findFirst({ + const existing = await db.eventTemplate.findFirst({ where: { id: templateId, congregationId }, select: { key: true }, }) const scoped = existing != null && isSystemTemplate(existing.key) ? { color: data.color } : data if (Object.values(scoped).every(v => v === undefined)) return null - return db.programmeTemplate.update({ + return db.eventTemplate.update({ where: { id_congregationId: { id: templateId, congregationId }, }, @@ -104,11 +104,11 @@ export async function upsertTemplatePart( } const part = partData.id - ? await db.programmeTemplatePart.update({ + ? await db.templatePart.update({ where: { id_congregationId: { id: partData.id, congregationId } }, data: baseData, }) - : await db.programmeTemplatePart.create({ + : await db.templatePart.create({ data: { ...baseData, templateId, congregationId }, }) @@ -137,7 +137,7 @@ export async function upsertTemplatePart( action: AuditAction.PartAllowedRolesChanged, congregationId, actorId, - entityType: 'ProgrammeTemplatePart', + entityType: 'TemplatePart', entityId: part.id, metadata: { speaker: speakerDiff, reader: readerDiff }, }) @@ -147,47 +147,47 @@ export async function upsertTemplatePart( } export function deleteTemplatePart(db: TransactionClient, partId: number, congregationId: number) { - return db.programmeTemplatePart.delete({ + return db.templatePart.delete({ where: { id_congregationId: { id: partId, congregationId }, }, }) } -export async function upsertTemplateServiceRole( +export async function upsertTemplateServicePart( db: TransactionClient, templateId: number, roleData: { id?: number; name: string; key: string; allowedRoleIds: number[] }, congregationId: number, actorId: number, ) { - const serviceRole = roleData.id - ? await db.programmeTemplateServiceRole.update({ + const servicePart = roleData.id + ? await db.templateServicePart.update({ where: { id_congregationId: { id: roleData.id, congregationId } }, data: { name: roleData.name, key: roleData.key }, }) - : await db.programmeTemplateServiceRole.create({ + : await db.templateServicePart.create({ data: { name: roleData.name, key: roleData.key, templateId, congregationId }, }) - const diff = await setTemplateServiceRoleAllowedRoles(db, serviceRole.id, roleData.allowedRoleIds, congregationId) + const diff = await setTemplateServicePartAllowedRoles(db, servicePart.id, roleData.allowedRoleIds, congregationId) if (diff.added.length > 0 || diff.removed.length > 0) { audit({ - action: AuditAction.ServiceRoleAllowedRolesChanged, + action: AuditAction.ServicePartAllowedRolesChanged, congregationId, actorId, - entityType: 'ProgrammeTemplateServiceRole', - entityId: serviceRole.id, + entityType: 'TemplateServicePart', + entityId: servicePart.id, metadata: { added: diff.added, removed: diff.removed }, }) } - return serviceRole + return servicePart } -export function deleteTemplateServiceRole(db: TransactionClient, roleId: number, congregationId: number) { - return db.programmeTemplateServiceRole.delete({ +export function deleteTemplateServicePart(db: TransactionClient, roleId: number, congregationId: number) { + return db.templateServicePart.delete({ where: { id_congregationId: { id: roleId, congregationId }, }, @@ -202,7 +202,7 @@ export async function reorderTemplateParts( await db.$executeRawUnsafe('SELECT pg_advisory_xact_lock($1, $2)', 1_000_005, congregationId) for (let i = 0; i < orderedIds.length; i++) { - await db.programmeTemplatePart.update({ + await db.templatePart.update({ where: { id_congregationId: { id: orderedIds[i], congregationId }, }, @@ -217,7 +217,7 @@ export function setTemplateResponsible( userId: number, congregationId: number, ) { - return db.programmeTemplateResponsible.upsert({ + return db.templateResponsible.upsert({ where: { templateId_congregationId: { templateId, congregationId }, }, @@ -231,7 +231,7 @@ export function setTemplateResponsible( } export function removeTemplateResponsible(db: TransactionClient, templateId: number, congregationId: number) { - return db.programmeTemplateResponsible.deleteMany({ + return db.templateResponsible.deleteMany({ where: { templateId, congregationId }, }) } @@ -242,20 +242,20 @@ export function isTemplateResponsible( userId: number, congregationId: number, ) { - return db.programmeTemplateResponsible.findFirst({ + return db.templateResponsible.findFirst({ where: { templateId, userId, congregationId }, }) } export async function duplicateTemplate(db: TransactionClient, templateId: number, congregationId: number) { - const source = await db.programmeTemplate.findFirst({ + const source = await db.eventTemplate.findFirst({ where: { id: templateId, congregationId }, include: { parts: { orderBy: { order: 'asc' }, include: { allowedRoles: true }, }, - serviceRoles: { include: { allowedRoles: true } }, + serviceParts: { include: { allowedRoles: true } }, }, }) if (!source) return null @@ -265,7 +265,7 @@ export async function duplicateTemplate(db: TransactionClient, templateId: numbe // Duplicate button; this is the server-side match. if (isSystemTemplate(source.key)) return null - const duplicated = await db.programmeTemplate.create({ + const duplicated = await db.eventTemplate.create({ data: { name: `${source.name} (copie)`, key: `${source.key}-copy-${Date.now()}`, @@ -288,8 +288,8 @@ export async function duplicateTemplate(db: TransactionClient, templateId: numbe congregationId, })), }, - serviceRoles: { - create: source.serviceRoles.map(role => ({ + serviceParts: { + create: source.serviceParts.map(role => ({ name: role.name, key: `${role.key}-copy-${Date.now()}`, congregationId, @@ -298,12 +298,12 @@ export async function duplicateTemplate(db: TransactionClient, templateId: numbe }, include: { parts: { orderBy: { order: 'asc' } }, - serviceRoles: { orderBy: { name: 'asc' } }, + serviceParts: { orderBy: { name: 'asc' } }, }, }) const sourcePartsByOrder = new Map(source.parts.map(p => [p.order, p])) - const sourceServiceRolesByName = new Map(source.serviceRoles.map(r => [r.name, r])) + const sourceServicePartsByName = new Map(source.serviceParts.map(r => [r.name, r])) for (const newPart of duplicated.parts) { const sourcePart = sourcePartsByOrder.get(newPart.order) @@ -311,24 +311,24 @@ export async function duplicateTemplate(db: TransactionClient, templateId: numbe const speakerRoleIds = sourcePart.allowedRoles.filter(r => r.asKind === 'speaker').map(r => r.roleId) const readerRoleIds = sourcePart.allowedRoles.filter(r => r.asKind === 'reader').map(r => r.roleId) if (speakerRoleIds.length > 0) { - await db.programmeTemplatePartAllowedRole.createMany({ + await db.templatePartAllowedRole.createMany({ data: speakerRoleIds.map(roleId => ({ partId: newPart.id, roleId, asKind: 'speaker', congregationId })), skipDuplicates: true, }) } if (readerRoleIds.length > 0) { - await db.programmeTemplatePartAllowedRole.createMany({ + await db.templatePartAllowedRole.createMany({ data: readerRoleIds.map(roleId => ({ partId: newPart.id, roleId, asKind: 'reader', congregationId })), skipDuplicates: true, }) } } - for (const newRole of duplicated.serviceRoles) { - const sourceRole = sourceServiceRolesByName.get(newRole.name) + for (const newRole of duplicated.serviceParts) { + const sourceRole = sourceServicePartsByName.get(newRole.name) if (!sourceRole || sourceRole.allowedRoles.length === 0) continue - await db.programmeTemplateServiceRoleAllowedRole.createMany({ - data: sourceRole.allowedRoles.map(r => ({ serviceRoleId: newRole.id, roleId: r.roleId, congregationId })), + await db.templateServicePartAllowedRole.createMany({ + data: sourceRole.allowedRoles.map(r => ({ servicePartId: newRole.id, roleId: r.roleId, congregationId })), skipDuplicates: true, }) } diff --git a/app/features/events/server/programme-auth.server.integration.test.ts b/app/features/events/server/events-auth.server.integration.test.ts similarity index 94% rename from app/features/events/server/programme-auth.server.integration.test.ts rename to app/features/events/server/events-auth.server.integration.test.ts index 80bde9a8..23fb4333 100644 --- a/app/features/events/server/programme-auth.server.integration.test.ts +++ b/app/features/events/server/events-auth.server.integration.test.ts @@ -35,7 +35,7 @@ const allowAll = (_p: Permission) => true const allowNone = (_p: Permission) => false const allowOnly = (allowed: Permission) => (p: Permission) => p === allowed -const { canEditEvent, getResponsibleTemplateIds, canManageAnyProgram } = await import('./programme-auth.server') +const { canEditEvent, getResponsibleTemplateIds, canManageAnyProgram } = await import('./events-auth.server') beforeAll(async () => { const primary = await testDb.congregation.create({ @@ -85,17 +85,17 @@ beforeAll(async () => { }) plainId = plain.id - const owned = await tx.programmeTemplate.create({ + const owned = await tx.eventTemplate.create({ data: { name: 'Owned Template', key: `owned-${ts}`, congregationId: primaryCongId }, }) templateOwnedId = owned.id - const otherTpl = await tx.programmeTemplate.create({ + const otherTpl = await tx.eventTemplate.create({ data: { name: 'Other Template', key: `other-${ts}`, congregationId: primaryCongId }, }) templateOtherId = otherTpl.id - await tx.programmeTemplateResponsible.create({ + await tx.templateResponsible.create({ data: { templateId: templateOwnedId, userId: responsibleId, congregationId: primaryCongId }, }) }) @@ -113,12 +113,12 @@ beforeAll(async () => { }) otherCongResponsibleId = otherCongResp.id - const otherCongTemplate = await tx.programmeTemplate.create({ + const otherCongTemplate = await tx.eventTemplate.create({ data: { name: 'Foreign Template', key: `foreign-${ts}`, congregationId: otherCongId }, }) templateOtherCongId = otherCongTemplate.id - await tx.programmeTemplateResponsible.create({ + await tx.templateResponsible.create({ data: { templateId: templateOtherCongId, userId: otherCongResponsibleId, congregationId: otherCongId }, }) }) @@ -128,8 +128,8 @@ afterAll(async () => { for (const congId of [primaryCongId, otherCongId]) { if (!congId) continue await withScope(congId, async tx => { - await tx.programmeTemplateResponsible.deleteMany({}) - await tx.programmeTemplate.deleteMany({}) + await tx.templateResponsible.deleteMany({}) + await tx.eventTemplate.deleteMany({}) await tx.userAccount.deleteMany({}) }) } diff --git a/app/features/events/server/programme-auth.server.test.ts b/app/features/events/server/events-auth.server.test.ts similarity index 85% rename from app/features/events/server/programme-auth.server.test.ts rename to app/features/events/server/events-auth.server.test.ts index be97debd..47658f41 100644 --- a/app/features/events/server/programme-auth.server.test.ts +++ b/app/features/events/server/events-auth.server.test.ts @@ -3,13 +3,13 @@ import { Permission } from '~/shared/types/permission' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { - programmeTemplateResponsible: { findFirst: vi.fn(), findMany: vi.fn() }, + templateResponsible: { findFirst: vi.fn(), findMany: vi.fn() }, event: { findMany: vi.fn() }, }, })) const { canEditEvent, getResponsibleTemplateIds, canManageAnyProgram, filterToManageableEventIds } = await import( - './programme-auth.server' + './events-auth.server' ) const { unscopedDb: db } = await import('~/shared/infra/db.server') @@ -38,7 +38,7 @@ describe('canEditEvent', () => { }) it('returns true for non-manager when responsible record exists', async () => { - vi.mocked(db.programmeTemplateResponsible.findFirst).mockResolvedValue({ + vi.mocked(db.templateResponsible.findFirst).mockResolvedValue({ id: 1, templateId: TEMPLATE_ID_OWNED, userId: USER_ID, @@ -50,7 +50,7 @@ describe('canEditEvent', () => { }) it('returns false for non-manager when no responsible record exists', async () => { - vi.mocked(db.programmeTemplateResponsible.findFirst).mockResolvedValue(null as never) + vi.mocked(db.templateResponsible.findFirst).mockResolvedValue(null as never) const result = await canEditEvent(db, allowNone, USER_ID, TEMPLATE_ID_OTHER, CONGREGATION_ID) expect(result).toBe(false) @@ -59,14 +59,14 @@ describe('canEditEvent', () => { describe('getResponsibleTemplateIds', () => { it('returns an empty array when the user is responsible for nothing', async () => { - vi.mocked(db.programmeTemplateResponsible.findMany).mockResolvedValue([] as never) + vi.mocked(db.templateResponsible.findMany).mockResolvedValue([] as never) const result = await getResponsibleTemplateIds(db, USER_ID, CONGREGATION_ID) expect(result).toEqual([]) }) it('returns the list of templateIds for the user', async () => { - vi.mocked(db.programmeTemplateResponsible.findMany).mockResolvedValue([ + vi.mocked(db.templateResponsible.findMany).mockResolvedValue([ { templateId: TEMPLATE_ID_OWNED }, { templateId: TEMPLATE_ID_OTHER }, ] as never) @@ -83,14 +83,14 @@ describe('canManageAnyProgram', () => { }) it('returns true for non-manager when at least one responsible record exists', async () => { - vi.mocked(db.programmeTemplateResponsible.findMany).mockResolvedValue([{ templateId: TEMPLATE_ID_OWNED }] as never) + vi.mocked(db.templateResponsible.findMany).mockResolvedValue([{ templateId: TEMPLATE_ID_OWNED }] as never) const result = await canManageAnyProgram(db, allowNone, USER_ID, CONGREGATION_ID) expect(result).toBe(true) }) it('returns false for non-manager when no responsible records exist', async () => { - vi.mocked(db.programmeTemplateResponsible.findMany).mockResolvedValue([] as never) + vi.mocked(db.templateResponsible.findMany).mockResolvedValue([] as never) const result = await canManageAnyProgram(db, allowNone, USER_ID, CONGREGATION_ID) expect(result).toBe(false) @@ -105,7 +105,7 @@ describe('filterToManageableEventIds', () => { const result = await filterToManageableEventIds(db, allowAll, [], USER_ID, CONGREGATION_ID) expect(result).toEqual([]) expect(db.event.findMany).not.toHaveBeenCalled() - expect(db.programmeTemplateResponsible.findMany).not.toHaveBeenCalled() + expect(db.templateResponsible.findMany).not.toHaveBeenCalled() }) // Manager path still validates ids belong to the congregation — a request @@ -131,7 +131,7 @@ describe('filterToManageableEventIds', () => { // responsibility set. Freeform events (templateId=null) are dropped — // consistent with canEditEvent's freeform-events-are-manager-only rule. it('keeps only events on templates the user is responsible for (non-manager)', async () => { - vi.mocked(db.programmeTemplateResponsible.findMany).mockResolvedValue([{ templateId: TEMPLATE_ID_OWNED }] as never) + vi.mocked(db.templateResponsible.findMany).mockResolvedValue([{ templateId: TEMPLATE_ID_OWNED }] as never) vi.mocked(db.event.findMany).mockResolvedValue([ { id: 10, templateId: TEMPLATE_ID_OWNED }, { id: 11, templateId: TEMPLATE_ID_OTHER }, @@ -144,7 +144,7 @@ describe('filterToManageableEventIds', () => { }) it('returns an empty list when the non-manager has no template responsibilities', async () => { - vi.mocked(db.programmeTemplateResponsible.findMany).mockResolvedValue([] as never) + vi.mocked(db.templateResponsible.findMany).mockResolvedValue([] as never) vi.mocked(db.event.findMany).mockResolvedValue([{ id: 10, templateId: TEMPLATE_ID_OWNED }] as never) const result = await filterToManageableEventIds(db, allowNone, [10], USER_ID, CONGREGATION_ID) diff --git a/app/features/events/server/programme-auth.server.ts b/app/features/events/server/events-auth.server.ts similarity index 96% rename from app/features/events/server/programme-auth.server.ts rename to app/features/events/server/events-auth.server.ts index 99ca82c9..2d5a4307 100644 --- a/app/features/events/server/programme-auth.server.ts +++ b/app/features/events/server/events-auth.server.ts @@ -1,9 +1,9 @@ -import { isTemplateResponsible } from '~/features/events/server/programme-templates.server' +import { isTemplateResponsible } from '~/features/events/server/event-templates.server' import type { TransactionClient } from '~/shared/infra/db.server' import { createLogger } from '~/shared/infra/logger.server' import { Permission } from '~/shared/types/permission' -const logger = createLogger('programme-auth') +const logger = createLogger('events-auth') export async function canEditEvent( db: TransactionClient, @@ -23,7 +23,7 @@ export async function getResponsibleTemplateIds( userId: number, congregationId: number, ): Promise { - const rows = await db.programmeTemplateResponsible.findMany({ + const rows = await db.templateResponsible.findMany({ where: { userId, congregationId }, select: { templateId: true }, }) diff --git a/app/features/events/server/external-speakers.server.test.ts b/app/features/events/server/external-speakers.server.test.ts index 634a2fc3..01e12b2a 100644 --- a/app/features/events/server/external-speakers.server.test.ts +++ b/app/features/events/server/external-speakers.server.test.ts @@ -17,7 +17,7 @@ const mockDb = { create: vi.fn(), update: vi.fn(), }, - programmePartAssignment: { + eventPart: { findMany: vi.fn(), }, } @@ -141,7 +141,7 @@ describe('listExternalSpeakers', () => { email: null, notes: null, archivedAt: null, - partAssignments: [{ event: { startDate: lastDate } }], + eventParts: [{ event: { startDate: lastDate } }], }, ]) @@ -159,7 +159,7 @@ describe('listExternalSpeakers', () => { email: null, notes: null, archivedAt: null, - partAssignments: [], + eventParts: [], }, ]) @@ -189,7 +189,7 @@ describe('getExternalSpeaker', () => { name: 'Pierre', congregationName: 'Marseille', }) - mockDb.programmePartAssignment.findMany.mockResolvedValue([ + mockDb.eventPart.findMany.mockResolvedValue([ { name: 'Discours public', topic: 'Foi', event: { startDate: new Date('2025-09-01') } }, ]) diff --git a/app/features/events/server/external-speakers.server.ts b/app/features/events/server/external-speakers.server.ts index 0787285c..643ebc56 100644 --- a/app/features/events/server/external-speakers.server.ts +++ b/app/features/events/server/external-speakers.server.ts @@ -47,7 +47,7 @@ export async function listExternalSpeakers( : {}), }, include: { - partAssignments: { + eventParts: { where: { event: { startDate: { lt: new Date() } } }, select: { event: { select: { startDate: true } } }, orderBy: { event: { startDate: 'desc' } }, @@ -65,7 +65,7 @@ export async function listExternalSpeakers( email: speaker.email, notes: speaker.notes, archivedAt: speaker.archivedAt, - lastVisitDate: speaker.partAssignments[0]?.event.startDate ?? null, + lastVisitDate: speaker.eventParts[0]?.event.startDate ?? null, })) } @@ -75,7 +75,7 @@ export async function getExternalSpeaker(db: TransactionClient, id: number, cong }) if (!speaker) return null - const recentHistory = await db.programmePartAssignment.findMany({ + const recentHistory = await db.eventPart.findMany({ where: { congregationId, externalSpeakerId: id, diff --git a/app/features/events/server/list-external-speaker-cadence.server.test.ts b/app/features/events/server/list-external-speaker-cadence.server.test.ts index ad749318..c2f3e636 100644 --- a/app/features/events/server/list-external-speaker-cadence.server.test.ts +++ b/app/features/events/server/list-external-speaker-cadence.server.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { event: { findMany: vi.fn() }, - programmePartAssignment: { findMany: vi.fn().mockResolvedValue([]) }, + eventPart: { findMany: vi.fn().mockResolvedValue([]) }, }, })) @@ -24,7 +24,7 @@ const DEFAULT_ARGS = { beforeEach(() => { vi.resetAllMocks() vi.mocked(db.event.findMany).mockResolvedValue([] as never) - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([] as never) }) describe('listExternalSpeakerCadence — event window queries', () => { @@ -77,7 +77,7 @@ describe('listExternalSpeakerCadence — assigned + personName', () => { id: 1, startDate: new Date('2026-04-01'), status: 'released', - partAssignments: [ + eventParts: [ { name: 'Discours public', section: 'Culte', @@ -104,7 +104,7 @@ describe('listExternalSpeakerCadence — assigned + personName', () => { id: 1, startDate: new Date('2026-04-01'), status: 'released', - partAssignments: [ + eventParts: [ { name: 'Discours public', section: 'Culte', @@ -131,7 +131,7 @@ describe('listExternalSpeakerCadence — assigned + personName', () => { id: 1, startDate: new Date('2026-04-01'), status: 'released', - partAssignments: [ + eventParts: [ { name: 'Discours public', section: 'Culte', @@ -163,7 +163,7 @@ describe('listExternalSpeakerCadence — assigned + personName', () => { id: 1, startDate: new Date('2026-04-01'), status: 'released', - partAssignments: [ + eventParts: [ { name: 'Discours public', section: 'Culte', @@ -185,7 +185,7 @@ describe('listExternalSpeakerCadence — assigned + personName', () => { it('returns personName=null when the event has no matching part assignment', async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), status: 'released', partAssignments: [] }, + { id: 1, startDate: new Date('2026-04-01'), status: 'released', eventParts: [] }, ] as never) .mockResolvedValueOnce([] as never) @@ -199,9 +199,7 @@ describe('listExternalSpeakerCadence — assigned + personName', () => { // don't accidentally coerce past status to 'released'. it("propagates event.status as 'draft' when the past row is a draft", async () => { vi.mocked(db.event.findMany) - .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), status: 'draft', partAssignments: [] }, - ] as never) + .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-04-01'), status: 'draft', eventParts: [] }] as never) .mockResolvedValueOnce([] as never) const result = await listExternalSpeakerCadence(db, DEFAULT_ARGS) @@ -212,7 +210,7 @@ describe('listExternalSpeakerCadence — assigned + personName', () => { it("bucket unknown Event.status values as 'released' (fallback contract)", async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), status: 'cancelled', partAssignments: [] }, + { id: 1, startDate: new Date('2026-04-01'), status: 'cancelled', eventParts: [] }, ] as never) .mockResolvedValueOnce([] as never) @@ -224,9 +222,7 @@ describe('listExternalSpeakerCadence — assigned + personName', () => { it("propagates event.status as 'draft' when the future row is a draft", async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([] as never) - .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-08-01'), status: 'draft', partAssignments: [] }, - ] as never) + .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-08-01'), status: 'draft', eventParts: [] }] as never) const result = await listExternalSpeakerCadence(db, DEFAULT_ARGS) @@ -242,7 +238,7 @@ describe('listExternalSpeakerCadence — anchor matching + hasHistory', () => { id: 1, startDate: new Date('2026-04-01'), status: 'released', - partAssignments: [ + eventParts: [ { name: 'Cantique', section: 'Culte', @@ -268,7 +264,7 @@ describe('listExternalSpeakerCadence — anchor matching + hasHistory', () => { id: 1, startDate: new Date('2026-04-01'), status: 'released', - partAssignments: [ + eventParts: [ { name: ' DISCOURS PUBLIC ', section: 'culte', @@ -287,17 +283,15 @@ describe('listExternalSpeakerCadence — anchor matching + hasHistory', () => { expect(result.past[0].assigned).toBe(true) }) - it('hasHistory query filters programmePartAssignment on the external speaker id', async () => { + it('hasHistory query filters eventPart on the external speaker id', async () => { await listExternalSpeakerCadence(db, DEFAULT_ARGS) - const call = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0] + const call = vi.mocked(db.eventPart.findMany).mock.calls[0][0] expect(call?.where).toMatchObject({ externalSpeakerId: 42 }) }) it('returns hasHistory=true when the speaker held the same-name-and-section slot at some point in the past', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ - { name: 'Discours public', section: 'Culte' }, - ] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([{ name: 'Discours public', section: 'Culte' }] as never) const result = await listExternalSpeakerCadence(db, DEFAULT_ARGS) @@ -305,7 +299,7 @@ describe('listExternalSpeakerCadence — anchor matching + hasHistory', () => { }) it('hasHistory ignores rows whose normalized name/section do not match', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { name: 'Prière', section: 'Culte' }, { name: 'Discours public', section: 'Assembly' }, ] as never) diff --git a/app/features/events/server/list-external-speaker-cadence.server.ts b/app/features/events/server/list-external-speaker-cadence.server.ts index 928f6f3a..92a9100a 100644 --- a/app/features/events/server/list-external-speaker-cadence.server.ts +++ b/app/features/events/server/list-external-speaker-cadence.server.ts @@ -42,7 +42,7 @@ export async function listExternalSpeakerCadence( } as const const commonWhere = { templateId: event.templateId, congregationId } as const - const rowSelect = { id: true, startDate: true, status: true, partAssignments: partsSelect } as const + const rowSelect = { id: true, startDate: true, status: true, eventParts: partsSelect } as const const [pastRows, futureRows, historicalAssignments] = await Promise.all([ db.event.findMany({ @@ -57,7 +57,7 @@ export async function listExternalSpeakerCadence( take: futureCount, select: rowSelect, }), - db.programmePartAssignment.findMany({ + db.eventPart.findMany({ where: { event: { ...commonWhere, startDate: { lt: event.startDate } }, externalSpeakerId, @@ -68,7 +68,7 @@ export async function listExternalSpeakerCadence( const targetName = normalize(partName) const targetSection = normalize(partSection) - type PartRow = (typeof pastRows)[number]['partAssignments'][number] + type PartRow = (typeof pastRows)[number]['eventParts'][number] // Prefer the external speaker on this row even if their name is empty — // the FK is the identity signal, not the display string. Fall back to // the in-house assignee for rows where the slot was covered internally. @@ -78,7 +78,7 @@ export async function listExternalSpeakerCadence( return null } const toEntry = (row: (typeof pastRows)[number]): CadenceEntry => { - const matches = row.partAssignments.filter( + const matches = row.eventParts.filter( p => normalize(p.name) === targetName && normalize(p.section) === targetSection, ) const person = matches.map(nameOf).find(n => n != null) diff --git a/app/features/events/server/list-user-cadence.server.test.ts b/app/features/events/server/list-user-cadence.server.test.ts index b1bbfc00..9b9228a6 100644 --- a/app/features/events/server/list-user-cadence.server.test.ts +++ b/app/features/events/server/list-user-cadence.server.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { event: { findMany: vi.fn() }, - programmePartAssignment: { findMany: vi.fn().mockResolvedValue([]) }, + eventPart: { findMany: vi.fn().mockResolvedValue([]) }, }, })) @@ -25,7 +25,7 @@ const DEFAULT_ARGS = { beforeEach(() => { vi.resetAllMocks() vi.mocked(db.event.findMany).mockResolvedValue([] as never) - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([] as never) }) describe('listUserCadence', () => { @@ -78,9 +78,9 @@ describe('listUserCadence', () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ // Prisma returned newest-first (desc) - { id: 3, startDate: new Date('2026-06-01'), partAssignments: [] }, - { id: 2, startDate: new Date('2026-05-01'), partAssignments: [] }, - { id: 1, startDate: new Date('2026-04-01'), partAssignments: [] }, + { id: 3, startDate: new Date('2026-06-01'), eventParts: [] }, + { id: 2, startDate: new Date('2026-05-01'), eventParts: [] }, + { id: 1, startDate: new Date('2026-04-01'), eventParts: [] }, ] as never) .mockResolvedValueOnce([] as never) @@ -99,7 +99,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 5, assistantId: null }], + eventParts: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 5, assistantId: null }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -115,7 +115,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 99, assistantId: 5 }], + eventParts: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 99, assistantId: 5 }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -134,7 +134,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 99, assistantId: 5 }], + eventParts: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 99, assistantId: 5 }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -150,7 +150,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 5, assistantId: null }], + eventParts: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 5, assistantId: null }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -166,7 +166,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 99, assistantId: 42 }], + eventParts: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 99, assistantId: 42 }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -178,7 +178,7 @@ describe('listUserCadence', () => { it('marks assigned=false when the event has no matching part assignment', async () => { vi.mocked(db.event.findMany) - .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-04-01'), partAssignments: [] }] as never) + .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-04-01'), eventParts: [] }] as never) .mockResolvedValueOnce([] as never) const result = await listUserCadence(db, DEFAULT_ARGS) @@ -192,7 +192,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [ + eventParts: [ { name: 'Song', section: 'Ministry', assigneeId: 5, assistantId: null }, { name: 'Bible Reading', section: 'Ministry', assigneeId: 99, assistantId: null }, ], @@ -211,7 +211,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [ + eventParts: [ // Same name, different section — must NOT feed the cadence. { name: 'Bible Reading', section: 'Weekend', assigneeId: 5, assistantId: null }, ], @@ -230,7 +230,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 5, assistantId: null }], + eventParts: [{ name: 'Bible Reading', section: 'Ministry', assigneeId: 5, assistantId: null }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -246,7 +246,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [{ name: ' Bible Reading ', section: ' Ministry ', assigneeId: 5, assistantId: null }], + eventParts: [{ name: ' Bible Reading ', section: ' Ministry ', assigneeId: 5, assistantId: null }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -262,7 +262,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [{ name: 'bible reading', section: 'MINISTRY', assigneeId: 5, assistantId: null }], + eventParts: [{ name: 'bible reading', section: 'MINISTRY', assigneeId: 5, assistantId: null }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -278,7 +278,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [{ name: 'Bible Reading', section: 'ministere', assigneeId: 5, assistantId: null }], + eventParts: [{ name: 'Bible Reading', section: 'ministere', assigneeId: 5, assistantId: null }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -297,7 +297,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [ + eventParts: [ { name: 'Bible Reading', section: 'Ministry', @@ -313,7 +313,7 @@ describe('listUserCadence', () => { { id: 2, startDate: new Date('2026-08-01'), - partAssignments: [ + eventParts: [ { name: 'Bible Reading', section: 'Ministry', @@ -345,7 +345,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [ + eventParts: [ { name: 'Bible Reading', section: 'Ministry', @@ -370,7 +370,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [ + eventParts: [ { name: 'Bible Reading', section: 'Ministry', @@ -391,7 +391,7 @@ describe('listUserCadence', () => { it('returns personName=null when no matching part assignment exists on the historical event', async () => { vi.mocked(db.event.findMany) - .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-04-01'), partAssignments: [] }] as never) + .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-04-01'), eventParts: [] }] as never) .mockResolvedValueOnce([] as never) const result = await listUserCadence(db, DEFAULT_ARGS) @@ -400,7 +400,7 @@ describe('listUserCadence', () => { }) it('returns hasHistory=false when the person has never been on the slot at any past template instance', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([] as never) const result = await listUserCadence(db, DEFAULT_ARGS) @@ -408,9 +408,7 @@ describe('listUserCadence', () => { }) it('returns hasHistory=true when the person appears on the matching slot in a historical row (any age)', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ - { name: 'Bible Reading', section: 'Ministry' }, - ] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([{ name: 'Bible Reading', section: 'Ministry' }] as never) const result = await listUserCadence(db, DEFAULT_ARGS) @@ -418,7 +416,7 @@ describe('listUserCadence', () => { }) it('hasHistory ignores rows whose normalized name/section do not match the anchor', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { name: 'Song', section: 'Ministry' }, { name: 'Bible Reading', section: 'Weekend' }, ] as never) @@ -428,19 +426,17 @@ describe('listUserCadence', () => { expect(result.hasHistory).toBe(false) }) - it('hasHistory query filters programmePartAssignment by the slot-matching FK', async () => { + it('hasHistory query filters eventPart by the slot-matching FK', async () => { await listUserCadence(db, DEFAULT_ARGS) - const call = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0] + const call = vi.mocked(db.eventPart.findMany).mock.calls[0][0] expect(call?.where).toMatchObject({ assigneeId: 5 }) expect(call?.where).not.toHaveProperty('assistantId') }) it("propagates event.status as 'draft' when the past row is a draft", async () => { vi.mocked(db.event.findMany) - .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), status: 'draft', partAssignments: [] }, - ] as never) + .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-04-01'), status: 'draft', eventParts: [] }] as never) .mockResolvedValueOnce([] as never) const result = await listUserCadence(db, DEFAULT_ARGS) @@ -451,7 +447,7 @@ describe('listUserCadence', () => { it("buckets unknown Event.status values as 'released' (fallback contract)", async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), status: 'cancelled', partAssignments: [] }, + { id: 1, startDate: new Date('2026-04-01'), status: 'cancelled', eventParts: [] }, ] as never) .mockResolvedValueOnce([] as never) @@ -463,9 +459,7 @@ describe('listUserCadence', () => { it("propagates event.status as 'draft' when the future row is a draft", async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([] as never) - .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-08-01'), status: 'draft', partAssignments: [] }, - ] as never) + .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-08-01'), status: 'draft', eventParts: [] }] as never) const result = await listUserCadence(db, DEFAULT_ARGS) @@ -475,7 +469,7 @@ describe('listUserCadence', () => { it("hasHistory query filters on assistantId when slot='assistant'", async () => { await listUserCadence(db, { ...DEFAULT_ARGS, slot: 'assistant' }) - const call = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0] + const call = vi.mocked(db.eventPart.findMany).mock.calls[0][0] expect(call?.where).toMatchObject({ assistantId: 5 }) expect(call?.where).not.toHaveProperty('assigneeId') }) @@ -486,7 +480,7 @@ describe('listUserCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - partAssignments: [ + eventParts: [ { name: 'Bible Reading', section: 'Ministry', diff --git a/app/features/events/server/list-user-cadence.server.ts b/app/features/events/server/list-user-cadence.server.ts index bea49a5f..ae6df2cc 100644 --- a/app/features/events/server/list-user-cadence.server.ts +++ b/app/features/events/server/list-user-cadence.server.ts @@ -54,7 +54,7 @@ export async function listUserCadence( } as const const commonWhere = { templateId: event.templateId, congregationId } as const - const rowSelect = { id: true, startDate: true, status: true, partAssignments: partsSelect } as const + const rowSelect = { id: true, startDate: true, status: true, eventParts: partsSelect } as const const [pastRows, futureRows, historicalAssignments] = await Promise.all([ db.event.findMany({ @@ -72,7 +72,7 @@ export async function listUserCadence( // Aggregate over ALL past events (not just the visible window) to detect // "used to do this slot, hasn't recently" candidates. Filtered in JS to // reuse the same normalized-name/section comparison. - db.programmePartAssignment.findMany({ + db.eventPart.findMany({ where: { event: { ...commonWhere, startDate: { lt: event.startDate } }, ...(slot === 'assignee' ? { assigneeId: userId } : { assistantId: userId }), @@ -83,12 +83,12 @@ export async function listUserCadence( const targetName = normalize(partName) const targetSection = normalize(partSection) - type PartRow = (typeof pastRows)[number]['partAssignments'][number] + type PartRow = (typeof pastRows)[number]['eventParts'][number] const isOnSlot = slot === 'assignee' ? (p: PartRow) => p.assigneeId === userId : (p: PartRow) => p.assistantId === userId const personOnSlot = (p: PartRow) => (slot === 'assignee' ? p.assignee : p.assistant) const toEntry = (row: (typeof pastRows)[number]): CadenceEntry => { - const matches = row.partAssignments.filter( + const matches = row.eventParts.filter( p => normalize(p.name) === targetName && normalize(p.section) === targetSection, ) const person = matches.map(personOnSlot).find(p => p != null) 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 index 9ea853bf..150a30e6 100644 --- 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 @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { - programmePartAssignment: { findMany: vi.fn() }, - programmeServiceRoleAssignment: { findMany: vi.fn() }, + eventPart: { findMany: vi.fn() }, + eventServicePart: { findMany: vi.fn() }, }, })) @@ -12,8 +12,8 @@ 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) + vi.mocked(db.eventPart.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] as never) }) describe('listUserConflictsInRange', () => { @@ -33,7 +33,7 @@ describe('listUserConflictsInRange', () => { await listUserConflictsInRange(db, memberId, start, end) - const call = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0] + const call = vi.mocked(db.eventPart.findMany).mock.calls[0][0] const where = call?.where as Record expect(where.hasConflict).toBe(true) expect(where.OR).toEqual([{ assigneeId: memberId }, { assistantId: memberId }]) @@ -44,7 +44,7 @@ describe('listUserConflictsInRange', () => { 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 call = vi.mocked(db.eventServicePart.findMany).mock.calls[0][0] const where = call?.where as Record expect(where.hasConflict).toBe(true) expect(where.assigneeId).toBe(memberId) @@ -52,7 +52,7 @@ describe('listUserConflictsInRange', () => { }) it('resolves the responsible name via accountDisplayName when a template responsible exists', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { name: 'Discours public', event: { @@ -75,7 +75,7 @@ describe('listUserConflictsInRange', () => { // 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([ + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([ { name: 'Micros', event: { @@ -101,7 +101,7 @@ describe('listUserConflictsInRange', () => { }) it('returns responsibleName as null for untemplated events', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { name: 'Custom part', event: { @@ -120,7 +120,7 @@ describe('listUserConflictsInRange', () => { // 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([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { name: 'Prière', event: { @@ -139,7 +139,7 @@ describe('listUserConflictsInRange', () => { // 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([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { name: 'Discours public', event: { @@ -160,7 +160,7 @@ describe('listUserConflictsInRange', () => { }) it('merges part + service conflicts and sorts by eventDate ascending', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventPart.findMany).mockResolvedValue([ { name: 'Later part', event: { startDate: new Date(2026, 6, 10), template: null }, @@ -170,7 +170,7 @@ describe('listUserConflictsInRange', () => { event: { startDate: new Date(2026, 6, 2), template: null }, }, ] as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([ + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([ { name: 'Middle service', event: { startDate: new Date(2026, 6, 5), template: null }, 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 index ca3baeeb..ed928dcb 100644 --- a/app/features/events/server/list-user-conflicts-in-range.server.ts +++ b/app/features/events/server/list-user-conflicts-in-range.server.ts @@ -42,7 +42,7 @@ export async function listUserConflictsInRange( endDate: Date, ): Promise { const [partConflicts, serviceConflicts] = await Promise.all([ - db.programmePartAssignment.findMany({ + db.eventPart.findMany({ where: { hasConflict: true, OR: [{ assigneeId: memberId }, { assistantId: memberId }], @@ -56,7 +56,7 @@ export async function listUserConflictsInRange( event: { select: eventWithResponsiblesSelect }, }, }), - db.programmeServiceRoleAssignment.findMany({ + db.eventServicePart.findMany({ where: { hasConflict: true, assigneeId: memberId, diff --git a/app/features/events/server/list-user-same-event-assignments.server.test.ts b/app/features/events/server/list-user-same-event-assignments.server.test.ts index f782008b..78132f2a 100644 --- a/app/features/events/server/list-user-same-event-assignments.server.test.ts +++ b/app/features/events/server/list-user-same-event-assignments.server.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { - programmePartAssignment: { findMany: vi.fn() }, - programmeServiceRoleAssignment: { findMany: vi.fn() }, + eventPart: { findMany: vi.fn() }, + eventServicePart: { findMany: vi.fn() }, }, })) @@ -12,8 +12,8 @@ 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) + vi.mocked(db.eventPart.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] as never) }) describe('listUserSameEventAssignments', () => { @@ -25,14 +25,14 @@ describe('listUserSameEventAssignments', () => { excludePartAssignmentId: 42, }) - const call = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0] + const call = vi.mocked(db.eventPart.findMany).mock.calls[0][0] expect(call?.where).toMatchObject({ id: { not: 42 } }) }) it('does not filter the part query when excludePartAssignmentId is omitted', async () => { await listUserSameEventAssignments(db, { userId: 5, eventId: 10, congregationId: 1 }) - const call = vi.mocked(db.programmePartAssignment.findMany).mock.calls[0][0] + const call = vi.mocked(db.eventPart.findMany).mock.calls[0][0] expect(call?.where).not.toHaveProperty('id') }) @@ -44,22 +44,20 @@ describe('listUserSameEventAssignments', () => { excludeServiceAssignmentId: 77, }) - const call = vi.mocked(db.programmeServiceRoleAssignment.findMany).mock.calls[0][0] + const call = vi.mocked(db.eventServicePart.findMany).mock.calls[0][0] expect(call?.where).toMatchObject({ id: { not: 77 } }) }) it('does not filter the service query when excludeServiceAssignmentId is omitted', async () => { await listUserSameEventAssignments(db, { userId: 5, eventId: 10, congregationId: 1 }) - const call = vi.mocked(db.programmeServiceRoleAssignment.findMany).mock.calls[0][0] + const call = vi.mocked(db.eventServicePart.findMany).mock.calls[0][0] expect(call?.where).not.toHaveProperty('id') }) it('returns parts tagged "part" and services tagged "service", parts first', async () => { - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([ - { id: 1, name: 'Reading', section: 'Ministry' }, - ] as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([{ id: 2, name: 'Sound' }] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([{ id: 1, name: 'Reading', section: 'Ministry' }] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([{ id: 2, name: 'Sound' }] as never) const result = await listUserSameEventAssignments(db, { userId: 5, eventId: 10, congregationId: 1 }) diff --git a/app/features/events/server/list-user-same-event-assignments.server.ts b/app/features/events/server/list-user-same-event-assignments.server.ts index fb562c0f..f8bbfa3a 100644 --- a/app/features/events/server/list-user-same-event-assignments.server.ts +++ b/app/features/events/server/list-user-same-event-assignments.server.ts @@ -16,7 +16,7 @@ export async function listUserSameEventAssignments( db: TransactionClient, { userId, eventId, congregationId, excludePartAssignmentId, excludeServiceAssignmentId }: Options, ): Promise { - const parts = await db.programmePartAssignment.findMany({ + const parts = await db.eventPart.findMany({ where: { eventId, congregationId, @@ -26,7 +26,7 @@ export async function listUserSameEventAssignments( select: { id: true, name: true, section: true }, }) - const services = await db.programmeServiceRoleAssignment.findMany({ + const services = await db.eventServicePart.findMany({ where: { eventId, congregationId, diff --git a/app/features/events/server/list-user-service-cadence.server.test.ts b/app/features/events/server/list-user-service-cadence.server.test.ts index d7453c75..1ae9fffd 100644 --- a/app/features/events/server/list-user-service-cadence.server.test.ts +++ b/app/features/events/server/list-user-service-cadence.server.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { event: { findMany: vi.fn() }, - programmeServiceRoleAssignment: { findMany: vi.fn().mockResolvedValue([]) }, + eventServicePart: { findMany: vi.fn().mockResolvedValue([]) }, }, })) @@ -15,7 +15,7 @@ const DEFAULT_ARGS = { userId: 5, event: { templateId: 42 as number | null, id: 100, startDate: NOW }, congregationId: 1, - serviceRoleName: 'Sono', + servicePartName: 'Sono', pastCount: 6, futureCount: 6, } @@ -23,7 +23,7 @@ const DEFAULT_ARGS = { beforeEach(() => { vi.resetAllMocks() vi.mocked(db.event.findMany).mockResolvedValue([] as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] as never) }) describe('listUserServiceCadence', () => { @@ -78,9 +78,9 @@ describe('listUserServiceCadence', () => { it('reverses the past query result so entries flow oldest → newest', async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ - { id: 3, startDate: new Date('2026-06-01'), serviceRoleAssignments: [] }, - { id: 2, startDate: new Date('2026-05-01'), serviceRoleAssignments: [] }, - { id: 1, startDate: new Date('2026-04-01'), serviceRoleAssignments: [] }, + { id: 3, startDate: new Date('2026-06-01'), eventServiceParts: [] }, + { id: 2, startDate: new Date('2026-05-01'), eventServiceParts: [] }, + { id: 1, startDate: new Date('2026-04-01'), eventServiceParts: [] }, ] as never) .mockResolvedValueOnce([] as never) @@ -99,7 +99,7 @@ describe('listUserServiceCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - serviceRoleAssignments: [{ name: 'Sono', assigneeId: 5 }], + eventServiceParts: [{ name: 'Sono', assigneeId: 5 }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -115,7 +115,7 @@ describe('listUserServiceCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - serviceRoleAssignments: [{ name: 'Sono', assigneeId: 99 }], + eventServiceParts: [{ name: 'Sono', assigneeId: 99 }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -127,7 +127,7 @@ describe('listUserServiceCadence', () => { it('marks assigned=false when the event has no matching service role assignment', async () => { vi.mocked(db.event.findMany) - .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-04-01'), serviceRoleAssignments: [] }] as never) + .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-04-01'), eventServiceParts: [] }] as never) .mockResolvedValueOnce([] as never) const result = await listUserServiceCadence(db, DEFAULT_ARGS) @@ -141,7 +141,7 @@ describe('listUserServiceCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - serviceRoleAssignments: [ + eventServiceParts: [ { name: 'Sono', assigneeId: 99 }, { name: 'Accueil', assigneeId: 5 }, ], @@ -157,7 +157,7 @@ describe('listUserServiceCadence', () => { it('matches when name lines up exactly', async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), serviceRoleAssignments: [{ name: 'Sono', assigneeId: 5 }] }, + { id: 1, startDate: new Date('2026-04-01'), eventServiceParts: [{ name: 'Sono', assigneeId: 5 }] }, ] as never) .mockResolvedValueOnce([] as never) @@ -169,7 +169,7 @@ describe('listUserServiceCadence', () => { it('matches when the historical row has surrounding whitespace on the name', async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), serviceRoleAssignments: [{ name: ' Sono ', assigneeId: 5 }] }, + { id: 1, startDate: new Date('2026-04-01'), eventServiceParts: [{ name: ' Sono ', assigneeId: 5 }] }, ] as never) .mockResolvedValueOnce([] as never) @@ -181,7 +181,7 @@ describe('listUserServiceCadence', () => { it('matches when the case of the name differs', async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), serviceRoleAssignments: [{ name: 'SONO', assigneeId: 5 }] }, + { id: 1, startDate: new Date('2026-04-01'), eventServiceParts: [{ name: 'SONO', assigneeId: 5 }] }, ] as never) .mockResolvedValueOnce([] as never) @@ -193,11 +193,11 @@ describe('listUserServiceCadence', () => { it('matches when diacritics differ (Accueil ↔ accueil)', async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), serviceRoleAssignments: [{ name: 'accueil', assigneeId: 5 }] }, + { id: 1, startDate: new Date('2026-04-01'), eventServiceParts: [{ name: 'accueil', assigneeId: 5 }] }, ] as never) .mockResolvedValueOnce([] as never) - const result = await listUserServiceCadence(db, { ...DEFAULT_ARGS, serviceRoleName: 'Accueil' }) + const result = await listUserServiceCadence(db, { ...DEFAULT_ARGS, servicePartName: 'Accueil' }) expect(result.past[0].assigned).toBe(true) }) @@ -208,7 +208,7 @@ describe('listUserServiceCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - serviceRoleAssignments: [{ name: 'Chairman', assigneeId: 5 }], + eventServiceParts: [{ name: 'Chairman', assigneeId: 5 }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -224,18 +224,14 @@ describe('listUserServiceCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - serviceRoleAssignments: [ - { name: 'Sono', assigneeId: 5, assignee: { firstname: 'Jean', lastname: 'Dupont' } }, - ], + eventServiceParts: [{ name: 'Sono', assigneeId: 5, assignee: { firstname: 'Jean', lastname: 'Dupont' } }], }, ] as never) .mockResolvedValueOnce([ { id: 2, startDate: new Date('2026-08-01'), - serviceRoleAssignments: [ - { name: 'Sono', assigneeId: 99, assignee: { firstname: 'Marie', lastname: 'Curie' } }, - ], + eventServiceParts: [{ name: 'Sono', assigneeId: 99, assignee: { firstname: 'Marie', lastname: 'Curie' } }], }, ] as never) @@ -255,7 +251,7 @@ describe('listUserServiceCadence', () => { it("propagates event.status as 'draft' when the past row is a draft", async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), status: 'draft', serviceRoleAssignments: [] }, + { id: 1, startDate: new Date('2026-04-01'), status: 'draft', eventServiceParts: [] }, ] as never) .mockResolvedValueOnce([] as never) @@ -267,7 +263,7 @@ describe('listUserServiceCadence', () => { it("buckets unknown Event.status values as 'released' (fallback contract)", async () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-04-01'), status: 'cancelled', serviceRoleAssignments: [] }, + { id: 1, startDate: new Date('2026-04-01'), status: 'cancelled', eventServiceParts: [] }, ] as never) .mockResolvedValueOnce([] as never) @@ -280,7 +276,7 @@ describe('listUserServiceCadence', () => { vi.mocked(db.event.findMany) .mockResolvedValueOnce([] as never) .mockResolvedValueOnce([ - { id: 1, startDate: new Date('2026-08-01'), status: 'draft', serviceRoleAssignments: [] }, + { id: 1, startDate: new Date('2026-08-01'), status: 'draft', eventServiceParts: [] }, ] as never) const result = await listUserServiceCadence(db, DEFAULT_ARGS) @@ -294,9 +290,7 @@ describe('listUserServiceCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - serviceRoleAssignments: [ - { name: 'Sono', assigneeId: 5, assignee: { firstname: 'Jean', lastname: 'Dupont' } }, - ], + eventServiceParts: [{ name: 'Sono', assigneeId: 5, assignee: { firstname: 'Jean', lastname: 'Dupont' } }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -312,7 +306,7 @@ describe('listUserServiceCadence', () => { { id: 1, startDate: new Date('2026-04-01'), - serviceRoleAssignments: [{ name: 'Sono', assigneeId: null, assignee: null }], + eventServiceParts: [{ name: 'Sono', assigneeId: null, assignee: null }], }, ] as never) .mockResolvedValueOnce([] as never) @@ -324,7 +318,7 @@ describe('listUserServiceCadence', () => { it('returns personName=null when the event has no matching service assignment', async () => { vi.mocked(db.event.findMany) - .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-04-01'), serviceRoleAssignments: [] }] as never) + .mockResolvedValueOnce([{ id: 1, startDate: new Date('2026-04-01'), eventServiceParts: [] }] as never) .mockResolvedValueOnce([] as never) const result = await listUserServiceCadence(db, DEFAULT_ARGS) @@ -333,7 +327,7 @@ describe('listUserServiceCadence', () => { }) it('returns hasHistory=false when the person has never held the service role at any past template instance', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] as never) const result = await listUserServiceCadence(db, DEFAULT_ARGS) @@ -341,7 +335,7 @@ describe('listUserServiceCadence', () => { }) it('returns hasHistory=true when the person appears on the matching service role (any age)', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([{ name: 'Sono' }] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([{ name: 'Sono' }] as never) const result = await listUserServiceCadence(db, DEFAULT_ARGS) @@ -349,20 +343,17 @@ describe('listUserServiceCadence', () => { }) it('hasHistory ignores rows whose normalized name does not match the anchor', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([ - { name: 'Chairman' }, - { name: 'Accueil' }, - ] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([{ name: 'Chairman' }, { name: 'Accueil' }] as never) const result = await listUserServiceCadence(db, DEFAULT_ARGS) expect(result.hasHistory).toBe(false) }) - it('hasHistory query filters programmeServiceRoleAssignment by assigneeId', async () => { + it('hasHistory query filters eventServicePart by assigneeId', async () => { await listUserServiceCadence(db, DEFAULT_ARGS) - const call = vi.mocked(db.programmeServiceRoleAssignment.findMany).mock.calls[0][0] + const call = vi.mocked(db.eventServicePart.findMany).mock.calls[0][0] expect(call?.where).toMatchObject({ assigneeId: 5 }) }) }) diff --git a/app/features/events/server/list-user-service-cadence.server.ts b/app/features/events/server/list-user-service-cadence.server.ts index 866a4d18..47dc0d06 100644 --- a/app/features/events/server/list-user-service-cadence.server.ts +++ b/app/features/events/server/list-user-service-cadence.server.ts @@ -16,21 +16,21 @@ type Options = { // Matches on the historical service-role assignment's `name`. Diacritic- // insensitive and whitespace/case-tolerant so trivial drift doesn't split a // single service role into two disjoint cadences. - serviceRoleName: string + servicePartName: string pastCount: number futureCount: number } // Sibling of listUserCadence for service-role assignments. Same event-side // query pattern (anchored on Event.templateId) but reads the -// `serviceRoleAssignments` relation instead of `partAssignments`. Service +// `serviceParts` relation instead of `parts`. Service // assignments have a single slot (assigneeId) and no section, so the anchor // and the participant check are both simpler. `hasHistory` reports whether // the user was ever on the matching service role at any past event of the // same template — used to tell first-timers from overdue candidates. export async function listUserServiceCadence( db: TransactionClient, - { userId, event, congregationId, serviceRoleName, pastCount, futureCount }: Options, + { userId, event, congregationId, servicePartName, pastCount, futureCount }: Options, ): Promise { if (event.templateId == null) return { past: [], future: [], hasHistory: false } @@ -46,7 +46,7 @@ export async function listUserServiceCadence( } as const const commonWhere = { templateId: event.templateId, congregationId } as const - const rowSelect = { id: true, startDate: true, status: true, serviceRoleAssignments: servicesSelect } as const + const rowSelect = { id: true, startDate: true, status: true, eventServiceParts: servicesSelect } as const const [pastRows, futureRows, historicalAssignments] = await Promise.all([ db.event.findMany({ @@ -61,15 +61,15 @@ export async function listUserServiceCadence( take: futureCount, select: rowSelect, }), - db.programmeServiceRoleAssignment.findMany({ + db.eventServicePart.findMany({ where: { event: { ...commonWhere, startDate: { lt: event.startDate } }, assigneeId: userId }, select: { name: true }, }), ]) - const targetName = normalize(serviceRoleName) + const targetName = normalize(servicePartName) const toEntry = (row: (typeof pastRows)[number]): CadenceEntry => { - const matches = row.serviceRoleAssignments.filter(s => normalize(s.name) === targetName) + const matches = row.eventServiceParts.filter(s => normalize(s.name) === targetName) const person = matches.map(s => s.assignee).find(p => p != null) return { date: row.startDate.toISOString(), diff --git a/app/features/events/server/notifications.server.tsx b/app/features/events/server/notifications.server.tsx index 12a433f6..9b41b5fe 100644 --- a/app/features/events/server/notifications.server.tsx +++ b/app/features/events/server/notifications.server.tsx @@ -1,9 +1,9 @@ import { z } from 'zod' import { defineNotificationType, manifest } from '~/features/notifications' import * as m from '~/i18n/paraglide/messages' -import ProgrammeAssignmentAssigned from '../emails/programme-assignment-assigned' -import ProgrammeAssignmentUnassigned from '../emails/programme-assignment-unassigned' -import { PROGRAMME_ROLES } from '../model/programme-role' +import ProgrammeAssignmentAssigned from '../emails/event-part-assigned' +import ProgrammeAssignmentUnassigned from '../emails/event-part-unassigned' +import { PROGRAMME_ROLES } from '../model/template-role' const PROGRAMME_CATEGORY = { key: 'programme', label: () => m.notification_category_programme() } diff --git a/app/features/events/server/notify-assignment.server.test.ts b/app/features/events/server/notify-assignment.server.test.ts index 741c6519..d8283d3c 100644 --- a/app/features/events/server/notify-assignment.server.test.ts +++ b/app/features/events/server/notify-assignment.server.test.ts @@ -14,7 +14,7 @@ vi.mock('~/features/display-board/index.server', () => ({ resolveProgrammeLink: vi.fn(), })) -const { dispatchAssignmentDiffs, notifyAssignment, partAssignmentDiffs, serviceRoleAssignmentDiffs } = await import( +const { dispatchAssignmentDiffs, notifyAssignment, partAssignmentDiffs, servicePartAssignmentDiffs } = await import( './notify-assignment.server' ) const { unscopedDb: db } = await import('~/shared/infra/db.server') @@ -30,7 +30,7 @@ const CTX = { status: 'released' as const, }, assignmentName: 'Perles de la Parole', - entityType: 'ProgrammePartAssignment' as const, + entityType: 'EventPart' as const, entityId: 100, congregationId: 42, actorId: 7, @@ -66,7 +66,7 @@ describe('notifyAssignment', () => { const call = vi.mocked(notify).mock.calls[0][1] expect(call).toMatchObject({ type: 'programme.assignment.assigned', - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: 100, congregationId: 42, recipientId: 33, @@ -223,14 +223,14 @@ describe('partAssignmentDiffs (pure)', () => { }) }) -describe('serviceRoleAssignmentDiffs (pure)', () => { +describe('servicePartAssignmentDiffs (pure)', () => { it('maps previousAssigneeId → servant slot', () => { - const diffs = serviceRoleAssignmentDiffs({ previousAssigneeId: 5 }, { assigneeId: 9 }) + const diffs = servicePartAssignmentDiffs({ previousAssigneeId: 5 }, { assigneeId: 9 }) expect(diffs).toEqual([{ role: 'servant', previousMemberId: 5, newMemberId: 9 }]) }) it('produces exactly one diff', () => { - const diffs = serviceRoleAssignmentDiffs({ previousAssigneeId: null }, { assigneeId: null }) + const diffs = servicePartAssignmentDiffs({ previousAssigneeId: null }, { assigneeId: null }) expect(diffs).toHaveLength(1) expect(diffs[0].role).toBe('servant') }) diff --git a/app/features/events/server/notify-assignment.server.ts b/app/features/events/server/notify-assignment.server.ts index 86f85472..d15a3243 100644 --- a/app/features/events/server/notify-assignment.server.ts +++ b/app/features/events/server/notify-assignment.server.ts @@ -5,7 +5,7 @@ import { notificationRecipientFilter } from '~/shared/auth/permissions.server' import type { TransactionClient } from '~/shared/infra/db.server' import { createLogger } from '~/shared/infra/logger.server' import { formatEventDate } from '~/shared/utils/event-time' -import type { ProgrammeRole } from '../model/programme-role' +import type { ProgrammeRole } from '../model/template-role' import { type AssignmentChangeType, PROGRAMME_ASSIGNMENT_TYPE } from './notifications.server' const logger = createLogger('notify-assignment') @@ -13,7 +13,7 @@ const logger = createLogger('notify-assignment') // Shared shape both routes (assign-part, assign-service, remove-assignment) // hand to notifyAssignment. `event.startDate` is the raw DB Date; we format it // with the congregation's locale + timezone before it enters the payload so the -// worker doesn't need to know either. `templateId` feeds the programme-link +// worker doesn't need to know either. `templateId` feeds the event-link // resolver — null means the event was created ad-hoc without a template, in // which case the resolver falls back to /board. export interface AssignmentNotificationContext { @@ -21,7 +21,7 @@ export interface AssignmentNotificationContext { // yet. Draft events accumulate diffs silently; release re-enqueues them. event: { id: number; name: string; startDate: Date; templateId: number | null; status: string } assignmentName: string - entityType: 'ProgrammePartAssignment' | 'ProgrammeServiceRoleAssignment' + entityType: 'EventPart' | 'EventServicePart' entityId: number congregationId: number actorId: number @@ -37,7 +37,7 @@ export type { AssignmentChangeType, ProgrammeRole } export function buildAssignmentContext(args: { event: { id: number; name: string; startDate: Date; templateId: number | null; status: string } assignmentName: string | undefined - entityType: 'ProgrammePartAssignment' | 'ProgrammeServiceRoleAssignment' + entityType: 'EventPart' | 'EventServicePart' entityId: number congregationId: number actorId: number @@ -90,7 +90,7 @@ export function partAssignmentDiffs( } // Service-role assignment: single slot (servant = assignee). -export function serviceRoleAssignmentDiffs( +export function servicePartAssignmentDiffs( before: { previousAssigneeId: number | null }, after: { assigneeId: number | null }, ): AssignmentDiff[] { diff --git a/app/features/events/server/part-role-labels.integration.test.ts b/app/features/events/server/part-role-labels.integration.test.ts index 3c6e948f..e36b339f 100644 --- a/app/features/events/server/part-role-labels.integration.test.ts +++ b/app/features/events/server/part-role-labels.integration.test.ts @@ -8,9 +8,9 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' -import { applyTemplateToEvent } from '~/features/events/server/programme-events.server' -import { generateEventsFromTemplate } from '~/features/events/server/programme-generation.server' -import { duplicateTemplate } from '~/features/events/server/programme-templates.server' +import { applyTemplateToEvent } from '~/features/events/server/event-parts.server' +import { generateEventsFromTemplate } from '~/features/events/server/event-template-generation.server' +import { duplicateTemplate } from '~/features/events/server/event-templates.server' const adapter = new PrismaPg({ connectionString: process.env.DB_RUNTIME_URL ?? process.env.DB_URL, @@ -44,7 +44,7 @@ interface SeedTemplateResult { function seedTemplate(key: string, weekDay: number | null = null): Promise { return withScope(congId, async tx => { - const template = await tx.programmeTemplate.create({ + const template = await tx.eventTemplate.create({ data: { name: `Midweek ${key}`, key, @@ -103,12 +103,12 @@ beforeAll(async () => { }) afterAll(async () => { - await testDb.programmePartAssignmentAllowedRole.deleteMany({ where: { congregationId: congId } }) - await testDb.programmePartAssignment.deleteMany({ where: { congregationId: congId } }) - await testDb.programmeTemplatePartAllowedRole.deleteMany({ where: { congregationId: congId } }) - await testDb.programmeTemplatePart.deleteMany({ where: { congregationId: congId } }) + await testDb.eventPartAllowedRole.deleteMany({ where: { congregationId: congId } }) + await testDb.eventPart.deleteMany({ where: { congregationId: congId } }) + await testDb.templatePartAllowedRole.deleteMany({ where: { congregationId: congId } }) + await testDb.templatePart.deleteMany({ where: { congregationId: congId } }) await testDb.event.deleteMany({ where: { congregationId: congId } }) - await testDb.programmeTemplate.deleteMany({ where: { congregationId: congId } }) + await testDb.eventTemplate.deleteMany({ where: { congregationId: congId } }) await testDb.auditLog.deleteMany({ where: { congregationId: congId } }) await testDb.userAccount.deleteMany({ where: { congregationId: congId } }) await testDb.congregation.delete({ where: { id: congId } }) @@ -134,7 +134,7 @@ describe('per-part role labels — end-to-end', () => { await withScope(congId, tx => applyTemplateToEvent(tx, event.id, templateId, congId, managerAccountId)) - const assignments = await testDb.programmePartAssignment.findMany({ + const assignments = await testDb.eventPart.findMany({ where: { eventId: event.id, congregationId: congId }, orderBy: { order: 'asc' }, select: { name: true, speakerLabel: true, readerLabel: true }, @@ -156,7 +156,7 @@ describe('per-part role labels — end-to-end', () => { ) expect(events.length).toBeGreaterThan(0) - const assignments = await testDb.programmePartAssignment.findMany({ + const assignments = await testDb.eventPart.findMany({ where: { eventId: events[0].id, congregationId: congId }, orderBy: { order: 'asc' }, select: { name: true, speakerLabel: true, readerLabel: true }, @@ -175,7 +175,7 @@ describe('per-part role labels — end-to-end', () => { const duplicated = await withScope(congId, tx => duplicateTemplate(tx, templateId, congId)) expect(duplicated).not.toBeNull() - const parts = await testDb.programmeTemplatePart.findMany({ + const parts = await testDb.templatePart.findMany({ where: { templateId: duplicated?.id, congregationId: congId }, orderBy: { order: 'asc' }, select: { name: true, speakerLabel: true, readerLabel: true }, diff --git a/app/features/events/server/personal-assignments.integration.test.ts b/app/features/events/server/personal-assignments.integration.test.ts index 9e7787c8..3e169efb 100644 --- a/app/features/events/server/personal-assignments.integration.test.ts +++ b/app/features/events/server/personal-assignments.integration.test.ts @@ -1,7 +1,7 @@ import { PrismaPg } from '@prisma/adapter-pg' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { PrismaClient } from '~/database/generated/client' -import { ProgrammeTemplateKey } from '~/features/events/model/programme-template.type' +import { EventTemplateKey } from '~/features/events/model/event-template.type' import { getPersonalAssignments } from '~/features/events/server/personal-assignments.server' import { PublisherType } from '~/shared/types/publisher-type' @@ -60,10 +60,10 @@ beforeAll(async () => { aliceId = alice.id const aliceMemberId = aliceMember.id - const dayOffTemplate = await tx.programmeTemplate.create({ + const dayOffTemplate = await tx.eventTemplate.create({ data: { name: 'Day off', - key: ProgrammeTemplateKey.DayOff, + key: EventTemplateKey.DayOff, color: '#cfcfcf', weekDay: null, isRecurring: false, @@ -97,7 +97,7 @@ beforeAll(async () => { }) evergreenId = dayOff.id - await tx.programmePartAssignment.create({ + await tx.eventPart.create({ data: { name: 'Trésors', section: 'Section 1', @@ -108,7 +108,7 @@ beforeAll(async () => { }, }) - await tx.programmeServiceRoleAssignment.create({ + await tx.eventServicePart.create({ data: { name: 'Sono', eventId: meetingEvent.id, @@ -150,7 +150,7 @@ beforeAll(async () => { }, }) - const foreign = await tx.programmePartAssignment.create({ + const foreign = await tx.eventPart.create({ data: { name: 'Foreign', section: 'X', @@ -165,14 +165,14 @@ beforeAll(async () => { afterAll(async () => { // FK-safe deletion order: assignments → events → kinds → users → congregations - await testDb.programmePartAssignment.deleteMany({ + await testDb.eventPart.deleteMany({ where: { congregationId: { in: [congAId, congBId] } }, }) - await testDb.programmeServiceRoleAssignment.deleteMany({ + await testDb.eventServicePart.deleteMany({ where: { congregationId: { in: [congAId, congBId] } }, }) await testDb.event.deleteMany({ where: { congregationId: { in: [congAId, congBId] } } }) - await testDb.programmeTemplate.deleteMany({ where: { congregationId: { in: [congAId, congBId] } } }) + await testDb.eventTemplate.deleteMany({ where: { congregationId: { in: [congAId, congBId] } } }) await testDb.userAccount.deleteMany({ where: { congregationId: { in: [congAId, congBId] } } }) await testDb.member.deleteMany({ where: { congregationId: { in: [congAId, congBId] } } }) await testDb.congregation.deleteMany({ where: { id: { in: [congAId, congBId] } } }) diff --git a/app/features/events/server/personal-assignments.server.test.ts b/app/features/events/server/personal-assignments.server.test.ts index 352eea4c..0372c727 100644 --- a/app/features/events/server/personal-assignments.server.test.ts +++ b/app/features/events/server/personal-assignments.server.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { getPersonalAssignments } from './personal-assignments.server' -function makeDb(rows: { parts?: unknown[]; serviceRoles?: unknown[]; daysOff?: unknown[]; memberId?: number }) { +function makeDb(rows: { parts?: unknown[]; serviceParts?: unknown[]; daysOff?: unknown[]; memberId?: number }) { // Mirror the new id-resolution: getPersonalAssignments looks up the linked // member id from the UserAccount before issuing member-bound queries. return { userAccount: { findUnique: vi.fn().mockResolvedValue({ memberId: rows.memberId ?? 42 }) }, - programmePartAssignment: { findMany: vi.fn().mockResolvedValue(rows.parts ?? []) }, - programmeServiceRoleAssignment: { findMany: vi.fn().mockResolvedValue(rows.serviceRoles ?? []) }, + eventPart: { findMany: vi.fn().mockResolvedValue(rows.parts ?? []) }, + eventServicePart: { findMany: vi.fn().mockResolvedValue(rows.serviceParts ?? []) }, event: { findMany: vi.fn().mockResolvedValue(rows.daysOff ?? []) }, } as never } @@ -19,8 +19,8 @@ describe('getPersonalAssignments', () => { const db = makeDb({}) await getPersonalAssignments(db, 42, since) - const partsCall = (db as never as { programmePartAssignment: { findMany: { mock: { calls: unknown[][] } } } }) - .programmePartAssignment.findMany.mock.calls[0][0] as { where: Record } + const partsCall = (db as never as { eventPart: { findMany: { mock: { calls: unknown[][] } } } }).eventPart.findMany + .mock.calls[0][0] as { where: Record } // Personal calendar / ICS is publisher-facing — drafts must not appear // there. Only released events flow through. expect(partsCall.where).toMatchObject({ @@ -28,9 +28,8 @@ describe('getPersonalAssignments', () => { event: { startDate: { gte: since }, status: 'released' }, }) - const rolesCall = ( - db as never as { programmeServiceRoleAssignment: { findMany: { mock: { calls: unknown[][] } } } } - ).programmeServiceRoleAssignment.findMany.mock.calls[0][0] as { where: Record } + const rolesCall = (db as never as { eventServicePart: { findMany: { mock: { calls: unknown[][] } } } }) + .eventServicePart.findMany.mock.calls[0][0] as { where: Record } expect(rolesCall.where).toMatchObject({ assigneeId: 42, event: { startDate: { gte: since }, status: 'released' }, @@ -129,7 +128,7 @@ describe('getPersonalAssignments', () => { event, }, ], - serviceRoles: [{ id: 2, assigneeId: 42, name: 'Sono', note: '', updatedAt: new Date(), event }], + serviceParts: [{ id: 2, assigneeId: 42, name: 'Sono', note: '', updatedAt: new Date(), event }], daysOff: [ { id: 3, diff --git a/app/features/events/server/personal-assignments.server.ts b/app/features/events/server/personal-assignments.server.ts index 1fac0d91..f7e3c132 100644 --- a/app/features/events/server/personal-assignments.server.ts +++ b/app/features/events/server/personal-assignments.server.ts @@ -1,6 +1,6 @@ -import type { Event, ProgrammePartAssignment, ProgrammeServiceRoleAssignment } from '~/database/generated/client' +import type { Event, EventPart, EventServicePart } from '~/database/generated/client' import { EventStatus } from '~/features/events/model/event-status.type' -import { ProgrammeTemplateKey } from '~/features/events/model/programme-template.type' +import { EventTemplateKey } from '~/features/events/model/event-template.type' import * as m from '~/i18n/paraglide/messages' import type { TransactionClient } from '~/shared/infra/db.server' @@ -15,13 +15,13 @@ export type PersonalCalendarItem = { updatedAt: Date } -type PartWithEvent = ProgrammePartAssignment & { event: Event } -type ServiceRoleWithEvent = ProgrammeServiceRoleAssignment & { event: Event } +type PartWithEvent = EventPart & { event: Event } +type ServicePartWithEvent = EventServicePart & { event: Event } /** * `userId` is a UserAccount id. Days-off events are account-bound (createdById), - * while programme assignments are member-bound (assigneeId/assistantId), so we - * resolve the linked member id internally. + * while event parts and service roles are member-bound (assigneeId/assistantId), + * so we resolve the linked member id internally. */ export async function getPersonalAssignments( db: TransactionClient, @@ -34,9 +34,9 @@ export async function getPersonalAssignments( }) const memberId = account?.memberId ?? null - const [partAssignments, serviceRoleAssignments, daysOff] = await Promise.all([ + const [parts, serviceParts, daysOff] = await Promise.all([ memberId != null - ? db.programmePartAssignment.findMany({ + ? db.eventPart.findMany({ where: { OR: [{ assigneeId: memberId }, { assistantId: memberId }], // Drafts stay off the publisher's calendar and ICS feed. @@ -46,7 +46,7 @@ export async function getPersonalAssignments( }) : Promise.resolve([]), memberId != null - ? db.programmeServiceRoleAssignment.findMany({ + ? db.eventServicePart.findMany({ where: { assigneeId: memberId, event: { startDate: { gte: since }, status: EventStatus.Released }, @@ -57,15 +57,15 @@ export async function getPersonalAssignments( db.event.findMany({ where: { createdById: userId, - template: { key: ProgrammeTemplateKey.DayOff }, + template: { key: EventTemplateKey.DayOff }, startDate: { gte: since }, }, }), ]) return [ - ...partAssignments.map(p => partAssignmentToItem(p, memberId ?? userId)), - ...serviceRoleAssignments.map(serviceRoleAssignmentToItem), + ...parts.map(p => partAssignmentToItem(p, memberId ?? userId)), + ...serviceParts.map(servicePartAssignmentToItem), ...daysOff.map(dayOffToItem), ] } @@ -91,7 +91,7 @@ function partAssignmentToItem(assignment: PartWithEvent, userId: number): Person } } -function serviceRoleAssignmentToItem(assignment: ServiceRoleWithEvent): PersonalCalendarItem { +function servicePartAssignmentToItem(assignment: ServicePartWithEvent): PersonalCalendarItem { const role = m.calendar_feed_role_service() const summaryParts = [assignment.event.name, assignment.name].filter(Boolean) diff --git a/app/features/events/server/resolve-external-speaker-cadence.server.test.ts b/app/features/events/server/resolve-external-speaker-cadence.server.test.ts index f29e07aa..127c1a4f 100644 --- a/app/features/events/server/resolve-external-speaker-cadence.server.test.ts +++ b/app/features/events/server/resolve-external-speaker-cadence.server.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { - programmePartAssignment: { findFirst: vi.fn(), findMany: vi.fn().mockResolvedValue([]) }, + eventPart: { findFirst: vi.fn(), findMany: vi.fn().mockResolvedValue([]) }, event: { findMany: vi.fn().mockResolvedValue([]) }, }, })) @@ -20,7 +20,7 @@ const DEFAULT_ARGS = { beforeEach(() => { vi.resetAllMocks() - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([] as never) vi.mocked(db.event.findMany).mockResolvedValue([] as never) }) @@ -33,7 +33,7 @@ describe('resolveExternalSpeakerCadence — dispatch', () => { }) it('returns EMPTY_CADENCE when the anchor row is missing', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventPart.findFirst).mockResolvedValue(null as never) const result = await resolveExternalSpeakerCadence(db, DEFAULT_ARGS) @@ -41,7 +41,7 @@ describe('resolveExternalSpeakerCadence — dispatch', () => { }) it('returns anchored=true when the anchor row is found', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ name: 'Discours', section: 'Culte', externalSpeakerId: 42, @@ -55,7 +55,7 @@ describe('resolveExternalSpeakerCadence — dispatch', () => { describe('resolveExternalSpeakerCadence — savedMatchesSelection', () => { it('fires when the saved external speaker matches the URL speaker', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ name: 'Discours', section: 'Culte', externalSpeakerId: 42, @@ -67,7 +67,7 @@ describe('resolveExternalSpeakerCadence — savedMatchesSelection', () => { }) it('does not fire when the saved slot is unassigned', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ name: 'Discours', section: 'Culte', externalSpeakerId: null, @@ -79,7 +79,7 @@ describe('resolveExternalSpeakerCadence — savedMatchesSelection', () => { }) it('does not fire when the saved external speaker is a different one', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ name: 'Discours', section: 'Culte', externalSpeakerId: 99, @@ -93,11 +93,11 @@ describe('resolveExternalSpeakerCadence — savedMatchesSelection', () => { describe('resolveExternalSpeakerCadence — anchor lookup filter', () => { it('scopes the anchor lookup to id + congregationId', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventPart.findFirst).mockResolvedValue(null as never) await resolveExternalSpeakerCadence(db, DEFAULT_ARGS) - const call = vi.mocked(db.programmePartAssignment.findFirst).mock.calls[0][0] + const call = vi.mocked(db.eventPart.findFirst).mock.calls[0][0] expect(call?.where).toEqual({ id: 77, congregationId: 1 }) expect(call?.select).toMatchObject({ name: true, section: true, externalSpeakerId: true }) }) diff --git a/app/features/events/server/resolve-external-speaker-cadence.server.ts b/app/features/events/server/resolve-external-speaker-cadence.server.ts index a3dcdb96..a30d0944 100644 --- a/app/features/events/server/resolve-external-speaker-cadence.server.ts +++ b/app/features/events/server/resolve-external-speaker-cadence.server.ts @@ -22,7 +22,7 @@ export async function resolveExternalSpeakerCadence( ): Promise { if (excludePartAssignmentId == null) return EMPTY_CADENCE - const current = await db.programmePartAssignment.findFirst({ + const current = await db.eventPart.findFirst({ where: { id: excludePartAssignmentId, congregationId }, select: { name: true, section: true, externalSpeakerId: true }, }) diff --git a/app/features/events/server/resolve-publisher-cadence.server.test.ts b/app/features/events/server/resolve-publisher-cadence.server.test.ts index 58bbac77..68c0e547 100644 --- a/app/features/events/server/resolve-publisher-cadence.server.test.ts +++ b/app/features/events/server/resolve-publisher-cadence.server.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('~/shared/infra/db.server', () => ({ unscopedDb: { - programmePartAssignment: { findFirst: vi.fn(), findMany: vi.fn().mockResolvedValue([]) }, - programmeServiceRoleAssignment: { findFirst: vi.fn(), findMany: vi.fn().mockResolvedValue([]) }, + eventPart: { findFirst: vi.fn(), findMany: vi.fn().mockResolvedValue([]) }, + eventServicePart: { findFirst: vi.fn(), findMany: vi.fn().mockResolvedValue([]) }, event: { findMany: vi.fn().mockResolvedValue([]) }, }, })) @@ -23,8 +23,8 @@ const DEFAULT_ARGS = { beforeEach(() => { vi.resetAllMocks() - vi.mocked(db.programmePartAssignment.findMany).mockResolvedValue([] as never) - vi.mocked(db.programmeServiceRoleAssignment.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventPart.findMany).mockResolvedValue([] as never) + vi.mocked(db.eventServicePart.findMany).mockResolvedValue([] as never) vi.mocked(db.event.findMany).mockResolvedValue([] as never) }) @@ -41,7 +41,7 @@ describe('resolvePublisherCadence — dispatch', () => { }) it('returns EMPTY_CADENCE (anchored=false) when the part assignment cannot be found', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventPart.findFirst).mockResolvedValue(null as never) const result = await resolvePublisherCadence(db, DEFAULT_ARGS) @@ -49,7 +49,7 @@ describe('resolvePublisherCadence — dispatch', () => { }) it('returns EMPTY_CADENCE (anchored=false) when the service assignment cannot be found', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue(null as never) const result = await resolvePublisherCadence(db, { ...DEFAULT_ARGS, @@ -61,7 +61,7 @@ describe('resolvePublisherCadence — dispatch', () => { }) it('returns anchored=true when a part assignment resolves', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ name: 'Bible Reading', section: 'Ministry', assigneeId: 5, @@ -74,7 +74,7 @@ describe('resolvePublisherCadence — dispatch', () => { }) it('returns anchored=true when a service assignment resolves', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ name: 'Sono', assigneeId: 5, } as never) @@ -91,7 +91,7 @@ describe('resolvePublisherCadence — dispatch', () => { describe('resolvePublisherCadence — savedMatchesSelection (parts)', () => { it("fires when partSlot='assignee' and the saved assigneeId matches userId", async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ name: 'X', section: 'Y', assigneeId: 5, @@ -104,7 +104,7 @@ describe('resolvePublisherCadence — savedMatchesSelection (parts)', () => { }) it("does not fire when partSlot='assignee' but the saved assistantId matches userId (cross-slot)", async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ name: 'X', section: 'Y', assigneeId: 99, @@ -117,7 +117,7 @@ describe('resolvePublisherCadence — savedMatchesSelection (parts)', () => { }) it("fires when partSlot='assistant' and the saved assistantId matches userId", async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ name: 'X', section: 'Y', assigneeId: 99, @@ -130,7 +130,7 @@ describe('resolvePublisherCadence — savedMatchesSelection (parts)', () => { }) it('does not fire when the saved slot is null', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ name: 'X', section: 'Y', assigneeId: null, @@ -143,7 +143,7 @@ describe('resolvePublisherCadence — savedMatchesSelection (parts)', () => { }) it('does not fire when the saved id is a different user', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventPart.findFirst).mockResolvedValue({ name: 'X', section: 'Y', assigneeId: 99, @@ -158,7 +158,7 @@ describe('resolvePublisherCadence — savedMatchesSelection (parts)', () => { describe('resolvePublisherCadence — savedMatchesSelection (services)', () => { it('fires when the saved service assigneeId matches userId', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ name: 'Sono', assigneeId: 5, } as never) @@ -173,7 +173,7 @@ describe('resolvePublisherCadence — savedMatchesSelection (services)', () => { }) it('does not fire when the service slot is unassigned', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue({ + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue({ name: 'Sono', assigneeId: null, } as never) @@ -190,17 +190,17 @@ describe('resolvePublisherCadence — savedMatchesSelection (services)', () => { describe('resolvePublisherCadence — anchor lookup filters', () => { it('scopes the part assignment lookup to id + congregationId', async () => { - vi.mocked(db.programmePartAssignment.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventPart.findFirst).mockResolvedValue(null as never) await resolvePublisherCadence(db, DEFAULT_ARGS) - const call = vi.mocked(db.programmePartAssignment.findFirst).mock.calls[0][0] + const call = vi.mocked(db.eventPart.findFirst).mock.calls[0][0] expect(call?.where).toEqual({ id: 77, congregationId: 1 }) expect(call?.select).toMatchObject({ name: true, section: true, assigneeId: true, assistantId: true }) }) it('scopes the service assignment lookup to id + congregationId', async () => { - vi.mocked(db.programmeServiceRoleAssignment.findFirst).mockResolvedValue(null as never) + vi.mocked(db.eventServicePart.findFirst).mockResolvedValue(null as never) await resolvePublisherCadence(db, { ...DEFAULT_ARGS, @@ -208,7 +208,7 @@ describe('resolvePublisherCadence — anchor lookup filters', () => { excludeServiceAssignmentId: 55, }) - const call = vi.mocked(db.programmeServiceRoleAssignment.findFirst).mock.calls[0][0] + const call = vi.mocked(db.eventServicePart.findFirst).mock.calls[0][0] expect(call?.where).toEqual({ id: 55, congregationId: 1 }) expect(call?.select).toMatchObject({ name: true, assigneeId: true }) }) diff --git a/app/features/events/server/resolve-publisher-cadence.server.ts b/app/features/events/server/resolve-publisher-cadence.server.ts index b46660be..781d65ba 100644 --- a/app/features/events/server/resolve-publisher-cadence.server.ts +++ b/app/features/events/server/resolve-publisher-cadence.server.ts @@ -25,7 +25,7 @@ export async function resolvePublisherCadence( { userId, event, congregationId, excludePartAssignmentId, excludeServiceAssignmentId, partSlot }: Options, ): Promise { if (excludePartAssignmentId != null) { - const current = await db.programmePartAssignment.findFirst({ + const current = await db.eventPart.findFirst({ where: { id: excludePartAssignmentId, congregationId }, select: { name: true, section: true, assigneeId: true, assistantId: true }, }) @@ -46,7 +46,7 @@ export async function resolvePublisherCadence( } if (excludeServiceAssignmentId != null) { - const current = await db.programmeServiceRoleAssignment.findFirst({ + const current = await db.eventServicePart.findFirst({ where: { id: excludeServiceAssignmentId, congregationId }, select: { name: true, assigneeId: true }, }) @@ -56,7 +56,7 @@ export async function resolvePublisherCadence( userId, event, congregationId, - serviceRoleName: current.name, + servicePartName: current.name, pastCount: PAST_COUNT, futureCount: FUTURE_COUNT, }) diff --git a/app/features/events/server/seed-templates.server.test.ts b/app/features/events/server/seed-templates.server.test.ts index 903ef4f8..a8b1a340 100644 --- a/app/features/events/server/seed-templates.server.test.ts +++ b/app/features/events/server/seed-templates.server.test.ts @@ -4,7 +4,7 @@ const { seedDefaultTemplates } = await import('./seed-templates.server') function makeDb() { return { - programmeTemplate: { + eventTemplate: { findFirst: vi.fn(), create: vi.fn(), }, @@ -18,47 +18,47 @@ beforeEach(() => { describe('seedDefaultTemplates', () => { it('seeds all 5 templates when none exist', async () => { const db = makeDb() - db.programmeTemplate.findFirst.mockResolvedValue(null as never) - db.programmeTemplate.create.mockResolvedValue({} as never) + db.eventTemplate.findFirst.mockResolvedValue(null as never) + db.eventTemplate.create.mockResolvedValue({} as never) await seedDefaultTemplates(db, 1, 'fr') - expect(db.programmeTemplate.findFirst).toHaveBeenCalledTimes(5) - expect(db.programmeTemplate.create).toHaveBeenCalledTimes(5) + expect(db.eventTemplate.findFirst).toHaveBeenCalledTimes(5) + expect(db.eventTemplate.create).toHaveBeenCalledTimes(5) }) it('skips existing templates', async () => { const db = makeDb() - db.programmeTemplate.findFirst + db.eventTemplate.findFirst .mockResolvedValueOnce({ id: 1 } as never) // midweek exists .mockResolvedValueOnce(null as never) // weekend does not .mockResolvedValueOnce(null as never) // memorial does not .mockResolvedValueOnce(null as never) // day-off does not .mockResolvedValueOnce(null as never) // freeform does not - db.programmeTemplate.create.mockResolvedValue({} as never) + db.eventTemplate.create.mockResolvedValue({} as never) await seedDefaultTemplates(db, 1, 'fr') - expect(db.programmeTemplate.create).toHaveBeenCalledTimes(4) + expect(db.eventTemplate.create).toHaveBeenCalledTimes(4) }) it('skips all when all templates exist', async () => { const db = makeDb() - db.programmeTemplate.findFirst.mockResolvedValue({ id: 1 } as never) + db.eventTemplate.findFirst.mockResolvedValue({ id: 1 } as never) await seedDefaultTemplates(db, 1, 'fr') - expect(db.programmeTemplate.create).toHaveBeenCalledTimes(0) + expect(db.eventTemplate.create).toHaveBeenCalledTimes(0) }) it('creates templates with names from message functions', async () => { const db = makeDb() - db.programmeTemplate.findFirst.mockResolvedValue(null as never) - db.programmeTemplate.create.mockResolvedValue({} as never) + db.eventTemplate.findFirst.mockResolvedValue(null as never) + db.eventTemplate.create.mockResolvedValue({} as never) await seedDefaultTemplates(db, 1, 'fr') - const createdNames = db.programmeTemplate.create.mock.calls.map( + const createdNames = db.eventTemplate.create.mock.calls.map( (call: unknown[]) => (call[0] as { data: { name: string } }).data.name, ) expect(createdNames).toEqual([ @@ -72,12 +72,12 @@ describe('seedDefaultTemplates', () => { it('creates day-off and freeform templates with the right key, empty parts, and colour', async () => { const db = makeDb() - db.programmeTemplate.findFirst.mockResolvedValue(null as never) - db.programmeTemplate.create.mockResolvedValue({} as never) + db.eventTemplate.findFirst.mockResolvedValue(null as never) + db.eventTemplate.create.mockResolvedValue({} as never) await seedDefaultTemplates(db, 1, 'fr') - const [, , , dayOffCall, freeformCall] = db.programmeTemplate.create.mock.calls + const [, , , dayOffCall, freeformCall] = db.eventTemplate.create.mock.calls const dayOff = (dayOffCall[0] as { data: Record }).data const freeform = (freeformCall[0] as { data: Record }).data @@ -86,25 +86,25 @@ describe('seedDefaultTemplates', () => { expect(dayOff.weekDay).toBeNull() expect(dayOff.color).toBe('#cfcfcf') expect((dayOff.parts as { create: unknown[] }).create).toEqual([]) - expect((dayOff.serviceRoles as { create: unknown[] }).create).toEqual([]) + expect((dayOff.serviceParts as { create: unknown[] }).create).toEqual([]) expect(freeform.key).toBe('freeform') expect(freeform.isRecurring).toBe(false) expect(freeform.weekDay).toBeNull() expect(freeform.color).toBe('#6366f1') expect((freeform.parts as { create: unknown[] }).create).toEqual([]) - expect((freeform.serviceRoles as { create: unknown[] }).create).toEqual([]) + expect((freeform.serviceParts as { create: unknown[] }).create).toEqual([]) }) it('creates parts with correct structure', async () => { const db = makeDb() - db.programmeTemplate.findFirst.mockResolvedValue(null as never) - db.programmeTemplate.create.mockResolvedValue({} as never) + db.eventTemplate.findFirst.mockResolvedValue(null as never) + db.eventTemplate.create.mockResolvedValue({} as never) await seedDefaultTemplates(db, 1, 'fr') // Check the midweek template (first create call) - const midweekData = db.programmeTemplate.create.mock.calls[0][0].data + const midweekData = db.eventTemplate.create.mock.calls[0][0].data const midweekParts = midweekData.parts.create expect(midweekParts.length).toBe(12) @@ -132,15 +132,15 @@ describe('seedDefaultTemplates', () => { it('creates service roles for each meeting template', async () => { const db = makeDb() - db.programmeTemplate.findFirst.mockResolvedValue(null as never) - db.programmeTemplate.create.mockResolvedValue({} as never) + db.eventTemplate.findFirst.mockResolvedValue(null as never) + db.eventTemplate.create.mockResolvedValue({} as never) await seedDefaultTemplates(db, 1, 'fr') // Only the meeting-style templates (first 3) carry the shared service roles; // the day-off and freeform templates are structural placeholders with none. - for (const call of db.programmeTemplate.create.mock.calls.slice(0, 3)) { - const roles = call[0].data.serviceRoles.create + for (const call of db.eventTemplate.create.mock.calls.slice(0, 3)) { + const roles = call[0].data.serviceParts.create expect(roles.length).toBe(4) expect(roles.map((r: { key: string }) => r.key)).toEqual(['sono', 'stage', 'welcome', 'cleaning']) } @@ -148,12 +148,12 @@ describe('seedDefaultTemplates', () => { it('passes the congregationId to the template, parts, and service roles', async () => { const db = makeDb() - db.programmeTemplate.findFirst.mockResolvedValue(null as never) - db.programmeTemplate.create.mockResolvedValue({} as never) + db.eventTemplate.findFirst.mockResolvedValue(null as never) + db.eventTemplate.create.mockResolvedValue({} as never) await seedDefaultTemplates(db, 7, 'fr') - for (const call of db.programmeTemplate.create.mock.calls) { + for (const call of db.eventTemplate.create.mock.calls) { const data = call[0].data expect(data.congregationId).toBe(7) @@ -161,7 +161,7 @@ describe('seedDefaultTemplates', () => { expect(part.congregationId).toBe(7) } - for (const role of data.serviceRoles.create) { + for (const role of data.serviceParts.create) { expect(role.congregationId).toBe(7) } } @@ -169,12 +169,12 @@ describe('seedDefaultTemplates', () => { it('checks existence with the correct key and congregationId', async () => { const db = makeDb() - db.programmeTemplate.findFirst.mockResolvedValue(null as never) - db.programmeTemplate.create.mockResolvedValue({} as never) + db.eventTemplate.findFirst.mockResolvedValue(null as never) + db.eventTemplate.create.mockResolvedValue({} as never) await seedDefaultTemplates(db, 3, 'fr') - const findCalls = db.programmeTemplate.findFirst.mock.calls + const findCalls = db.eventTemplate.findFirst.mock.calls expect(findCalls[0][0]).toEqual({ where: { key: 'midweek-meeting', congregationId: 3 } }) expect(findCalls[1][0]).toEqual({ where: { key: 'weekend-meeting', congregationId: 3 } }) expect(findCalls[2][0]).toEqual({ where: { key: 'memorial', congregationId: 3 } }) diff --git a/app/features/events/server/seed-templates.server.ts b/app/features/events/server/seed-templates.server.ts index 156fd0b4..61691a1a 100644 --- a/app/features/events/server/seed-templates.server.ts +++ b/app/features/events/server/seed-templates.server.ts @@ -1,4 +1,4 @@ -import { ProgrammeTemplateKey } from '~/features/events/model/programme-template.type' +import { EventTemplateKey } from '~/features/events/model/event-template.type' import * as m from '~/i18n/paraglide/messages' import type { locales } from '~/i18n/paraglide/runtime' @@ -12,7 +12,7 @@ interface PartDefinition { allowExternalSpeaker: boolean } -interface ServiceRoleDefinition { +interface ServicePartDefinition { name: string key: string } @@ -26,10 +26,10 @@ interface TemplateDefinition { endTime: string color: string parts: PartDefinition[] - serviceRoles: ServiceRoleDefinition[] + serviceParts: ServicePartDefinition[] } -function getSharedServiceRoles(locale: Locale): ServiceRoleDefinition[] { +function getSharedServiceParts(locale: Locale): ServicePartDefinition[] { return [ { name: m.seed_service_sound({}, { locale }), key: 'sono' }, { name: m.seed_service_stage({}, { locale }), key: 'stage' }, @@ -39,12 +39,12 @@ function getSharedServiceRoles(locale: Locale): ServiceRoleDefinition[] { } function getTemplates(locale: Locale): TemplateDefinition[] { - const sharedServiceRoles = getSharedServiceRoles(locale) + const sharedServiceParts = getSharedServiceParts(locale) return [ { name: m.seed_template_midweek({}, { locale }), - key: ProgrammeTemplateKey.MidweekMeeting, + key: EventTemplateKey.MidweekMeeting, weekDay: 2, // Tuesday isRecurring: true, startTime: '19:00', @@ -130,11 +130,11 @@ function getTemplates(locale: Locale): TemplateDefinition[] { allowExternalSpeaker: false, }, ], - serviceRoles: sharedServiceRoles, + serviceParts: sharedServiceParts, }, { name: m.seed_template_weekend({}, { locale }), - key: ProgrammeTemplateKey.WeekendMeeting, + key: EventTemplateKey.WeekendMeeting, weekDay: 6, // Saturday isRecurring: true, startTime: '10:00', @@ -171,11 +171,11 @@ function getTemplates(locale: Locale): TemplateDefinition[] { allowExternalSpeaker: false, }, ], - serviceRoles: sharedServiceRoles, + serviceParts: sharedServiceParts, }, { name: m.seed_template_memorial({}, { locale }), - key: ProgrammeTemplateKey.Memorial, + key: EventTemplateKey.Memorial, weekDay: null, isRecurring: false, startTime: '19:00', @@ -218,29 +218,29 @@ function getTemplates(locale: Locale): TemplateDefinition[] { allowExternalSpeaker: false, }, ], - serviceRoles: sharedServiceRoles, + serviceParts: sharedServiceParts, }, { name: m.seed_template_day_off({}, { locale }), - key: ProgrammeTemplateKey.DayOff, + key: EventTemplateKey.DayOff, weekDay: null, isRecurring: false, startTime: '00:00', endTime: '23:59', color: '#cfcfcf', parts: [], - serviceRoles: [], + serviceParts: [], }, { name: m.seed_template_freeform({}, { locale }), - key: ProgrammeTemplateKey.Freeform, + key: EventTemplateKey.Freeform, weekDay: null, isRecurring: false, startTime: '19:00', endTime: '21:00', color: '#6366f1', parts: [], - serviceRoles: [], + serviceParts: [], }, ] } @@ -248,13 +248,13 @@ function getTemplates(locale: Locale): TemplateDefinition[] { // biome-ignore lint/suspicious/noExplicitAny: accepts both PrismaClient and unscoped db export async function seedDefaultTemplates(db: any, congregationId: number, locale: Locale) { for (const tpl of getTemplates(locale)) { - const existing = await db.programmeTemplate.findFirst({ + const existing = await db.eventTemplate.findFirst({ where: { key: tpl.key, congregationId }, }) if (existing) continue - await db.programmeTemplate.create({ + await db.eventTemplate.create({ data: { name: tpl.name, key: tpl.key, @@ -274,8 +274,8 @@ export async function seedDefaultTemplates(db: any, congregationId: number, loca congregationId, })), }, - serviceRoles: { - create: tpl.serviceRoles.map(role => ({ + serviceParts: { + create: tpl.serviceParts.map(role => ({ name: role.name, key: role.key, congregationId, diff --git a/app/features/events/ui/ProgrammeBoardDocument.tsx b/app/features/events/ui/EventBoardDocument.tsx similarity index 95% rename from app/features/events/ui/ProgrammeBoardDocument.tsx rename to app/features/events/ui/EventBoardDocument.tsx index dbc63d19..e251993d 100644 --- a/app/features/events/ui/ProgrammeBoardDocument.tsx +++ b/app/features/events/ui/EventBoardDocument.tsx @@ -1,7 +1,7 @@ import path from 'node:path' import { Document, Font, Page, StyleSheet, Text, View } from '@react-pdf/renderer' import { groupPartsBySlot } from '~/features/events/model/group-parts-by-slot' -import type { ExportEvent, TemplateExportConfig } from '~/features/events/server/programme-export.server' +import type { ExportEvent, TemplateExportConfig } from '~/features/events/server/event-export.server' import { formatMemberName, getPartAssigneeDisplay } from '~/features/events/ui/part-display' import { sanitizeText } from '~/shared/utils/sanitize-text' @@ -38,7 +38,7 @@ function formatDate(date: Date | string): string { return d.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) } -interface ProgrammeBoardDocumentProps { +interface EventBoardDocumentProps { events: ExportEvent[] configMap: Map> groupBy: 'date' | 'template' @@ -46,7 +46,7 @@ interface ProgrammeBoardDocumentProps { congregationName: string } -type PartAssignment = ExportEvent['partAssignments'][number] +type PartAssignment = ExportEvent['eventParts'][number] const styles = StyleSheet.create({ page: { @@ -210,7 +210,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', paddingVertical: 2, }, - serviceRoleName: { + servicePartName: { fontSize: 8, color: '#64748b', width: '45%', @@ -244,13 +244,7 @@ const styles = StyleSheet.create({ }, }) -export function ProgrammeBoardDocument({ - events, - configMap, - groupBy, - title, - congregationName, -}: ProgrammeBoardDocumentProps) { +export function EventBoardDocument({ events, configMap, groupBy, title, congregationName }: EventBoardDocumentProps) { ensureFontsRegistered() const orderedEvents = @@ -310,7 +304,7 @@ function EventCard({ showParts: boolean showServices: boolean }) { - const sectionGroups = groupPartsBySlot(event.partAssignments) + const sectionGroups = groupPartsBySlot(event.eventParts) return ( @@ -331,15 +325,15 @@ function EventCard({ ))} - {showServices && event.serviceRoleAssignments.length > 0 && ( + {showServices && event.eventServiceParts.length > 0 && ( Services - {event.serviceRoleAssignments.map((role, roleIdx) => { + {event.eventServiceParts.map((role, roleIdx) => { const name = formatMemberName(role.assignee) return ( - {role.name} + {role.name} {name ? {name} : } ) diff --git a/app/features/events/ui/ProgrammeDocument.tsx b/app/features/events/ui/EventDocument.tsx similarity index 93% rename from app/features/events/ui/ProgrammeDocument.tsx rename to app/features/events/ui/EventDocument.tsx index bc17d8ce..400dd0fc 100644 --- a/app/features/events/ui/ProgrammeDocument.tsx +++ b/app/features/events/ui/EventDocument.tsx @@ -42,7 +42,7 @@ interface PartAssignment { externalSpeaker: { name: string } | null } -interface ServiceRoleAssignment { +interface ServicePartAssignment { name: string assignee: { firstname: string | null; lastname: string | null } | null } @@ -50,11 +50,11 @@ interface ServiceRoleAssignment { interface ProgrammeEvent { name: string startDate: Date | string - partAssignments: PartAssignment[] - serviceRoleAssignments: ServiceRoleAssignment[] + eventParts: PartAssignment[] + eventServiceParts: ServicePartAssignment[] } -interface ProgrammeDocumentProps { +interface EventDocumentProps { events: ProgrammeEvent[] title: string showParts: boolean @@ -177,7 +177,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', paddingVertical: 2, }, - serviceRoleName: { + servicePartName: { fontSize: 7.5, color: '#64748b', width: '45%', @@ -221,7 +221,7 @@ function formatDate(date: Date | string): string { return d.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }) } -export function ProgrammeDocument({ events, title, showParts, showServices }: ProgrammeDocumentProps) { +export function EventDocument({ events, title, showParts, showServices }: EventDocumentProps) { const pages: ProgrammeEvent[][] = [] for (let i = 0; i < events.length; i += 2) { pages.push(events.slice(i, i + 2)) @@ -267,7 +267,7 @@ function EventBlock({ showParts: boolean showServices: boolean }) { - const sortedParts = [...event.partAssignments].sort((a, b) => a.order - b.order) + const sortedParts = [...event.eventParts].sort((a, b) => a.order - b.order) // Group parts by section for colored headers const groupedParts: { section: string; parts: PartAssignment[] }[] = [] @@ -309,15 +309,15 @@ function EventBlock({ ) })} - {showServices && event.serviceRoleAssignments.length > 0 && ( + {showServices && event.eventServiceParts.length > 0 && ( Services - {event.serviceRoleAssignments.map((role, roleIdx) => { + {event.eventServiceParts.map((role, roleIdx) => { const name = formatMemberName(role.assignee) return ( - {role.name} + {role.name} {name ? {name} : } ) diff --git a/app/features/events/ui/EventServicesCard.tsx b/app/features/events/ui/EventServicesCard.tsx index 795bf342..c89e28ac 100644 --- a/app/features/events/ui/EventServicesCard.tsx +++ b/app/features/events/ui/EventServicesCard.tsx @@ -4,16 +4,16 @@ import { Button } from '~/shared/ui/button' import { Card, CardAction, CardContent, CardHeader, CardTitle } from '~/shared/ui/card' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/shared/ui/table' -export type ServiceRoleAssignment = { +export type ServicePartAssignment = { id: number name: string allowedRoleIds: number[] } type EventServicesCardProps = { - services: ServiceRoleAssignment[] + services: ServicePartAssignment[] onAddService: () => void - onEditService: (service: ServiceRoleAssignment) => void + onEditService: (service: ServicePartAssignment) => void onDeleteService: (service: { id: number; name: string }) => void } diff --git a/app/features/notifications/server/handle-notification-email.server.test.ts b/app/features/notifications/server/handle-notification-email.server.test.ts index d3450860..99a1aa29 100644 --- a/app/features/notifications/server/handle-notification-email.server.test.ts +++ b/app/features/notifications/server/handle-notification-email.server.test.ts @@ -327,7 +327,7 @@ describe('handleDigestEmail — transient mailer failures partition per event', events: [ { type: 'programme.assignment.assigned', - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: 501, payload: JSON.stringify({ eventId: 1, @@ -340,7 +340,7 @@ describe('handleDigestEmail — transient mailer failures partition per event', }, { type: 'programme.assignment.assigned', - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: 502, payload: JSON.stringify({ eventId: 1, @@ -353,7 +353,7 @@ describe('handleDigestEmail — transient mailer failures partition per event', }, { type: 'programme.assignment.assigned', - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: 503, payload: JSON.stringify({ eventId: 1, @@ -553,7 +553,7 @@ describe('handleDigestEmail — success / failure partitioning', () => { events: [ { type: 'programme.assignment.assigned', - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: 500, payload: JSON.stringify({ eventId: 1, @@ -590,7 +590,7 @@ describe('handleDigestEmail — success / failure partitioning', () => { events: [ { type: 'programme.assignment.assigned', - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: 500, payload: JSON.stringify({ eventId: 1, @@ -702,7 +702,7 @@ describe('handleDigestEmail — recipient lookup misses', () => { events: [ { type: 'programme.assignment.assigned', - entityType: 'ProgrammePartAssignment', + entityType: 'EventPart', entityId: 700, payload: JSON.stringify({ eventId: 1, diff --git a/app/features/settings/routes/congregation/template-list.tsx b/app/features/settings/routes/congregation/template-list.tsx index 4470e94f..ab874ba6 100644 --- a/app/features/settings/routes/congregation/template-list.tsx +++ b/app/features/settings/routes/congregation/template-list.tsx @@ -74,7 +74,7 @@ export default function TemplateListPage({ loaderData }: Route.ComponentProps) { )} {template._count.parts} - {template._count.serviceRoles} + {template._count.serviceParts} {template.weekDay != null ? ( {dayLabelShort(template.weekDay)} diff --git a/app/features/settings/routes/congregation/templates/edit.tsx b/app/features/settings/routes/congregation/templates/edit.tsx index 9fefe028..757b81de 100644 --- a/app/features/settings/routes/congregation/templates/edit.tsx +++ b/app/features/settings/routes/congregation/templates/edit.tsx @@ -8,19 +8,19 @@ import { commitSession, getSession } from '~/features/authentication/index.serve import { InlineDeleteDialog, isSystemTemplate, PartEditSheet, ServiceEditSheet, SortableRow } from '~/features/events' import { deleteTemplatePart, - deleteTemplateServiceRole, + deleteTemplateServicePart, getTemplateById, isTemplateResponsible, updateTemplate, upsertTemplatePart, - upsertTemplateServiceRole, + upsertTemplateServicePart, } from '~/features/events/index.server' import { deletePartSchema, - deleteServiceRoleSchema, + deleteServicePartSchema, updateTemplateSchema, upsertPartSchema, - upsertServiceRoleSchema, + upsertServicePartSchema, } from '~/features/settings/schemas/template.schema' import * as m from '~/i18n/paraglide/messages' import { currentAccountContext, permissionsContext, withScopeFromContext } from '~/shared/auth/route-context.server' @@ -61,16 +61,16 @@ export function loader({ params, context }: Route.LoaderArgs) { const responsible = await isTemplateResponsible(db, templateId, currentUser.id, currentUser.congregationId) if (!permissions.has(Permission.ProgramManager) && !responsible) throw redirect('/settings/congregation/templates') - const partAllowedRoles = await db.programmeTemplatePartAllowedRole.findMany({ + const partAllowedRoles = await db.templatePartAllowedRole.findMany({ where: { partId: { in: template.parts.map(p => p.id) }, congregationId: currentUser.congregationId }, select: { partId: true, roleId: true, asKind: true }, }) - const serviceRoleAllowed = await db.programmeTemplateServiceRoleAllowedRole.findMany({ + const servicePartAllowed = await db.templateServicePartAllowedRole.findMany({ where: { - serviceRoleId: { in: template.serviceRoles.map(r => r.id) }, + servicePartId: { in: template.serviceParts.map(r => r.id) }, congregationId: currentUser.congregationId, }, - select: { serviceRoleId: true, roleId: true }, + select: { servicePartId: true, roleId: true }, }) const partsWithRoles = template.parts.map(p => ({ @@ -80,9 +80,9 @@ export function loader({ params, context }: Route.LoaderArgs) { .map(r => r.roleId), allowedReaderRoleIds: partAllowedRoles.filter(r => r.partId === p.id && r.asKind === 'reader').map(r => r.roleId), })) - const serviceRolesWithRoles = template.serviceRoles.map(r => ({ + const servicePartsWithRoles = template.serviceParts.map(r => ({ ...r, - allowedRoleIds: serviceRoleAllowed.filter(s => s.serviceRoleId === r.id).map(s => s.roleId), + allowedRoleIds: servicePartAllowed.filter(s => s.servicePartId === r.id).map(s => s.roleId), })) const roles = allRoles.map(r => ({ id: r.id, key: r.key, name: r.name, isBuiltIn: r.isBuiltIn })) @@ -91,7 +91,7 @@ export function loader({ params, context }: Route.LoaderArgs) { const trackSuggestions = distinct(template.parts.map(p => p.track)) return { - template: { ...template, parts: partsWithRoles, serviceRoles: serviceRolesWithRoles }, + template: { ...template, parts: partsWithRoles, serviceParts: servicePartsWithRoles }, isSystem: isSystemTemplate(template.key), roles, sectionSuggestions, @@ -120,7 +120,7 @@ export async function action({ request, params, context }: Route.ActionArgs) { // deleting parts or service roles — is rejected outright, matching the // read-only UI. Flashes an error so a stale form submitting `delete-part` // gets a visible reason rather than a silent redirect. - const guardTarget = await db.programmeTemplate.findFirst({ + const guardTarget = await db.eventTemplate.findFirst({ where: { id: templateId, congregationId: currentUser.congregationId }, select: { key: true }, }) @@ -154,7 +154,7 @@ export async function action({ request, params, context }: Route.ActionArgs) { if (partResult && 'reply' in partResult) return data(partResult.reply(), { status: 400 }) if (partResult?.message) session.flash('success', partResult.message) - const serviceResult = await handleServiceRoleIntent( + const serviceResult = await handleServicePartIntent( intent, formData, db, @@ -229,7 +229,7 @@ async function handlePartIntent( return null } -async function handleServiceRoleIntent( +async function handleServicePartIntent( intent: FormDataEntryValue | null, formData: FormData, db: TransactionClient, @@ -238,7 +238,7 @@ async function handleServiceRoleIntent( actorId: number, ): Promise { if (intent === 'upsert-service-role') { - const submission = parseWithZod(formData, { schema: upsertServiceRoleSchema }) + const submission = parseWithZod(formData, { schema: upsertServicePartSchema }) if (submission.status !== 'success') return submission const { roleId, roleName, roleKey, allowedRoleIds } = submission.value @@ -248,7 +248,7 @@ async function handleServiceRoleIntent( .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') - await upsertTemplateServiceRole( + await upsertTemplateServicePart( db, templateId, { id: roleId, name: roleName, key, allowedRoleIds }, @@ -260,10 +260,10 @@ async function handleServiceRoleIntent( } } if (intent === 'delete-service-role') { - const submission = parseWithZod(formData, { schema: deleteServiceRoleSchema }) + const submission = parseWithZod(formData, { schema: deleteServicePartSchema }) if (submission.status !== 'success') return submission - await deleteTemplateServiceRole(db, submission.value.roleId, congregationId) + await deleteTemplateServicePart(db, submission.value.roleId, congregationId) return { message: m.settings_template_edit_service_role_deleted() } } return null @@ -557,7 +557,7 @@ export default function TemplateEditPage({ loaderData }: Route.ComponentProps) { - {template.serviceRoles.length > 0 ? ( + {template.serviceParts.length > 0 ? ( @@ -566,7 +566,7 @@ export default function TemplateEditPage({ loaderData }: Route.ComponentProps) { - {template.serviceRoles.map(role => ( + {template.serviceParts.map(role => ( {role.name} diff --git a/app/features/settings/routes/congregation/templates/new.tsx b/app/features/settings/routes/congregation/templates/new.tsx index 22757547..43bea57a 100644 --- a/app/features/settings/routes/congregation/templates/new.tsx +++ b/app/features/settings/routes/congregation/templates/new.tsx @@ -2,7 +2,7 @@ import { parseWithZod } from '@conform-to/zod' import { data, Form, redirect } from 'react-router' import { commitSession, getSession } from '~/features/authentication/index.server' import { createTemplateSchema } from '~/features/settings/schemas/template.schema' -import { createProgrammeTemplate } from '~/features/settings/server/programme-template.server' +import { createEventTemplate } from '~/features/settings/server/event-template.server' import * as m from '~/i18n/paraglide/messages' import { currentAccountContext, @@ -47,7 +47,7 @@ export async function action({ request, context }: Route.ActionArgs) { const session = await getSession(request.headers.get('Cookie')) return withScopeFromContext(context, async db => { - const existing = await db.programmeTemplate.findFirst({ + const existing = await db.eventTemplate.findFirst({ where: { key, congregationId: currentUser.congregationId }, }) if (existing) { @@ -57,7 +57,7 @@ export async function action({ request, context }: Route.ActionArgs) { }) } - const template = await createProgrammeTemplate(db, { + const template = await createEventTemplate(db, { name, key, weekDay, diff --git a/app/features/settings/routes/congregation/templates/reorder-parts.tsx b/app/features/settings/routes/congregation/templates/reorder-parts.tsx index a8e7aa06..2bf4fd98 100644 --- a/app/features/settings/routes/congregation/templates/reorder-parts.tsx +++ b/app/features/settings/routes/congregation/templates/reorder-parts.tsx @@ -33,7 +33,7 @@ export async function action({ request, params, context }: Route.ActionArgs) { // Mirror the guard on /edit — system templates have no parts today, but // if that ever changes we don't want a stale endpoint to be the loose // thread. - const target = await db.programmeTemplate.findFirst({ + const target = await db.eventTemplate.findFirst({ where: { id: templateId, congregationId }, select: { key: true }, }) diff --git a/app/features/settings/routes/congregation/templates/view.tsx b/app/features/settings/routes/congregation/templates/view.tsx index 8fbd0192..497a448d 100644 --- a/app/features/settings/routes/congregation/templates/view.tsx +++ b/app/features/settings/routes/congregation/templates/view.tsx @@ -188,7 +188,7 @@ export default function TemplateViewPage({ loaderData }: Route.ComponentProps) {
- {template.serviceRoles.map(role => ( + {template.serviceParts.map(role => ( {role.name} diff --git a/app/features/settings/schemas/template.schema.ts b/app/features/settings/schemas/template.schema.ts index f8c2c8a9..2d9f3e9a 100644 --- a/app/features/settings/schemas/template.schema.ts +++ b/app/features/settings/schemas/template.schema.ts @@ -68,7 +68,7 @@ export const deletePartSchema = z.object({ partId: z.coerce.number(), }) -export const upsertServiceRoleSchema = z.object({ +export const upsertServicePartSchema = z.object({ intent: z.literal('upsert-service-role'), roleId: z.coerce.number().optional(), roleName: z.string().min(1), @@ -76,7 +76,7 @@ export const upsertServiceRoleSchema = z.object({ allowedRoleIds: roleIdsField.default([]), }) -export const deleteServiceRoleSchema = z.object({ +export const deleteServicePartSchema = z.object({ intent: z.literal('delete-service-role'), roleId: z.coerce.number(), }) diff --git a/app/features/settings/server/data-transfer.integration.test.ts b/app/features/settings/server/data-transfer.integration.test.ts index 63398c0f..d2d3735f 100644 --- a/app/features/settings/server/data-transfer.integration.test.ts +++ b/app/features/settings/server/data-transfer.integration.test.ts @@ -143,7 +143,7 @@ beforeAll(async () => { }, }) - const template = await tx.programmeTemplate.create({ + const template = await tx.eventTemplate.create({ data: { name: 'Test Template', key: `template-${ts}`, @@ -153,7 +153,7 @@ beforeAll(async () => { }, }) - const part = await tx.programmeTemplatePart.create({ + const part = await tx.templatePart.create({ data: { name: 'Opening', section: 'intro', @@ -164,11 +164,11 @@ beforeAll(async () => { }, }) - const serviceRole = await tx.programmeTemplateServiceRole.create({ + const servicePart = await tx.templateServicePart.create({ data: { name: 'Sound', key: `sound-${ts}`, templateId: template.id, congregationId: sourceId }, }) - await tx.programmeTemplateResponsible.create({ + await tx.templateResponsible.create({ data: { templateId: template.id, userId: alice.id, congregationId: sourceId }, }) @@ -183,7 +183,7 @@ beforeAll(async () => { }, }) - await tx.programmePartAssignment.create({ + await tx.eventPart.create({ data: { eventId: event.id, partId: part.id, @@ -197,10 +197,10 @@ beforeAll(async () => { }, }) - const serviceRoleAssignment = await tx.programmeServiceRoleAssignment.create({ + const servicePartAssignment = await tx.eventServicePart.create({ data: { eventId: event.id, - serviceRoleId: serviceRole.id, + servicePartId: servicePart.id, assigneeId: bobMember.id, name: 'Sound', congregationId: sourceId, @@ -273,12 +273,12 @@ beforeAll(async () => { }) // Wire the external speaker into the existing part-assignment so the FK link survives import. - const partAssignment = await tx.programmePartAssignment.findFirst({ + const partAssignment = await tx.eventPart.findFirst({ where: { eventId: event.id }, select: { id: true }, }) if (partAssignment != null) { - await tx.programmePartAssignment.update({ + await tx.eventPart.update({ where: { id: partAssignment.id }, data: { allowExternalSpeaker: true, externalSpeakerId: externalSpeaker.id, trackOrder: 2 }, }) @@ -286,11 +286,11 @@ beforeAll(async () => { // Give the template a distinctive colour and give the part a trackOrder // to cover the adjacent leakage gaps. - await tx.programmeTemplate.update({ + await tx.eventTemplate.update({ where: { id: template.id }, data: { color: '#ff0000' }, }) - await tx.programmeTemplatePart.update({ + await tx.templatePart.update({ where: { id: part.id }, data: { trackOrder: 1 }, }) @@ -303,18 +303,18 @@ beforeAll(async () => { data: { paths: [[{ lat: 48.8, lng: 2.3 }]], congregationId: sourceId }, }) - await tx.programmeTemplatePartAllowedRole.create({ + await tx.templatePartAllowedRole.create({ data: { partId: part.id, roleId: elderRole.id, asKind: 'speaker', congregationId: sourceId }, }) - await tx.programmeTemplateServiceRoleAllowedRole.create({ - data: { serviceRoleId: serviceRole.id, roleId: elderRole.id, congregationId: sourceId }, + await tx.templateServicePartAllowedRole.create({ + data: { servicePartId: servicePart.id, roleId: elderRole.id, congregationId: sourceId }, }) if (partAssignment != null) { - await tx.programmePartAssignmentAllowedRole.create({ + await tx.eventPartAllowedRole.create({ data: { - assignmentId: partAssignment.id, + eventPartId: partAssignment.id, roleId: elderRole.id, asKind: 'speaker', congregationId: sourceId, @@ -322,8 +322,8 @@ beforeAll(async () => { }) } - await tx.programmeServiceRoleAssignmentAllowedRole.create({ - data: { assignmentId: serviceRoleAssignment.id, roleId: elderRole.id, congregationId: sourceId }, + await tx.eventServicePartAllowedRole.create({ + data: { eventServicePartId: servicePartAssignment.id, roleId: elderRole.id, congregationId: sourceId }, }) await tx.boardSectionVisibilityRole.create({ @@ -355,18 +355,18 @@ afterAll(async () => { // v1.1 join tables — must precede their parents. Most cascade on delete, but explicit // ordering keeps cleanup deterministic across ad-hoc reruns. await tx.boardSectionVisibilityRole.deleteMany({}) - await tx.programmeServiceRoleAssignmentAllowedRole.deleteMany({}) - await tx.programmePartAssignmentAllowedRole.deleteMany({}) - await tx.programmeTemplateServiceRoleAllowedRole.deleteMany({}) - await tx.programmeTemplatePartAllowedRole.deleteMany({}) - await tx.programmeServiceRoleAssignment.deleteMany({}) - await tx.programmePartAssignment.deleteMany({}) + await tx.eventServicePartAllowedRole.deleteMany({}) + await tx.eventPartAllowedRole.deleteMany({}) + await tx.templateServicePartAllowedRole.deleteMany({}) + await tx.templatePartAllowedRole.deleteMany({}) + await tx.eventServicePart.deleteMany({}) + await tx.eventPart.deleteMany({}) await tx.externalSpeaker.deleteMany({}) - await tx.programmeTemplateResponsible.deleteMany({}) - await tx.programmeTemplateServiceRole.deleteMany({}) - await tx.programmeTemplatePart.deleteMany({}) + await tx.templateResponsible.deleteMany({}) + await tx.templateServicePart.deleteMany({}) + await tx.templatePart.deleteMany({}) await tx.event.deleteMany({}) - await tx.programmeTemplate.deleteMany({}) + await tx.eventTemplate.deleteMany({}) await tx.consentRecord.deleteMany({}) await tx.boardDynamicDocumentSettings.deleteMany({}) await tx.boardDocumentVersion.deleteMany({}) @@ -474,17 +474,17 @@ async function importFromZip(buffer: Buffer, congregationId: number): Promise { // Delete source users so the emails are free for import (simulates cross-instance migration) await withScope(sourceId, async tx => { await tx.boardSectionVisibilityRole.deleteMany({}) - await tx.programmeServiceRoleAssignmentAllowedRole.deleteMany({}) - await tx.programmePartAssignmentAllowedRole.deleteMany({}) - await tx.programmeTemplateServiceRoleAllowedRole.deleteMany({}) - await tx.programmeTemplatePartAllowedRole.deleteMany({}) - await tx.programmeServiceRoleAssignment.deleteMany({}) - await tx.programmePartAssignment.deleteMany({}) + await tx.eventServicePartAllowedRole.deleteMany({}) + await tx.eventPartAllowedRole.deleteMany({}) + await tx.templateServicePartAllowedRole.deleteMany({}) + await tx.templatePartAllowedRole.deleteMany({}) + await tx.eventServicePart.deleteMany({}) + await tx.eventPart.deleteMany({}) await tx.externalSpeaker.deleteMany({}) - await tx.programmeTemplateResponsible.deleteMany({}) - await tx.programmeTemplateServiceRole.deleteMany({}) - await tx.programmeTemplatePart.deleteMany({}) + await tx.templateResponsible.deleteMany({}) + await tx.templateServicePart.deleteMany({}) + await tx.templatePart.deleteMany({}) await tx.event.deleteMany({}) - await tx.programmeTemplate.deleteMany({}) + await tx.eventTemplate.deleteMany({}) await tx.consentRecord.deleteMany({}) await tx.boardDynamicDocumentSettings.deleteMany({}) await tx.boardDocumentVersion.deleteMany({}) @@ -691,17 +691,17 @@ describe('Export/Import round-trip', () => { expect(activities[0].publisherId).toBe(aliceMember.id) // Programme templates + parts + service roles + responsibles - const templates = await tx.programmeTemplate.findMany({}) + const templates = await tx.eventTemplate.findMany({}) expect(templates).toHaveLength(1) // colour survived the round-trip expect(templates[0].color).toBe('#ff0000') - const parts = await tx.programmeTemplatePart.findMany({}) - expect(parts).toHaveLength(1) - expect(parts[0].templateId).toBe(templates[0].id) - expect(parts[0].trackOrder).toBe(1) - const serviceRoles = await tx.programmeTemplateServiceRole.findMany({}) - expect(serviceRoles).toHaveLength(1) - const responsibles = await tx.programmeTemplateResponsible.findMany({}) + const templateParts = await tx.templatePart.findMany({}) + expect(templateParts).toHaveLength(1) + expect(templateParts[0].templateId).toBe(templates[0].id) + expect(templateParts[0].trackOrder).toBe(1) + const serviceParts = await tx.templateServicePart.findMany({}) + expect(serviceParts).toHaveLength(1) + const responsibles = await tx.templateResponsible.findMany({}) expect(responsibles).toHaveLength(1) expect(responsibles[0].userId).toBe(alice.id) @@ -709,14 +709,14 @@ describe('Export/Import round-trip', () => { const events = await tx.event.findMany({}) expect(events).toHaveLength(1) expect(events[0].createdById).toBe(alice.id) - const partAssignments = await tx.programmePartAssignment.findMany({}) - expect(partAssignments).toHaveLength(1) - expect(partAssignments[0].assigneeId).toBe(aliceMember.id) - expect(partAssignments[0].assistantId).toBe(bobMember.id) - expect(partAssignments[0].topic).toBe('Welcome') - expect(partAssignments[0].trackOrder).toBe(2) - expect(partAssignments[0].allowExternalSpeaker).toBe(true) - const srAssignments = await tx.programmeServiceRoleAssignment.findMany({}) + const eventParts = await tx.eventPart.findMany({}) + expect(eventParts).toHaveLength(1) + expect(eventParts[0].assigneeId).toBe(aliceMember.id) + expect(eventParts[0].assistantId).toBe(bobMember.id) + expect(eventParts[0].topic).toBe('Welcome') + expect(eventParts[0].trackOrder).toBe(2) + expect(eventParts[0].allowExternalSpeaker).toBe(true) + const srAssignments = await tx.eventServicePart.findMany({}) expect(srAssignments).toHaveLength(1) expect(srAssignments[0].assigneeId).toBe(bobMember.id) @@ -756,7 +756,7 @@ describe('Export/Import round-trip', () => { const speakers = await tx.externalSpeaker.findMany({}) expect(speakers).toHaveLength(1) expect(speakers[0].name).toBe('Frère Visiteur') - expect(partAssignments[0].externalSpeakerId).toBe(speakers[0].id) + expect(eventParts[0].externalSpeakerId).toBe(speakers[0].id) // Territory card overlay + perimeter const overlays = await tx.territoryCardOverlay.findMany({}) @@ -767,24 +767,24 @@ describe('Export/Import round-trip', () => { // Allowed-role join rows — anchor onto the elder built-in role on target. const elderOnTarget = roles.find(r => r.key === 'elder')! - const partAllowed = await tx.programmeTemplatePartAllowedRole.findMany({}) + const partAllowed = await tx.templatePartAllowedRole.findMany({}) expect(partAllowed).toHaveLength(1) - expect(partAllowed[0].partId).toBe(parts[0].id) + expect(partAllowed[0].partId).toBe(templateParts[0].id) expect(partAllowed[0].roleId).toBe(elderOnTarget.id) expect(partAllowed[0].asKind).toBe('speaker') - const serviceRoleAllowed = await tx.programmeTemplateServiceRoleAllowedRole.findMany({}) - expect(serviceRoleAllowed).toHaveLength(1) - expect(serviceRoleAllowed[0].serviceRoleId).toBe(serviceRoles[0].id) + const servicePartAllowed = await tx.templateServicePartAllowedRole.findMany({}) + expect(servicePartAllowed).toHaveLength(1) + expect(servicePartAllowed[0].servicePartId).toBe(serviceParts[0].id) - const partAssignmentAllowed = await tx.programmePartAssignmentAllowedRole.findMany({}) + const partAssignmentAllowed = await tx.eventPartAllowedRole.findMany({}) expect(partAssignmentAllowed).toHaveLength(1) - expect(partAssignmentAllowed[0].assignmentId).toBe(partAssignments[0].id) + expect(partAssignmentAllowed[0].eventPartId).toBe(eventParts[0].id) expect(partAssignmentAllowed[0].asKind).toBe('speaker') - const serviceRoleAssignmentAllowed = await tx.programmeServiceRoleAssignmentAllowedRole.findMany({}) - expect(serviceRoleAssignmentAllowed).toHaveLength(1) - expect(serviceRoleAssignmentAllowed[0].assignmentId).toBe(srAssignments[0].id) + const servicePartAssignmentAllowed = await tx.eventServicePartAllowedRole.findMany({}) + expect(servicePartAssignmentAllowed).toHaveLength(1) + expect(servicePartAssignmentAllowed[0].eventServicePartId).toBe(srAssignments[0].id) // Board section visibility role const visibility = await tx.boardSectionVisibilityRole.findMany({}) @@ -937,3 +937,106 @@ describe('Export cross-congregation isolation', () => { } }) }) + +// AuditLog `entityType` values from older archives use pre-rename Prisma +// model names. The importer's `rewriteLegacyEntityType` shim must map them +// to the current names on write — otherwise `AuditLog` rows land with dead +// entity strings and any UI that groups history by entity type breaks +// silently. Round-trips a synthetic archive containing one row per legacy +// name (both pre-2.1 Programme* and 2.1-era ServiceRole*) through the real +// `importAuditLogs` path and asserts every written row carries the current +// model name. +describe('AuditLog importer rewrites legacy entityType strings', () => { + it('rewrites pre-2.1 Programme* and 2.1 ServiceRole* entityTypes on import', async () => { + // The mapping is intentionally exhaustive — every string that could + // appear in a legacy archive plus one current name (Member) to prove + // non-legacy strings pass through untouched. + const cases: { legacy: string; expected: string }[] = [ + { legacy: 'ProgrammeTemplate', expected: 'EventTemplate' }, + { legacy: 'ProgrammeTemplatePart', expected: 'TemplatePart' }, + { legacy: 'ProgrammeTemplateServiceRole', expected: 'TemplateServicePart' }, + { legacy: 'ProgrammePartAssignment', expected: 'EventPart' }, + { legacy: 'ProgrammeServiceRoleAssignment', expected: 'EventServicePart' }, + { legacy: 'ProgrammeTemplateResponsible', expected: 'TemplateResponsible' }, + { legacy: 'TemplateServiceRole', expected: 'TemplateServicePart' }, + { legacy: 'EventServiceRole', expected: 'EventServicePart' }, + { legacy: 'Member', expected: 'Member' }, + ] + + // Isolated congregation so we can assert on the exact rows we seeded + // without noise from beforeAll-created data. + const cong = await testDb.congregation.create({ + data: { name: `Audit ${ts}`, slug: `audit-${ts}`, active: true }, + }) + + // Seed a UserAccount in the target congregation so the actorId remap + // path has a hit — the importer calls `idMap.getOptional('user-accounts', + // record.actorId)` and would silently null-out actors if the mapping + // didn't wire up. Row 0 uses this mapped actor; rows 1..N-1 use + // `actorId: null` (also a valid archive shape). Row N maps to an actor + // that isn't in `idMap` — expected to land as `actorId: null`. + let sourceActorId = 500 + let targetActorId = 0 + try { + const targetAlice = await testDb.userAccount.create({ + data: { + email: `audit-alice-${ts}@test.com`, + password: 'x', + active: true, + congregationId: cong.id, + }, + }) + targetActorId = targetAlice.id + + const idMap = new EntityIdMap() + idMap.set('user-accounts', sourceActorId, targetActorId) + const unmappedSourceActorId = 999 + + const zip = new JsZip() + const ndjsonLines = cases.map((c, i) => + JSON.stringify({ + id: 1000 + i, + action: 'legacy.action', + entityType: c.legacy, + entityId: 42, + actorId: i === 0 ? sourceActorId : i === cases.length - 1 ? unmappedSourceActorId : null, + actorEmail: null, + metadata: null, + createdAt: '2026-01-01T00:00:00.000Z', + }), + ) + zip.file('data/audit-logs.ndjson', `${ndjsonLines.join('\n')}\n`) + + const { importAuditLogs } = await import('./import-audit-consent.server') + await withScope(cong.id, async db => { + await importAuditLogs(zip, db, idMap, cong.id) + }) + + const rows = await testDb.auditLog.findMany({ + where: { congregationId: cong.id, action: 'legacy.action' }, + select: { entityType: true, entityId: true, actorId: true }, + // Read order matches the seed order (id monotonic via `1000 + i`) + // so `rows[i]` corresponds to `cases[i]`. + orderBy: { entityId: 'asc' }, + }) + + expect(rows).toHaveLength(cases.length) + const gotEntityTypes = rows.map(r => r.entityType) + const expectedEntityTypes = cases.map(c => c.expected) + expect(gotEntityTypes).toEqual(expectedEntityTypes) + + // Row 0: mapped source actor → target actor id + expect(rows[0].actorId).toBe(targetActorId) + // Middle rows: null actorId round-trips as null + expect(rows[1].actorId).toBeNull() + // Last row: unmapped source actor lands as null (getOptional fallback) + expect(rows[rows.length - 1].actorId).toBeNull() + } finally { + await testDb.auditLog.deleteMany({ where: { congregationId: cong.id } }) + if (targetActorId > 0) { + await testDb.userAccount.delete({ where: { id: targetActorId } }) + } + await testDb.congregation.delete({ where: { id: cong.id } }) + } + }) +}) diff --git a/app/features/settings/server/data-transfer.type.test.ts b/app/features/settings/server/data-transfer.type.test.ts index c8845a4a..f76306f1 100644 --- a/app/features/settings/server/data-transfer.type.test.ts +++ b/app/features/settings/server/data-transfer.type.test.ts @@ -75,8 +75,8 @@ describe('EntityIdMap', () => { }) describe('ARCHIVE_VERSION', () => { - it('is the current 2.0 schema version', () => { - expect(ARCHIVE_VERSION).toBe('2.0') + it('is the current 2.1 schema version', () => { + expect(ARCHIVE_VERSION).toBe('2.1') }) }) diff --git a/app/features/settings/server/data-transfer.type.ts b/app/features/settings/server/data-transfer.type.ts index c7c1f865..eeeef277 100644 --- a/app/features/settings/server/data-transfer.type.ts +++ b/app/features/settings/server/data-transfer.type.ts @@ -1,4 +1,4 @@ -export const ARCHIVE_VERSION = '2.0' +export const ARCHIVE_VERSION = '2.1' // Older archive versions an import will still accept. v1.0 archives miss every // post-shipping feature table (custom roles, allowed-roles, external speakers, @@ -6,11 +6,13 @@ export const ARCHIVE_VERSION = '2.0' // and proceeds. v1.0 archives created before PR #152 also use the legacy // `congregation-user-roles.ndjson` filename; the import handles that fallback. // v2.0 splits `users.ndjson` into `members.ndjson` + `user-accounts.ndjson` -// and adds `member-role-assignments.ndjson`. v1.x archives are listed here so +// and adds `member-role-assignments.ndjson`. v2.1 renames the domain models +// from `Programme*` to `Event*` / `Template*`; the AuditLog importer rewrites +// pre-2.1 `entityType` strings on the fly. v1.x archives are listed here so // the import path reports them with a warning rather than rejecting outright; // a compatibility shim that splits legacy `users.ndjson` is a deferred // follow-up. -export const SUPPORTED_ARCHIVE_VERSIONS = ['1.0', '1.1', '2.0'] as const +export const SUPPORTED_ARCHIVE_VERSIONS = ['1.0', '1.1', '2.0', '2.1'] as const export interface ManifestJson { version: string diff --git a/app/features/settings/server/programme-template.server.test.ts b/app/features/settings/server/event-template.server.test.ts similarity index 70% rename from app/features/settings/server/programme-template.server.test.ts rename to app/features/settings/server/event-template.server.test.ts index c92bc72f..df3237dd 100644 --- a/app/features/settings/server/programme-template.server.test.ts +++ b/app/features/settings/server/event-template.server.test.ts @@ -1,9 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createProgrammeTemplate } from './programme-template.server' +import { createEventTemplate } from './event-template.server' const mockDb = { - programmeTemplate: { + eventTemplate: { create: vi.fn(), }, } @@ -12,12 +12,12 @@ beforeEach(() => { vi.resetAllMocks() }) -describe('createProgrammeTemplate', () => { +describe('createEventTemplate', () => { it('creates a recurring template when weekDay is provided', async () => { const expected = { id: 1, name: 'Reunion', key: 'meeting', weekDay: 3, isRecurring: true } - mockDb.programmeTemplate.create.mockResolvedValue(expected) + mockDb.eventTemplate.create.mockResolvedValue(expected) - const result = await createProgrammeTemplate(mockDb as never, { + const result = await createEventTemplate(mockDb as never, { name: 'Reunion', key: 'meeting', weekDay: 3, @@ -27,7 +27,7 @@ describe('createProgrammeTemplate', () => { }) expect(result).toEqual(expected) - expect(mockDb.programmeTemplate.create).toHaveBeenCalledWith({ + expect(mockDb.eventTemplate.create).toHaveBeenCalledWith({ data: { name: 'Reunion', key: 'meeting', @@ -42,9 +42,9 @@ describe('createProgrammeTemplate', () => { it('creates a non-recurring template when weekDay is null', async () => { const expected = { id: 2, name: 'Special', key: 'special', weekDay: null, isRecurring: false } - mockDb.programmeTemplate.create.mockResolvedValue(expected) + mockDb.eventTemplate.create.mockResolvedValue(expected) - const result = await createProgrammeTemplate(mockDb as never, { + const result = await createEventTemplate(mockDb as never, { name: 'Special', key: 'special', weekDay: null, @@ -54,7 +54,7 @@ describe('createProgrammeTemplate', () => { }) expect(result).toEqual(expected) - expect(mockDb.programmeTemplate.create).toHaveBeenCalledWith({ + expect(mockDb.eventTemplate.create).toHaveBeenCalledWith({ data: { name: 'Special', key: 'special', diff --git a/app/features/settings/server/programme-template.server.ts b/app/features/settings/server/event-template.server.ts similarity index 85% rename from app/features/settings/server/programme-template.server.ts rename to app/features/settings/server/event-template.server.ts index 80fa8128..9bdb8dbb 100644 --- a/app/features/settings/server/programme-template.server.ts +++ b/app/features/settings/server/event-template.server.ts @@ -1,6 +1,6 @@ import type { TransactionClient } from '~/shared/infra/db.server' -export function createProgrammeTemplate( +export function createEventTemplate( db: TransactionClient, data: { name: string @@ -11,7 +11,7 @@ export function createProgrammeTemplate( congregationId: number }, ) { - return db.programmeTemplate.create({ + return db.eventTemplate.create({ data: { name: data.name, key: data.key, diff --git a/app/features/settings/server/export-congregation.server.ts b/app/features/settings/server/export-congregation.server.ts index 3d2949e5..9a403038 100644 --- a/app/features/settings/server/export-congregation.server.ts +++ b/app/features/settings/server/export-congregation.server.ts @@ -368,7 +368,7 @@ export function buildExportSteps(db: TransactionClient, congregationId: number, { name: 'programme-templates', export: () => - db.programmeTemplate.findMany({ + db.eventTemplate.findMany({ select: { id: true, name: true, @@ -383,7 +383,7 @@ export function buildExportSteps(db: TransactionClient, congregationId: number, { name: 'programme-template-parts', export: () => - db.programmeTemplatePart.findMany({ + db.templatePart.findMany({ select: { id: true, name: true, @@ -400,28 +400,28 @@ export function buildExportSteps(db: TransactionClient, congregationId: number, { name: 'programme-template-part-allowed-roles', export: () => - db.programmeTemplatePartAllowedRole.findMany({ + db.templatePartAllowedRole.findMany({ select: { partId: true, roleId: true, asKind: true }, }), }, { name: 'programme-template-service-roles', export: () => - db.programmeTemplateServiceRole.findMany({ + db.templateServicePart.findMany({ select: { id: true, name: true, key: true, templateId: true }, }), }, { name: 'programme-template-service-role-allowed-roles', export: () => - db.programmeTemplateServiceRoleAllowedRole.findMany({ - select: { serviceRoleId: true, roleId: true }, + db.templateServicePartAllowedRole.findMany({ + select: { servicePartId: true, roleId: true }, }), }, { name: 'programme-template-responsibles', export: () => - db.programmeTemplateResponsible.findMany({ + db.templateResponsible.findMany({ select: { id: true, templateId: true, userId: true }, }), }, @@ -444,7 +444,7 @@ export function buildExportSteps(db: TransactionClient, congregationId: number, { name: 'programme-part-assignments', export: () => - db.programmePartAssignment.findMany({ + db.eventPart.findMany({ select: { id: true, topic: true, @@ -467,32 +467,41 @@ export function buildExportSteps(db: TransactionClient, congregationId: number, }, { name: 'programme-part-assignment-allowed-roles', - export: () => - db.programmePartAssignmentAllowedRole.findMany({ - select: { assignmentId: true, roleId: true, asKind: true }, - }), + // Renaming `eventPartId` back to `assignmentId` on write keeps the NDJSON + // format stable for pre-2.1 importers — the column was renamed in the + // 20260720400000 migration, but the archive schema stays frozen. + export: async () => { + const rows = await db.eventPartAllowedRole.findMany({ + select: { eventPartId: true, roleId: true, asKind: true }, + }) + return rows.map(r => ({ assignmentId: r.eventPartId, roleId: r.roleId, asKind: r.asKind })) + }, }, { name: 'programme-service-role-assignments', export: () => - db.programmeServiceRoleAssignment.findMany({ + db.eventServicePart.findMany({ select: { id: true, note: true, hasConflict: true, name: true, eventId: true, - serviceRoleId: true, + servicePartId: true, assigneeId: true, }, }), }, { name: 'programme-service-role-assignment-allowed-roles', - export: () => - db.programmeServiceRoleAssignmentAllowedRole.findMany({ - select: { assignmentId: true, roleId: true }, - }), + // See comment on programme-part-assignment-allowed-roles above — archive + // field name stays `assignmentId` even after the column rename. + export: async () => { + const rows = await db.eventServicePartAllowedRole.findMany({ + select: { eventServicePartId: true, roleId: true }, + }) + return rows.map(r => ({ assignmentId: r.eventServicePartId, roleId: r.roleId })) + }, }, { name: 'board-sections', diff --git a/app/features/settings/server/import-audit-consent.server.test.ts b/app/features/settings/server/import-audit-consent.server.test.ts new file mode 100644 index 00000000..c4862ade --- /dev/null +++ b/app/features/settings/server/import-audit-consent.server.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { rewriteLegacyEntityType } from './import-audit-consent.server' + +describe('rewriteLegacyEntityType', () => { + // Pre-2.1 archives were exported before the Programme→Event rename. The + // strings on disk are the ORIGINAL Prisma model names (`ProgrammeTemplate`, + // `ProgrammeTemplateServiceRole`, …) — not any interim name from the + // rename PR itself. Assertions below use the exact strings that fresh + // v2.0 archives contain, verified against `git show :app/database/schema.prisma`. + it('rewrites pre-2.1 Programme* entityTypes to current model names', () => { + expect(rewriteLegacyEntityType('ProgrammeTemplate')).toBe('EventTemplate') + expect(rewriteLegacyEntityType('ProgrammeTemplatePart')).toBe('TemplatePart') + expect(rewriteLegacyEntityType('ProgrammeTemplateServiceRole')).toBe('TemplateServicePart') + expect(rewriteLegacyEntityType('ProgrammePartAssignment')).toBe('EventPart') + expect(rewriteLegacyEntityType('ProgrammeServiceRoleAssignment')).toBe('EventServicePart') + expect(rewriteLegacyEntityType('ProgrammeTemplateResponsible')).toBe('TemplateResponsible') + }) + + // 2.1 archives were exported between the Programme→Event rename and the + // ServiceRole→ServicePart follow-up in this same PR. Their `entityType` + // strings for the service-role tables still say `EventServiceRole` / + // `TemplateServiceRole`. Covering these here keeps 2.1 archives importable. + it('rewrites 2.1 ServiceRole entityTypes to ServicePart', () => { + expect(rewriteLegacyEntityType('EventServiceRole')).toBe('EventServicePart') + expect(rewriteLegacyEntityType('TemplateServiceRole')).toBe('TemplateServicePart') + }) + + // Non-legacy entity types must pass through untouched. Otherwise a legacy + // archive with, say, `Member` audit rows would silently drop them. + it('returns unknown entityTypes verbatim', () => { + expect(rewriteLegacyEntityType('Member')).toBe('Member') + expect(rewriteLegacyEntityType('Territory')).toBe('Territory') + expect(rewriteLegacyEntityType('SomethingUnknown')).toBe('SomethingUnknown') + }) + + it('preserves null (AuditLog.entityType is nullable)', () => { + expect(rewriteLegacyEntityType(null)).toBeNull() + }) +}) diff --git a/app/features/settings/server/import-audit-consent.server.ts b/app/features/settings/server/import-audit-consent.server.ts index e7fc6859..6a200d4b 100644 --- a/app/features/settings/server/import-audit-consent.server.ts +++ b/app/features/settings/server/import-audit-consent.server.ts @@ -37,6 +37,35 @@ export async function importConsentRecords( } } +// Pre-2.1 archives store `entityType` under the old `Programme*` model names. +// The `TemplateServiceRole` / `EventServiceRole` entries below are defensive: +// no released 2.1 archive can carry those strings (both renames ship in the +// same PR), but pre-merge / dev / preview archives produced between the two +// rename commits in this PR could — cheap insurance so an in-flight archive +// doesn't need a re-export. The runtime schema and every fresh audit row uses +// the current `Event*` / `Template*` / `EventServicePart` / `TemplateServicePart` +// names, so history exported from any older archive would show a nonexistent +// entity string without a rewrite on import. +const LEGACY_ENTITY_TYPES: Record = { + // Pre-2.1: Programme* model names. + ProgrammeTemplate: 'EventTemplate', + ProgrammeTemplatePart: 'TemplatePart', + ProgrammeTemplateServiceRole: 'TemplateServicePart', + ProgrammePartAssignment: 'EventPart', + ProgrammeServiceRoleAssignment: 'EventServicePart', + ProgrammeTemplateResponsible: 'TemplateResponsible', + // Post-Programme, pre-ServicePart: the ServiceRole tables kept their name + // until the follow-up rename in this same PR. Cover both so a 2.1 archive + // still round-trips cleanly. + TemplateServiceRole: 'TemplateServicePart', + EventServiceRole: 'EventServicePart', +} + +export function rewriteLegacyEntityType(entityType: string | null): string | null { + if (entityType == null) return null + return LEGACY_ENTITY_TYPES[entityType] ?? entityType +} + export async function importAuditLogs( zip: JsZip, db: TransactionClient, @@ -58,7 +87,7 @@ export async function importAuditLogs( await db.auditLog.create({ data: { action: record.action, - entityType: record.entityType, + entityType: rewriteLegacyEntityType(record.entityType), entityId: record.entityId, actorId: idMap.getOptional('user-accounts', record.actorId), actorEmail: record.actorEmail, diff --git a/app/features/settings/server/import-congregation.server.ts b/app/features/settings/server/import-congregation.server.ts index f6f2bd72..c6f1fa0b 100644 --- a/app/features/settings/server/import-congregation.server.ts +++ b/app/features/settings/server/import-congregation.server.ts @@ -30,20 +30,20 @@ import { } from './import-buildings.server' import { importRolePermissions, importRoles, importSettings } from './import-configuration.server' import { - importEvents, - importProgrammePartAssignmentAllowedRoles, - importProgrammePartAssignments, - importProgrammeServiceRoleAssignmentAllowedRoles, - importProgrammeServiceRoleAssignments, -} from './import-programme-events.server' + importEventTemplates, + importTemplatePartAllowedRoles, + importTemplateParts, + importTemplateResponsibles, + importTemplateServicePartAllowedRoles, + importTemplateServiceParts, +} from './import-event-templates.server' import { - importProgrammeTemplatePartAllowedRoles, - importProgrammeTemplateParts, - importProgrammeTemplateResponsibles, - importProgrammeTemplateServiceRoleAllowedRoles, - importProgrammeTemplateServiceRoles, - importProgrammeTemplates, -} from './import-programme-templates.server' + importEventPartAllowedRoles, + importEventParts, + importEventServicePartAllowedRoles, + importEventServiceParts, + importEvents, +} from './import-events.server' import { importExternalSpeakers, importPublisherActivities, @@ -90,20 +90,20 @@ export { } from './import-buildings.server' export { importRolePermissions, importRoles, importSettings } from './import-configuration.server' export { - importEvents, - importProgrammePartAssignmentAllowedRoles, - importProgrammePartAssignments, - importProgrammeServiceRoleAssignmentAllowedRoles, - importProgrammeServiceRoleAssignments, -} from './import-programme-events.server' + importEventTemplates, + importTemplatePartAllowedRoles, + importTemplateParts, + importTemplateResponsibles, + importTemplateServicePartAllowedRoles, + importTemplateServiceParts, +} from './import-event-templates.server' export { - importProgrammeTemplatePartAllowedRoles, - importProgrammeTemplateParts, - importProgrammeTemplateResponsibles, - importProgrammeTemplateServiceRoleAllowedRoles, - importProgrammeTemplateServiceRoles, - importProgrammeTemplates, -} from './import-programme-templates.server' + importEventPartAllowedRoles, + importEventParts, + importEventServicePartAllowedRoles, + importEventServiceParts, + importEvents, +} from './import-events.server' export { importExternalSpeakers, importPublisherActivities, @@ -234,37 +234,37 @@ export async function runImport(job: Job): Promise { await importAttributions(zip, db, idMap, congregationId) await progress() - await importProgrammeTemplates(zip, db, idMap, congregationId) + await importEventTemplates(zip, db, idMap, congregationId) await progress() - await importProgrammeTemplateParts(zip, db, idMap, congregationId) + await importTemplateParts(zip, db, idMap, congregationId) await progress() - await importProgrammeTemplatePartAllowedRoles(zip, db, idMap, congregationId) + await importTemplatePartAllowedRoles(zip, db, idMap, congregationId) await progress() - await importProgrammeTemplateServiceRoles(zip, db, idMap, congregationId) + await importTemplateServiceParts(zip, db, idMap, congregationId) await progress() - await importProgrammeTemplateServiceRoleAllowedRoles(zip, db, idMap, congregationId) + await importTemplateServicePartAllowedRoles(zip, db, idMap, congregationId) await progress() - await importProgrammeTemplateResponsibles(zip, db, idMap, congregationId) + await importTemplateResponsibles(zip, db, idMap, congregationId) await progress() await importEvents(zip, db, idMap, congregationId) await progress() - await importProgrammePartAssignments(zip, db, idMap, congregationId) + await importEventParts(zip, db, idMap, congregationId) await progress() - await importProgrammePartAssignmentAllowedRoles(zip, db, idMap, congregationId) + await importEventPartAllowedRoles(zip, db, idMap, congregationId) await progress() - await importProgrammeServiceRoleAssignments(zip, db, idMap, congregationId) + await importEventServiceParts(zip, db, idMap, congregationId) await progress() - await importProgrammeServiceRoleAssignmentAllowedRoles(zip, db, idMap, congregationId) + await importEventServicePartAllowedRoles(zip, db, idMap, congregationId) await progress() await importBoardSections(zip, db, idMap, congregationId) diff --git a/app/features/settings/server/import-programme-templates.server.ts b/app/features/settings/server/import-event-templates.server.ts similarity index 78% rename from app/features/settings/server/import-programme-templates.server.ts rename to app/features/settings/server/import-event-templates.server.ts index 69817482..4bdf7a8f 100644 --- a/app/features/settings/server/import-programme-templates.server.ts +++ b/app/features/settings/server/import-event-templates.server.ts @@ -3,7 +3,7 @@ import type { TransactionClient } from '~/shared/infra/db.server' import type { EntityIdMap } from './data-transfer.type' import { readNdjsonFile } from './ndjson-archive' -export async function importProgrammeTemplates( +export async function importEventTemplates( zip: JsZip, db: TransactionClient, idMap: EntityIdMap, @@ -20,9 +20,9 @@ export async function importProgrammeTemplates( }>(zip, 'programme-templates') for (const record of records) { - const existing = await db.programmeTemplate.findFirst({ where: { key: record.key } }) + const existing = await db.eventTemplate.findFirst({ where: { key: record.key } }) if (existing) { - await db.programmeTemplate.update({ + await db.eventTemplate.update({ where: { id: existing.id }, data: { name: record.name, @@ -34,7 +34,7 @@ export async function importProgrammeTemplates( }) idMap.set('programme-templates', record.id, existing.id) } else { - const created = await db.programmeTemplate.create({ + const created = await db.eventTemplate.create({ data: { name: record.name, key: record.key, @@ -50,7 +50,7 @@ export async function importProgrammeTemplates( } } -export async function importProgrammeTemplateParts( +export async function importTemplateParts( zip: JsZip, db: TransactionClient, idMap: EntityIdMap, @@ -72,7 +72,7 @@ export async function importProgrammeTemplateParts( const templateId = idMap.getOptional('programme-templates', record.templateId) if (!templateId) continue - const created = await db.programmeTemplatePart.create({ + const created = await db.templatePart.create({ data: { name: record.name, section: record.section, @@ -89,7 +89,7 @@ export async function importProgrammeTemplateParts( } } -export async function importProgrammeTemplateServiceRoles( +export async function importTemplateServiceParts( zip: JsZip, db: TransactionClient, idMap: EntityIdMap, @@ -106,7 +106,7 @@ export async function importProgrammeTemplateServiceRoles( const templateId = idMap.getOptional('programme-templates', record.templateId) if (!templateId) continue - const created = await db.programmeTemplateServiceRole.create({ + const created = await db.templateServicePart.create({ data: { name: record.name, key: record.key, @@ -118,7 +118,7 @@ export async function importProgrammeTemplateServiceRoles( } } -export async function importProgrammeTemplateResponsibles( +export async function importTemplateResponsibles( zip: JsZip, db: TransactionClient, idMap: EntityIdMap, @@ -134,13 +134,13 @@ export async function importProgrammeTemplateResponsibles( const userId = idMap.getOptional('user-accounts', record.userId) if (!templateId || !userId) continue - await db.programmeTemplateResponsible.create({ + await db.templateResponsible.create({ data: { templateId, userId, congregationId }, }) } } -export async function importProgrammeTemplatePartAllowedRoles( +export async function importTemplatePartAllowedRoles( zip: JsZip, db: TransactionClient, idMap: EntityIdMap, @@ -160,30 +160,30 @@ export async function importProgrammeTemplatePartAllowedRoles( } if (data.length > 0) { - await db.programmeTemplatePartAllowedRole.createMany({ data, skipDuplicates: true }) + await db.templatePartAllowedRole.createMany({ data, skipDuplicates: true }) } } -export async function importProgrammeTemplateServiceRoleAllowedRoles( +export async function importTemplateServicePartAllowedRoles( zip: JsZip, db: TransactionClient, idMap: EntityIdMap, congregationId: number, ): Promise { - const records = await readNdjsonFile<{ serviceRoleId: number; roleId: number }>( + const records = await readNdjsonFile<{ servicePartId: number; roleId: number }>( zip, 'programme-template-service-role-allowed-roles', ) - const data: { serviceRoleId: number; roleId: number; congregationId: number }[] = [] + const data: { servicePartId: number; roleId: number; congregationId: number }[] = [] for (const record of records) { - const serviceRoleId = idMap.getOptional('programme-template-service-roles', record.serviceRoleId) + const servicePartId = idMap.getOptional('programme-template-service-roles', record.servicePartId) const roleId = idMap.getOptional('roles', record.roleId) - if (!serviceRoleId || !roleId) continue - data.push({ serviceRoleId, roleId, congregationId }) + if (!servicePartId || !roleId) continue + data.push({ servicePartId, roleId, congregationId }) } if (data.length > 0) { - await db.programmeTemplateServiceRoleAllowedRole.createMany({ data, skipDuplicates: true }) + await db.templateServicePartAllowedRole.createMany({ data, skipDuplicates: true }) } } diff --git a/app/features/settings/server/import-programme-events.server.ts b/app/features/settings/server/import-events.server.ts similarity index 76% rename from app/features/settings/server/import-programme-events.server.ts rename to app/features/settings/server/import-events.server.ts index a8650141..70db584b 100644 --- a/app/features/settings/server/import-programme-events.server.ts +++ b/app/features/settings/server/import-events.server.ts @@ -41,7 +41,7 @@ export async function importEvents( } } -export async function importProgrammePartAssignments( +export async function importEventParts( zip: JsZip, db: TransactionClient, idMap: EntityIdMap, @@ -70,7 +70,7 @@ export async function importProgrammePartAssignments( const eventId = idMap.getOptional('events', record.eventId) if (!eventId) continue - const created = await db.programmePartAssignment.create({ + const created = await db.eventPart.create({ data: { topic: record.topic, note: record.note, @@ -94,7 +94,7 @@ export async function importProgrammePartAssignments( } } -export async function importProgrammeServiceRoleAssignments( +export async function importEventServiceParts( zip: JsZip, db: TransactionClient, idMap: EntityIdMap, @@ -106,7 +106,7 @@ export async function importProgrammeServiceRoleAssignments( hasConflict: boolean name: string eventId: number - serviceRoleId: number | null + servicePartId: number | null assigneeId: number | null }>(zip, 'programme-service-role-assignments') @@ -114,13 +114,13 @@ export async function importProgrammeServiceRoleAssignments( const eventId = idMap.getOptional('events', record.eventId) if (!eventId) continue - const created = await db.programmeServiceRoleAssignment.create({ + const created = await db.eventServicePart.create({ data: { note: record.note, hasConflict: record.hasConflict, name: record.name, eventId, - serviceRoleId: idMap.getOptional('programme-template-service-roles', record.serviceRoleId), + servicePartId: idMap.getOptional('programme-template-service-roles', record.servicePartId), assigneeId: idMap.getOptional('members', record.assigneeId), congregationId, }, @@ -129,7 +129,7 @@ export async function importProgrammeServiceRoleAssignments( } } -export async function importProgrammePartAssignmentAllowedRoles( +export async function importEventPartAllowedRoles( zip: JsZip, db: TransactionClient, idMap: EntityIdMap, @@ -139,21 +139,21 @@ export async function importProgrammePartAssignmentAllowedRoles( zip, 'programme-part-assignment-allowed-roles', ) - const data: { assignmentId: number; roleId: number; asKind: string; congregationId: number }[] = [] + const data: { eventPartId: number; roleId: number; asKind: string; congregationId: number }[] = [] for (const record of records) { - const assignmentId = idMap.getOptional('programme-part-assignments', record.assignmentId) + const eventPartId = idMap.getOptional('programme-part-assignments', record.assignmentId) const roleId = idMap.getOptional('roles', record.roleId) - if (!assignmentId || !roleId) continue - data.push({ assignmentId, roleId, asKind: record.asKind, congregationId }) + if (!eventPartId || !roleId) continue + data.push({ eventPartId, roleId, asKind: record.asKind, congregationId }) } if (data.length > 0) { - await db.programmePartAssignmentAllowedRole.createMany({ data, skipDuplicates: true }) + await db.eventPartAllowedRole.createMany({ data, skipDuplicates: true }) } } -export async function importProgrammeServiceRoleAssignmentAllowedRoles( +export async function importEventServicePartAllowedRoles( zip: JsZip, db: TransactionClient, idMap: EntityIdMap, @@ -163,16 +163,16 @@ export async function importProgrammeServiceRoleAssignmentAllowedRoles( zip, 'programme-service-role-assignment-allowed-roles', ) - const data: { assignmentId: number; roleId: number; congregationId: number }[] = [] + const data: { eventServicePartId: number; roleId: number; congregationId: number }[] = [] for (const record of records) { - const assignmentId = idMap.getOptional('programme-service-role-assignments', record.assignmentId) + const eventServicePartId = idMap.getOptional('programme-service-role-assignments', record.assignmentId) const roleId = idMap.getOptional('roles', record.roleId) - if (!assignmentId || !roleId) continue - data.push({ assignmentId, roleId, congregationId }) + if (!eventServicePartId || !roleId) continue + data.push({ eventServicePartId, roleId, congregationId }) } if (data.length > 0) { - await db.programmeServiceRoleAssignmentAllowedRole.createMany({ data, skipDuplicates: true }) + await db.eventServicePartAllowedRole.createMany({ data, skipDuplicates: true }) } } diff --git a/app/shared/domain/audit.server.ts b/app/shared/domain/audit.server.ts index 2633e935..6b71679e 100644 --- a/app/shared/domain/audit.server.ts +++ b/app/shared/domain/audit.server.ts @@ -23,7 +23,7 @@ export const AuditAction = { RoleDeleted: 'role.deleted', RolePermissionChanged: 'role.permission.changed', PartAllowedRolesChanged: 'part.allowed_roles.changed', - ServiceRoleAllowedRolesChanged: 'service_role.allowed_roles.changed', + ServicePartAllowedRolesChanged: 'service_role.allowed_roles.changed', // Data export UserDataExported: 'user.data.exported', diff --git a/biome.json b/biome.json index 245cb4b2..c01941ea 100644 --- a/biome.json +++ b/biome.json @@ -101,8 +101,8 @@ }, { "includes": [ - "app/features/events/ui/ProgrammeDocument.tsx", - "app/features/events/ui/ProgrammeBoardDocument.tsx", + "app/features/events/ui/EventDocument.tsx", + "app/features/events/ui/EventBoardDocument.tsx", "app/features/territories/ui/TerritoryAttributionDocument.tsx" ], "linter": { diff --git a/docs/development/architecture-conventions.md b/docs/development/architecture-conventions.md index 905076b2..e247d655 100644 --- a/docs/development/architecture-conventions.md +++ b/docs/development/architecture-conventions.md @@ -67,7 +67,13 @@ Do not introduce aggregates speculatively. Most CRUD entities (Board documents, When the rules are duplicated across multiple writers but extracting an aggregate would prematurely lock the design, extract a **policy** instead. - An **aggregate** owns the mutation: callers invoke `member.aggregate.updateFlags(...)`; the aggregate writes, asserts, audits. -- A **policy** owns the rule: callers still write directly (or via existing service functions) but call `programmeAssignmentPolicy.assertAssignable(...)` before the write. +- A **policy** owns the rule: callers still write directly (or via existing service functions) but call `eventPartPolicy.assertAssignable(...)` before the write. + +> **Naming note — Programme vs Event:** the Prisma models were renamed from `ProgrammeXxx` to `EventXxx` / `TemplateXxx` (see migration `20260720350000_rename_programme_to_event`) and the code files were renamed to match (`event-parts.server.ts`, `event-templates.server.ts`, `event-part.policy.ts`, etc.). A follow-up migration `20260720500000_rename_service_role_to_service_part` then renamed `EventServiceRole` → `EventServicePart` and `TemplateServiceRole` → `TemplateServicePart` so `Role` unambiguously refers to a permission role. Callouts: +> - URL paths under `/programs/*` are preserved intentionally to keep bookmarks and calendar-feed URLs valid. +> - The NDJSON archive filenames inside data-transfer ZIPs (`programme-templates.ndjson`, `programme-part-assignments.ndjson`, `programme-service-role-assignments.ndjson`, …) are frozen for backward compatibility with older archives. `ARCHIVE_VERSION` bumped from 2.0 to 2.1 for the AuditLog `entityType` shim; the archive filenames themselves stay stable. +> +> Now, `templatePart` refers to the part slot defined on a template, `eventPart` refers to the concrete part row on a materialised event, and `templateServicePart` / `eventServicePart` are their service-slot counterparts. Promote a policy to an aggregate when a third writer appears or when the rule set grows beyond pure assertions. @@ -77,7 +83,7 @@ Promote a policy to an aggregate when a third writer appears or when the rule se |---|---|---|---| | `Member` | aggregate | `app/features/publishers/server/member.aggregate.ts` | 12 mutation sites route through it so `syncBuiltInRoleAssignments` fires after every identity-flag change | | `Attribution` | aggregate | `app/features/territories/server/attribution.aggregate.ts` | State machine (assigned → returned → archived) + overlap invariant (one active per publisher × territory time-window) | -| `ProgrammeAssignment` | policy | `app/features/events/server/programme-assignment.policy.ts` | Eligibility + distinctness + day-off + external-speaker rules shared by `assignPart` and `assignServiceRole` | +| `EventPart` | policy | `app/features/events/server/event-part.policy.ts` | Eligibility + distinctness + day-off + external-speaker rules shared by `assignPart` and `assignServicePart` | ### Aggregate contract @@ -270,13 +276,13 @@ A feature with only server exports may leave `index.ts` empty (with `export {}`) ```typescript // Allowed — client-safe barrel -import { EventKind, dayLabel } from '~/features/events' +import { EventTemplateKey, dayLabel } from '~/features/events' // Allowed — server-only barrel (called from a route loader or another .server file) import { getTemplates, seedDefaultTemplates } from '~/features/events/index.server' // Forbidden — fails `pnpm test:boundaries` -import { getTemplates } from '~/features/events/server/programme-templates.server' +import { getTemplates } from '~/features/events/server/event-templates.server' ``` ### Why the split? diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 121b1af6..6ae69685 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -19,7 +19,7 @@ Four valid combinations, all real personas: FK target rule of thumb: - **Action requires a login** → FK targets `UserAccount`. Examples: audit `actorId`, `Event.createdBy`, `BoardDocument.viewedBy`, `BoardDocumentVersion.uploadedBy`, all token tables, `CongregationUserPermission.user`, `UserRoleAssignment.user`. -- **Subject is a person in the congregation** → FK targets `Member`. Examples: `Attribution.publisherId`, `PublisherActivity.publisherId`, `ProgrammePartAssignment.{assigneeId,assistantId}`, `ProgrammeServiceRoleAssignment.assigneeId`, `PublisherGroup.{members,responsible,deputy}`, `MemberRoleAssignment.member`. +- **Subject is a person in the congregation** → FK targets `Member`. Examples: `Attribution.publisherId`, `PublisherActivity.publisherId`, `EventPart.{assigneeId,assistantId}`, `EventServicePart.assigneeId`, `PublisherGroup.{members,responsible,deputy}`, `MemberRoleAssignment.member`. Helpers: `account.member?.firstname ?? account.firstname` for display (use the `accountDisplayName` helper in `app/shared/utils/display-name.ts`); `currentUser.member?.id` to get the linked member id from the session-loaded account. @@ -132,7 +132,7 @@ When to use each: - **Board**: BoardSection, BoardSectionVisibilityRole, BoardDocument, BoardDocumentVersion, BoardDynamicDocumentSettings - **Territories**: Territory, Attribution, Building, BuildingEntrance, BuildingAccess, BuildingResidentialData, TerritoryCardOverlay, TerritoryPerimeter - **Publishers**: PublisherGroup, PublisherActivity -- **Events**: Event, EventKind, ProgrammeTemplate, ProgrammeTemplatePart, ProgrammeTemplateServiceRole, ProgrammePartAssignment, ProgrammeServiceRoleAssignment, ProgrammeTemplateResponsible, ExternalSpeaker +- **Events**: Event, EventTemplate, TemplatePart, TemplateServicePart, EventPart, EventServicePart, TemplateResponsible, ExternalSpeaker - **Settings**: Setting - **Notifications**: NotificationEvent, NotificationPreference - **GDPR / Audit**: AuditLog, DataDeletionRecord, ConsentRecord diff --git a/docs/development/coding-conventions.md b/docs/development/coding-conventions.md index 63a366a9..ce2472c6 100644 --- a/docs/development/coding-conventions.md +++ b/docs/development/coding-conventions.md @@ -102,7 +102,7 @@ Enforced by [Biome](https://biomejs.dev/). Run `pnpm build:format` to auto-fix. Business logic belongs in **service functions** (`features/*/server/*.server.ts`), not in route loaders or actions. -For the *structural* rules — per-feature folder shape, `index.ts` boundaries between features, aggregate doctrine for `Member`/`Attribution`/`ProgrammeAssignment`, CQRS-lite read/write split, and file-size budgets — see [Architecture Conventions](architecture-conventions.md). This section covers the mechanics of an individual service function; the architecture doc covers where the file lives and what it's allowed to touch. +For the *structural* rules — per-feature folder shape, `index.ts` boundaries between features, aggregate doctrine for `Member`/`Attribution`/`EventPart`, CQRS-lite read/write split, and file-size budgets — see [Architecture Conventions](architecture-conventions.md). This section covers the mechanics of an individual service function; the architecture doc covers where the file lives and what it's allowed to touch. ### Rules @@ -170,7 +170,7 @@ db.territory.create({ ### Compound Unique Keys -`Setting` and `EventKind` have compound unique keys `[key, congregationId]`. Use `findFirst` instead of `findUnique`: +`Setting`, `Role`, and `EventTemplate` have compound unique keys `[key, congregationId]`. Use `findFirst` instead of `findUnique`: ```typescript // Good diff --git a/docs/development/data-transfer.md b/docs/development/data-transfer.md index 9903cc85..df2215a7 100644 --- a/docs/development/data-transfer.md +++ b/docs/development/data-transfer.md @@ -12,7 +12,7 @@ A `.unitae` archive is a ZIP with three top-level entries: | `data/.ndjson` | One file per entity type, in newline-delimited JSON. Each line is one row. Empty files are omitted from the archive | | `files/` | Uploaded user files (board PDFs, territory cards). Only present when the export was created with `includeFiles: true` | -The archive version is a constant at the top of `app/features/settings/server/data-transfer.type.ts` (`ARCHIVE_VERSION`, currently `2.0` — bumped from `1.1` when `User` was split into `Member` + `UserAccount`). The list of accepted versions on import lives in `SUPPORTED_ARCHIVE_VERSIONS` in the same file — versions outside that list are reported as a warning and skipped. Bump the constant when the on-disk shape changes; add the previous value to `SUPPORTED_ARCHIVE_VERSIONS` when the format remains compatible enough to import-with-warning. +The archive version is a constant at the top of `app/features/settings/server/data-transfer.type.ts` (`ARCHIVE_VERSION`, currently `2.1` — bumped from `2.0` for the `AuditLog.entityType` rewrite shim that maps pre-rename `Programme*` model names to their current `Event*` / `Template*` / `EventServicePart` / `TemplateServicePart` counterparts on import; `2.0` itself was bumped from `1.1` when `User` was split into `Member` + `UserAccount`). The list of accepted versions on import lives in `SUPPORTED_ARCHIVE_VERSIONS` in the same file — versions outside that list are reported as a warning and skipped. Bump the constant when the on-disk shape changes; add the previous value to `SUPPORTED_ARCHIVE_VERSIONS` when the format remains compatible enough to import-with-warning. v1.x archives are importable through `migrateLegacyUsersNdjson` (in `import-congregation.server.ts`), which runs at the top of `validateImport` / `runImport` and synthesizes `members.ndjson` + `user-accounts.ndjson` from the legacy `users.ndjson` directly inside the in-memory zip. The split mirrors the schema migration heuristic: rows with publisher signals (`isPublisher`, baptism date, helder/servant/anointed flag, group membership) yield a Member, and rows whose email isn't `*.placeholder.unitae.app` yield a UserAccount linked to that Member via the preserved id space. Downstream import functions then read the v2.0 layout uniformly — no v1.x branch beyond the shim. Placeholder-email accounts are dropped, and the operator receives a warning summarising the split counts. @@ -24,7 +24,7 @@ Entities are exported in dependency order so an import can replay them top-down ## Backward compatibility — pre-1.1 archives -`1.0` archives are missing every feature table added after the export first shipped (custom roles, role-permissions, user-role-assignments, board section visibility, allowed-roles on programme parts/service-roles and their per-event copies, external speakers, territory card overlays, territory perimeter). The import accepts them with a warning and proceeds — those tables stay empty on the target. +`1.0` archives are missing every feature table added after the export first shipped (custom roles, role-permissions, user-role-assignments, board section visibility, allowed-roles on template parts / template service parts and their per-event copies, external speakers, territory card overlays, territory perimeter). The import accepts them with a warning and proceeds — those tables stay empty on the target. A subset of `1.0` archives, created before [PR #152](https://github.com/Unitae/unitae/pull/152), use the legacy filename `data/congregation-user-roles.ndjson` with a `roleKey` field instead of `data/congregation-user-permissions.ndjson` with `permissionKey`. The rename was a pure terminology change (`UserRole` → `Permission`, key values preserved); `importCongregationUserPermissions` falls back to the legacy filename when the current one is missing. @@ -39,7 +39,7 @@ The validator scans for natural-key collisions before any write. Decisions per e | User accounts | matching `email` | Update in place (link to imported member when present) | Skip with warning | | Members | (none — fresh ids per import) | Always insert | n/a | | Territories | matching `number` | Update in place | n/a (RLS-scoped) | -| Event kinds | matching compound key `(key, congregationId)` | Skip | n/a | +| Event templates | matching compound key `(key, congregationId)` | Skip | n/a | Other entities are inserted as-is; the import flow reassigns primary keys and stitches relationships through `EntityIdMap`. diff --git a/docs/development/notifications.md b/docs/development/notifications.md index 9229df42..7f1dce47 100644 --- a/docs/development/notifications.md +++ b/docs/development/notifications.md @@ -134,11 +134,11 @@ await notify(db, { The function is fire-and-forget — failures are logged but never block the calling operation. -## Programme assignment dispatch gate +## Event part 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. +`programme.assignment.{assigned,unassigned}` (event-part notifications — the type strings kept the legacy `programme.assignment.*` name so pending `NotificationEvent` rows survive the model rename) 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`. +When a manager releases an event, `fireReleaseNotifications` (`app/features/events/server/event-status.server.ts`) iterates every current part / service-part 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. diff --git a/docs/development/permissions-and-roles.md b/docs/development/permissions-and-roles.md index 988bb0b6..32a1d39d 100644 --- a/docs/development/permissions-and-roles.md +++ b/docs/development/permissions-and-roles.md @@ -90,13 +90,13 @@ 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`: +Release and un-release actions do **not** get their own permission. They share the event-edit gate `canEditEvent` in `app/features/events/server/events-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) +- **Template responsible** — the user set as `EventTemplate.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)). +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#event-part-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. diff --git a/docs/development/row-level-security.md b/docs/development/row-level-security.md index a173568b..223f6664 100644 --- a/docs/development/row-level-security.md +++ b/docs/development/row-level-security.md @@ -74,7 +74,7 @@ The authoritative source is the set of `CREATE POLICY tenant_isolation` statemen **Tenant-scoped (RLS enforced):** -Attribution, AuditLog, BoardDocument, BoardDocumentVersion, BoardDynamicDocumentSettings, BoardSection, BoardSectionVisibilityRole, Building, BuildingAccess, BuildingEntrance, BuildingResidentialData, CongregationUserPermission, ConsentRecord, DataDeletionRecord, Event, EventKind, ExternalSpeaker, Member, MemberRoleAssignment, NotificationEvent, NotificationPreference, ProgrammePartAssignment, ProgrammePartAssignmentAllowedRole, ProgrammeServiceRoleAssignment, ProgrammeServiceRoleAssignmentAllowedRole, ProgrammeTemplate, ProgrammeTemplatePart, ProgrammeTemplatePartAllowedRole, ProgrammeTemplateResponsible, ProgrammeTemplateServiceRole, ProgrammeTemplateServiceRoleAllowedRole, PublisherActivity, PublisherGroup, Role, RolePermission, Setting, Territory, TerritoryCardOverlay, TerritoryPerimeter, UserAccount, UserRoleAssignment. +Attribution, AuditLog, BoardDocument, BoardDocumentVersion, BoardDynamicDocumentSettings, BoardSection, BoardSectionVisibilityRole, Building, BuildingAccess, BuildingEntrance, BuildingResidentialData, CongregationUserPermission, ConsentRecord, DataDeletionRecord, Event, EventPart, EventPartAllowedRole, EventServicePart, EventServicePartAllowedRole, EventTemplate, ExternalSpeaker, Member, MemberRoleAssignment, NotificationEvent, NotificationPreference, PublisherActivity, PublisherGroup, Role, RolePermission, Setting, TemplatePart, TemplatePartAllowedRole, TemplateResponsible, TemplateServicePart, TemplateServicePartAllowedRole, Territory, TerritoryCardOverlay, TerritoryPerimeter, UserAccount, UserRoleAssignment. **Global (no `congregationId`, RLS not applicable):** diff --git a/scripts/check-file-sizes.ts b/scripts/check-file-sizes.ts index 1632764b..670cd80d 100644 --- a/scripts/check-file-sizes.ts +++ b/scripts/check-file-sizes.ts @@ -59,7 +59,7 @@ const EXEMPT_FILES = new Set([ // Service files >350L (6) — remaining after Wave 4. 'app/features/dashboard/server/dashboard.server.ts', // 351 'app/features/display-board/server/dynamic-documents.server.ts', // 375 - 'app/features/events/server/programme-events.server.ts', // 358 + 'app/features/events/server/event-parts.server.ts', // 358 'app/features/settings/server/export-congregation.server.ts', // 657 'app/features/territories/server/buildings.server.ts', // 366 'app/shared/domain/roles.server.ts', // 421 @@ -88,7 +88,7 @@ const EXEMPT_FILES = new Set([ // UI components >400L (4) — remaining after Wave 4. 'app/features/display-board/ui/dynamic/ProgrammeView.tsx', // 536 - 'app/features/events/ui/ProgrammeBoardDocument.tsx', // 432 + 'app/features/events/ui/EventBoardDocument.tsx', // 432 'app/features/territories/ui/BuildingEntranceMapEditor.tsx', // 412 'app/features/territories/ui/TerritoryAttributionDocument.tsx', // 409 ]) diff --git a/scripts/check-service-test-coverage.test.ts b/scripts/check-service-test-coverage.test.ts index af737e90..355977a7 100644 --- a/scripts/check-service-test-coverage.test.ts +++ b/scripts/check-service-test-coverage.test.ts @@ -71,7 +71,7 @@ describe('classifyServiceFile', () => { 'app/features/publishers/server/member.aggregate.ts', 'app/features/settings/server/anonymize.workflow.ts', 'app/features/settings/server/territory-settings.queries.ts', - 'app/features/events/server/programme-assignment.policy.ts', + 'app/features/events/server/event-part.policy.ts', ]) { const v = classifyServiceFile(rel, siblingFiles([rel]), EXEMPT) expect(v.status, `expected ${rel} to be a service file`).toBe('violation') diff --git a/scripts/check-service-test-coverage.ts b/scripts/check-service-test-coverage.ts index 3cae6f03..2a13142f 100644 --- a/scripts/check-service-test-coverage.ts +++ b/scripts/check-service-test-coverage.ts @@ -61,8 +61,8 @@ export const EXEMPT_FILES = new Set([ 'app/features/settings/server/import-buildings.server.ts', 'app/features/settings/server/import-configuration.server.ts', 'app/features/settings/server/import-congregation.server.ts', - 'app/features/settings/server/import-programme-events.server.ts', - 'app/features/settings/server/import-programme-templates.server.ts', + 'app/features/settings/server/import-events.server.ts', + 'app/features/settings/server/import-event-templates.server.ts', 'app/features/settings/server/import-publishers.server.ts', 'app/features/settings/server/import-territories.server.ts', 'app/features/settings/server/import-user-accounts.server.ts',