From fd85b17a00c48f6698df63f8f50b7f696950843a Mon Sep 17 00:00:00 2001
From: mindsers
Date: Mon, 31 Aug 2026 21:00:19 +0200
Subject: [PATCH 1/2] feat(publishers)!: drop the Member.type column
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Final step: PioneerEnrolment has been the source of truth for a member's standing
pioneer status since the plan/actual split, and the previous release moved every
reader onto the stints and backfilled anyone holding a type without one. This
removes what nothing reads any more, plus the CHECK constraint that referenced it
(dropped first, or the column drop fails on the dependency).
Two behaviours had to be translated rather than deleted, because the column was
carrying them:
Turning off the permanent-auxiliary profile used to flip every auxiliary member's
cached type. It now closes their ongoing auxiliary stints at the current month via
endOngoingEnrolmentsOfType — the same intent against the source of truth, and it
keeps the history of what they actually did. The old version was already incoherent:
it flipped the cache and left the stints open, so the two disagreed immediately.
Filtering the publisher list by type now filters on stints — an ongoing stint of
that type, or for Normal the absence of one. A single-month auxiliary is closed, so
it still reads as Normal, exactly as the column did.
The create form no longer offers a pioneer type. A pioneer appointment is an
enrolment with a period, made from the pioneer section of the edit page, which is
where the edit form already sent people. The form schemas drop the field, so a
member is created Normal and appointed afterwards.
Also: the S-21 export and the group roster derive the status from the stints; the
archive export stops emitting the field and the importer ignores it on older
archives; the activity-derived backfill loses its `memberType` fallback, so
contiguous auxiliary months now backfill as single-month stints — the snapshots
cannot distinguish a monthly auxiliary from a permanent one, and single-month is the
safe guess because it grants no standing status.
Three readers were invisible to the compiler and only failed at runtime: a `where`
clause inside an `OR`, a spread-built filter, and a spread `select`. They were found
by applying the migration locally and running the whole suite against a database
without the column, which is the only check that catches this class.
---
.../migration.sql | 14 +++
...ing-pioneer-enrolments.integration.test.ts | 35 +++++-
.../drop-member-type.integration.test.ts | 60 ++++++++++
app/database/schema.prisma | 6 +-
...mic-documents-inactive.integration.test.ts | 2 -
.../dynamic-documents.integration.test.ts | 1 -
.../server/dynamic-documents.server.ts | 2 +-
.../ui/dynamic/PublisherGroupsView.tsx | 2 +-
.../routes/programs/events/publisher-info.tsx | 5 +-
app/features/publishers/index.server.ts | 8 +-
.../publishers/routes/activity/new.tsx | 17 ++-
.../routes/publishers/edit-publisher.tsx | 9 +-
.../routes/publishers/new-publisher.tsx | 15 +--
.../schemas/edit-publisher.schema.ts | 2 -
.../schemas/publisher.schema.test.ts | 19 ++-
.../publishers/schemas/publisher.schema.ts | 2 -
.../publishers/server/member-identity.test.ts | 6 -
.../publishers/server/member-identity.ts | 7 --
...r-preconditions.server.integration.test.ts | 4 -
.../member.aggregate.integration.test.ts | 70 +----------
.../publishers/server/member.aggregate.ts | 52 ---------
.../pioneer-activity.integration.test.ts | 1 -
.../server/pioneer-activity.queries.test.ts | 2 +-
.../server/pioneer-activity.queries.ts | 15 ++-
...olment-backfill.server.integration.test.ts | 16 +--
.../pioneer-enrolment-backfill.server.test.ts | 29 +++--
.../pioneer-enrolment-backfill.server.ts | 19 +--
...er-enrolment.aggregate.integration.test.ts | 3 +-
...neer-enrolment.queries.integration.test.ts | 2 +-
...eer-enrolment.workflow.integration.test.ts | 110 ++++++++++++++----
.../server/pioneer-enrolment.workflow.ts | 68 +++++++----
.../server/publishers.server.test.ts | 6 +-
.../publishers/server/publishers.server.ts | 14 ++-
.../server/render-activity-pdf-zip.server.tsx | 7 +-
.../update-member.server.integration.test.ts | 1 -
.../server/update-member.server.test.ts | 4 -
.../ui/PublisherActivityDocument.tsx | 13 ++-
.../ui/PublisherFieldServiceForm.tsx | 49 +-------
.../routes/users/add-to-congregation.tsx | 2 -
.../congregation-settings.server.test.ts | 69 +++--------
.../server/congregation-settings.server.ts | 17 +--
.../server/export-congregation.server.ts | 1 -
.../server/import-user-accounts.server.ts | 2 -
.../server/link-member-to-account.server.ts | 3 -
.../migrate-legacy-users-ndjson.server.ts | 1 -
.../built-in-roles.server.integration.test.ts | 12 +-
46 files changed, 398 insertions(+), 406 deletions(-)
create mode 100644 app/database/migrations/20260831230000_drop_member_type/migration.sql
create mode 100644 app/database/migrations/drop-member-type.integration.test.ts
diff --git a/app/database/migrations/20260831230000_drop_member_type/migration.sql b/app/database/migrations/20260831230000_drop_member_type/migration.sql
new file mode 100644
index 00000000..7a3991d4
--- /dev/null
+++ b/app/database/migrations/20260831230000_drop_member_type/migration.sql
@@ -0,0 +1,14 @@
+-- Drops `Member.type`. It cached a member's standing pioneer status; PioneerEnrolment is the source
+-- of truth for the same fact and has been since the plan/actual split. The preceding release moved
+-- every reader onto the stints and backfilled the members who had a type but no stint, so this file
+-- only removes what nothing reads any more.
+--
+-- Contract: no change in who is a pioneer. Anyone whose status came from this column already has an
+-- ongoing stint carrying it.
+--
+-- The CHECK constraint has to go first — it references the column, so the DROP COLUMN would fail on
+-- a dependency error rather than cascading.
+
+ALTER TABLE "Member" DROP CONSTRAINT IF EXISTS "member_pioneer_requires_baptism";
+
+ALTER TABLE "Member" DROP COLUMN "type";
diff --git a/app/database/migrations/backfill-standing-pioneer-enrolments.integration.test.ts b/app/database/migrations/backfill-standing-pioneer-enrolments.integration.test.ts
index d8527c8f..03d9cbf7 100644
--- a/app/database/migrations/backfill-standing-pioneer-enrolments.integration.test.ts
+++ b/app/database/migrations/backfill-standing-pioneer-enrolments.integration.test.ts
@@ -27,6 +27,26 @@ function migrationStatements(): string[] {
.filter(statement => statement.length > 0)
}
+// A later migration drops `Member.type`, so by the time this test runs against a migrated database
+// the column this backfill reads is gone. Recreate a minimal stand-in — `IF NOT EXISTS` makes it a
+// no-op on a database where the drop has not been applied, so the test behaves identically in both.
+// The PublisherType enum itself survives — PioneerEnrolment and PublisherActivity still use it —
+// and the stand-in must BE that enum: the migration selects this column straight into
+// PioneerEnrolment.type, which a text column cannot satisfy.
+// Prisma's client speaks the enum NAMES; the database stores the `@map`-ed values. Raw SQL needs
+// the latter, and the mapping is only in schema.prisma, so it is spelled out here.
+const DB_ENUM_VALUE: Record = {
+ [PublisherType.Normal]: 'normal',
+ [PublisherType.PionnierAuxiliaires]: 'pionnier-auxiliaires',
+ [PublisherType.PionnierPermanant]: 'pionnier-permanant',
+ [PublisherType.PionnierSpecial]: 'pionnier-special',
+ [PublisherType.Missionnaire]: 'missionnaire',
+}
+
+const RECREATE_DROPPED_COLUMN = `
+ ALTER TABLE "Member" ADD COLUMN IF NOT EXISTS "type" "PublisherType" NOT NULL DEFAULT 'normal'
+`
+
/** Thrown to roll the fixture back; every assertion runs on captured values. */
class Rollback extends Error {}
@@ -71,23 +91,32 @@ async function execute(): Promise {
await testDb.$transaction(
async tx => {
const stamp = `mig-${process.pid}-${globalThis.performance.now().toString().replace('.', '')}`
+ await tx.$executeRawUnsafe(RECREATE_DROPPED_COLUMN)
const congregation = await tx.congregation.create({ data: { name: stamp, slug: stamp, active: true } })
const congregationId = congregation.id
// baptismDate is not optional here: a `member_pioneer_requires_baptism` CHECK constraint
// means a pioneer-typed row cannot exist without one, which also bounds the population this
// migration can ever see.
- const makeMember = (firstname: string, type: PublisherType) =>
- tx.member.create({
+ // Prisma no longer knows the column a later migration dropped, so the fixture writes it
+ // in raw SQL against the stand-in recreated above.
+ const makeMember = async (firstname: string, type: PublisherType) => {
+ const member = await tx.member.create({
data: {
firstname,
lastname: stamp,
isPublisher: true,
- type,
baptismDate: new Date('2015-01-01'),
congregationId,
},
})
+ await tx.$executeRawUnsafe(
+ 'UPDATE "Member" SET "type" = $1::"PublisherType" WHERE "id" = $2',
+ DB_ENUM_VALUE[type],
+ member.id,
+ )
+ return member
+ }
// Has reported activity — the stint should start at their EARLIEST reported month.
const withActivity = await makeMember('WithActivity', PublisherType.PionnierPermanant)
diff --git a/app/database/migrations/drop-member-type.integration.test.ts b/app/database/migrations/drop-member-type.integration.test.ts
new file mode 100644
index 00000000..af707396
--- /dev/null
+++ b/app/database/migrations/drop-member-type.integration.test.ts
@@ -0,0 +1,60 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { PrismaPg } from '@prisma/adapter-pg'
+import { afterAll, describe, expect, it } from 'vitest'
+import { PrismaClient } from '~/database/generated/client'
+
+// Runs against DB_URL rather than DB_RUNTIME_URL: a migration executes as the schema owner.
+const adapter = new PrismaPg({ connectionString: process.env.DB_URL, max: 3, connectionTimeoutMillis: 5000 })
+const testDb = new PrismaClient({ adapter })
+
+// Hoisted: a regex literal inside a function body is a lint error in this repo.
+const DROP_COLUMN_RE = /DROP COLUMN "type"/
+const DROP_CONSTRAINT_RE = /DROP CONSTRAINT IF EXISTS "member_pioneer_requires_baptism"/
+const ALTER_TABLE_RE = /ALTER TABLE "(\w+)"/g
+
+const MIGRATION_SQL = resolve(import.meta.dirname, '20260831230000_drop_member_type', 'migration.sql')
+
+function migrationSql(): string {
+ return readFileSync(MIGRATION_SQL, 'utf8')
+}
+
+function migrationStatements(): string[] {
+ return migrationSql()
+ .split('\n')
+ .filter(line => !line.trimStart().startsWith('--'))
+ .join('\n')
+ .split(';')
+ .map(statement => statement.trim())
+ .filter(statement => statement.length > 0)
+}
+
+afterAll(async () => {
+ await testDb.$disconnect()
+})
+
+describe('drop Member.type (migration)', () => {
+ // Not executed here. DROP COLUMN takes ACCESS EXCLUSIVE on Member and every table whose foreign
+ // keys reference it, held until rollback — running it in a fixture deadlocks unrelated suites at
+ // random. The shape of the file is what this asserts; that it applies cleanly is proved by CI
+ // running `prisma migrate deploy` against a fresh database before any test executes.
+ it('drops the dependent CHECK constraint before the column', () => {
+ const statements = migrationStatements()
+ const constraintIndex = statements.findIndex(s => s.includes('member_pioneer_requires_baptism'))
+ const columnIndex = statements.findIndex(s => DROP_COLUMN_RE.test(s))
+
+ expect(constraintIndex).toBeGreaterThanOrEqual(0)
+ expect(columnIndex).toBeGreaterThanOrEqual(0)
+ // Postgres refuses to drop a column a constraint still references, so the order is load-bearing.
+ expect(constraintIndex).toBeLessThan(columnIndex)
+ })
+
+ it('drops the constraint idempotently', () => {
+ expect(migrationSql()).toMatch(DROP_CONSTRAINT_RE)
+ })
+
+ it('touches only Member', () => {
+ const tables = [...migrationSql().matchAll(ALTER_TABLE_RE)].map(match => match[1])
+ expect([...new Set(tables)]).toEqual(['Member'])
+ })
+})
diff --git a/app/database/schema.prisma b/app/database/schema.prisma
index fb007e32..3bdb4c0c 100644
--- a/app/database/schema.prisma
+++ b/app/database/schema.prisma
@@ -174,10 +174,10 @@ model Member {
dpaCardUpToDate Boolean @default(false)
survivalBackpackReady Boolean @default(false)
- // Domain status. `isPublisher = false` means ministry-school student.
- // `type` is only meaningful when `isPublisher = true`.
+ // Domain status. `isPublisher = false` means ministry-school student. Whether a publisher is a
+ // pioneer is NOT stored here — it is their PioneerEnrolment stints, so that a standing status and
+ // the period it covers cannot disagree.
isPublisher Boolean @default(false)
- type PublisherType @default(Normal)
baptismDate DateTime?
isAnointed Boolean @default(false)
isHelder Boolean @default(false)
diff --git a/app/features/display-board/server/dynamic-documents-inactive.integration.test.ts b/app/features/display-board/server/dynamic-documents-inactive.integration.test.ts
index fb4efddd..6438ba57 100644
--- a/app/features/display-board/server/dynamic-documents-inactive.integration.test.ts
+++ b/app/features/display-board/server/dynamic-documents-inactive.integration.test.ts
@@ -54,7 +54,6 @@ beforeAll(async () => {
firstname: 'Active',
lastname: 'Pioneer',
isPublisher: true,
- type: PublisherType.PionnierPermanant,
baptismDate: new Date('2010-01-01'),
congregationId,
},
@@ -66,7 +65,6 @@ beforeAll(async () => {
firstname: 'Inactive',
lastname: 'Pioneer',
isPublisher: true,
- type: PublisherType.PionnierPermanant,
baptismDate: new Date('2010-01-01'),
inactiveAt: new Date('2026-01-01'),
congregationId,
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 5e0c1d5d..73c12fe2 100644
--- a/app/features/display-board/server/dynamic-documents.integration.test.ts
+++ b/app/features/display-board/server/dynamic-documents.integration.test.ts
@@ -59,7 +59,6 @@ beforeAll(async () => {
lastname: 'Martin',
baptismDate: new Date('2010-01-01'),
isPublisher: true,
- type: PublisherType.PionnierPermanant,
congregationId,
},
})
diff --git a/app/features/display-board/server/dynamic-documents.server.ts b/app/features/display-board/server/dynamic-documents.server.ts
index 1fdb6169..3ac2696e 100644
--- a/app/features/display-board/server/dynamic-documents.server.ts
+++ b/app/features/display-board/server/dynamic-documents.server.ts
@@ -367,7 +367,7 @@ function fetchPublisherGroups(db: TransactionClient, congregationId: number) {
deputy: { select: userSelect },
members: {
where: { leftAt: null, isPublisher: true, inactiveAt: null },
- select: { ...userSelect, type: true },
+ select: userSelect,
orderBy: [{ lastname: 'asc' }, { firstname: 'asc' }],
},
},
diff --git a/app/features/display-board/ui/dynamic/PublisherGroupsView.tsx b/app/features/display-board/ui/dynamic/PublisherGroupsView.tsx
index 06f07ee8..d94df2d1 100644
--- a/app/features/display-board/ui/dynamic/PublisherGroupsView.tsx
+++ b/app/features/display-board/ui/dynamic/PublisherGroupsView.tsx
@@ -19,7 +19,7 @@ interface PublisherGroupsViewData {
adress: string
responsible: Person
deputy: (Person & { id: number }) | null
- members: (Person & { type: string })[]
+ members: Person[]
}[]
}
diff --git a/app/features/events/routes/programs/events/publisher-info.tsx b/app/features/events/routes/programs/events/publisher-info.tsx
index 4fa9b393..d083bff7 100644
--- a/app/features/events/routes/programs/events/publisher-info.tsx
+++ b/app/features/events/routes/programs/events/publisher-info.tsx
@@ -2,6 +2,7 @@ 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'
+import { standingTypeFromEnrolments } from '~/features/publishers'
import { currentAccountContext, permissionsContext, withScopeFromContext } from '~/shared/auth/route-context.server'
import { Permission } from '~/shared/types/permission'
import { formatGroupName } from '~/shared/utils/format-group-name'
@@ -42,7 +43,7 @@ export function loader({ request, params, context }: Route.LoaderArgs) {
// Publisher profile
const user = await db.member.findFirst({
where: { id: userId, congregationId },
- include: { publisherGroup: true, account: { select: { id: true } } },
+ include: { publisherGroup: true, account: { select: { id: true } }, pioneerEnrolments: true },
})
if (!user) return Response.json(null)
@@ -85,7 +86,7 @@ export function loader({ request, params, context }: Route.LoaderArgs) {
lastname: user.lastname,
isHelder: user.isHelder,
isServant: user.isServant,
- type: user.type,
+ type: standingTypeFromEnrolments(user.pioneerEnrolments),
group: user.publisherGroup ? formatGroupName(user.publisherGroup.name) : null,
},
daysOff: daysOff.map(d => ({
diff --git a/app/features/publishers/index.server.ts b/app/features/publishers/index.server.ts
index f8570f96..50f5657b 100644
--- a/app/features/publishers/index.server.ts
+++ b/app/features/publishers/index.server.ts
@@ -9,7 +9,13 @@ export {
getEnrolmentsForServiceYear,
resolveEnrolmentMonthlyGoal,
} from './server/pioneer-enrolment.queries'
-export { endPioneerEnrolment, enrolPioneer, removePioneerEnrolment } from './server/pioneer-enrolment.workflow'
+export {
+ endOngoingEnrolmentsOfType,
+ endPioneerEnrolment,
+ enrolPioneer,
+ removePioneerEnrolment,
+ updatePioneerEnrolment,
+} from './server/pioneer-enrolment.workflow'
export { backfillCongregationEnrolments } from './server/pioneer-enrolment-backfill.server'
export { listPioneerGoalsForYear, type PioneerGoalRow, resolvePioneerGoal } from './server/pioneer-goals.queries'
export { getPublishers } from './server/publishers.server'
diff --git a/app/features/publishers/routes/activity/new.tsx b/app/features/publishers/routes/activity/new.tsx
index e3ea28ec..2d64da2a 100644
--- a/app/features/publishers/routes/activity/new.tsx
+++ b/app/features/publishers/routes/activity/new.tsx
@@ -4,7 +4,7 @@ import { useState } from 'react'
import { data, Form, redirect, useSearchParams } from 'react-router'
import { commitSession, getSession } from '~/features/authentication/index.server'
import { getEnrolmentsForMember, resolveEnrolmentMonthlyGoal } from '~/features/publishers/index.server'
-import { enrolmentForMonth } from '~/features/publishers/model/pioneer-enrolment'
+import { enrolmentForMonth, standingTypeFromEnrolments } from '~/features/publishers/model/pioneer-enrolment'
import { toServiceYear } from '~/features/publishers/model/pioneer-pace'
import { createActivitySchema } from '~/features/publishers/schemas/activity.schema'
import { createPublisherActivity } from '~/features/publishers/server/publisher-activity-mutations.server'
@@ -84,16 +84,20 @@ export function loader({ request, context }: Route.LoaderArgs) {
}
// Show the enrolled goal as read-only context so the report can be entered against the plan.
+ // `standingType` is the member's current status, derived from their stints — the form uses it to
+ // decide whether to offer the auxiliary-pioneer picker at all.
let enrolmentGoal: number | null = null
+ let standingType: PublisherType = PublisherType.Normal
if (publisher != null) {
const enrolments = await getEnrolmentsForMember(db, publisher.id, currentUser.congregationId)
+ standingType = standingTypeFromEnrolments(enrolments)
const active = enrolmentForMonth(enrolments, month, year)
if (active != null) enrolmentGoal = await resolveEnrolmentMonthlyGoal(db, active, toServiceYear(month, year))
}
return {
publishers,
- publisher,
+ publisher: publisher == null ? null : { ...publisher, standingType },
selectedMonth: { month, year },
enrolmentGoal,
previousPage: request.headers.get('referer'),
@@ -105,7 +109,7 @@ export default function NewActivity({ loaderData, actionData }: Route.ComponentP
const { publishers, publisher, selectedMonth, enrolmentGoal, previousPage } = loaderData
const [searchParams, setSearchParams] = useSearchParams()
const [pioneer, setPioneer] = useState(
- publisher?.type === PublisherType.PionnierAuxiliaires ? PublisherType.PionnierAuxiliaires : null,
+ publisher?.standingType === PublisherType.PionnierAuxiliaires ? PublisherType.PionnierAuxiliaires : null,
)
const { blocker, markDirty } = useUnsavedChanges()
@@ -240,7 +244,7 @@ export default function NewActivity({ loaderData, actionData }: Route.ComponentP
- )}
)
diff --git a/app/features/settings/routes/users/add-to-congregation.tsx b/app/features/settings/routes/users/add-to-congregation.tsx
index a3f27530..d0e2dfef 100644
--- a/app/features/settings/routes/users/add-to-congregation.tsx
+++ b/app/features/settings/routes/users/add-to-congregation.tsx
@@ -15,7 +15,6 @@ import {
import { ConflictError, NotFoundError } from '~/shared/errors/app-error.server'
import type { AccountId } from '~/shared/types/branded'
import { Permission } from '~/shared/types/permission'
-import { PublisherType } from '~/shared/types/publisher-type'
import { Button } from '~/shared/ui/button'
import { useFocusError } from '~/shared/ui/hooks/use-focus-error'
import { Input } from '~/shared/ui/input'
@@ -181,7 +180,6 @@ export async function action({ request, params, context }: Route.ActionArgs) {
birthDate: submission.value.birthDate ? new Date(submission.value.birthDate) : null,
baptismDate: submission.value.baptismDate ? new Date(submission.value.baptismDate) : null,
isPublisher: submission.value.isPublisher,
- type: PublisherType.Normal,
isHelder: false,
isServant: false,
isAnointed: false,
diff --git a/app/features/settings/server/congregation-settings.server.test.ts b/app/features/settings/server/congregation-settings.server.test.ts
index a88441a5..2192ef72 100644
--- a/app/features/settings/server/congregation-settings.server.test.ts
+++ b/app/features/settings/server/congregation-settings.server.test.ts
@@ -12,13 +12,13 @@ vi.mock('~/shared/domain/settings.server', () => ({
setSetting: vi.fn(),
}))
vi.mock('~/shared/domain/audit.server', () => ({ AuditAction: {}, audit: vi.fn() }))
-vi.mock('~/shared/domain/built-in-roles.server', () => ({
- syncBuiltInRoleAssignments: vi.fn(),
+vi.mock('~/features/publishers/index.server', () => ({
+ endOngoingEnrolmentsOfType: vi.fn(),
}))
const { updateCongregationSettings } = await import('./congregation-settings.server')
const { setSetting } = await import('~/shared/domain/settings.server')
-const { syncBuiltInRoleAssignments } = await import('~/shared/domain/built-in-roles.server')
+const { endOngoingEnrolmentsOfType } = await import('~/features/publishers/index.server')
const mockDb = {
member: {
@@ -41,62 +41,27 @@ describe('updateCongregationSettings', () => {
})
expect(setSetting).toHaveBeenCalledWith(mockDb, 'auxiliary-pioneer-profile-active', 'true', 10)
- expect(mockDb.member.updateMany).not.toHaveBeenCalled()
- expect(syncBuiltInRoleAssignments).not.toHaveBeenCalled()
+ expect(endOngoingEnrolmentsOfType).not.toHaveBeenCalled()
})
- it('resets auxiliary pioneers to normal when feature is deactivated', async () => {
+ // Deactivating the profile means those members stop being permanent auxiliaries. That fact lives
+ // on the stint now, so the setting closes the ongoing ones instead of flipping a cached column.
+ // Re-syncing each affected member's roles is guaranteed inside endOngoingEnrolmentsOfType and
+ // covered by pioneer-enrolment.workflow.integration.test.ts — the regression this file used to
+ // guard (a bulk type flip that skipped the role sync) cannot recur, because there is no bulk flip.
+ it('closes ongoing auxiliary enrolments when the profile is deactivated', async () => {
vi.mocked(setSetting).mockResolvedValue(undefined as never)
- mockDb.member.updateMany.mockResolvedValue({ count: 3 })
await updateCongregationSettings(mockDb as never, 10, 99, {
auxiliaryPioneerProfileActivated: 'false',
})
- expect(mockDb.member.updateMany).toHaveBeenCalledWith({
- where: {
- congregationId: 10,
- type: PublisherType.PionnierAuxiliaires,
- },
- data: {
- type: PublisherType.Normal,
- },
- })
- })
-
- // Wave 1 bug 1 — regression test.
- // The bulk `updateMany` flips Member.type but used to skip
- // `syncBuiltInRoleAssignments`, leaving stale `pioneer` role assignments
- // for every member whose type was reset.
- it('syncs built-in roles for every member whose type was reset', async () => {
- vi.mocked(setSetting).mockResolvedValue(undefined as never)
- mockDb.member.findMany.mockResolvedValue([{ id: 100 }, { id: 200 }, { id: 300 }])
- mockDb.member.updateMany.mockResolvedValue({ count: 3 })
-
- await updateCongregationSettings(mockDb as never, 10, 99, {
- auxiliaryPioneerProfileActivated: 'false',
- })
-
- expect(mockDb.member.findMany).toHaveBeenCalledWith({
- where: { congregationId: 10, type: PublisherType.PionnierAuxiliaires },
- select: { id: true },
- })
- expect(syncBuiltInRoleAssignments).toHaveBeenCalledTimes(3)
- expect(syncBuiltInRoleAssignments).toHaveBeenNthCalledWith(1, mockDb, 100, 10, 99)
- expect(syncBuiltInRoleAssignments).toHaveBeenNthCalledWith(2, mockDb, 200, 10, 99)
- expect(syncBuiltInRoleAssignments).toHaveBeenNthCalledWith(3, mockDb, 300, 10, 99)
- })
-
- it('does not sync when no auxiliary pioneers exist to reset', async () => {
- vi.mocked(setSetting).mockResolvedValue(undefined as never)
- mockDb.member.findMany.mockResolvedValue([])
- mockDb.member.updateMany.mockResolvedValue({ count: 0 })
-
- await updateCongregationSettings(mockDb as never, 10, 99, {
- auxiliaryPioneerProfileActivated: 'false',
- })
-
- expect(mockDb.member.updateMany).toHaveBeenCalled()
- expect(syncBuiltInRoleAssignments).not.toHaveBeenCalled()
+ expect(endOngoingEnrolmentsOfType).toHaveBeenCalledWith(
+ mockDb,
+ 10,
+ 99,
+ PublisherType.PionnierAuxiliaires,
+ expect.objectContaining({ endMonth: expect.any(Number), endYear: expect.any(Number) }),
+ )
})
})
diff --git a/app/features/settings/server/congregation-settings.server.ts b/app/features/settings/server/congregation-settings.server.ts
index 33fe372a..4b0fb8eb 100644
--- a/app/features/settings/server/congregation-settings.server.ts
+++ b/app/features/settings/server/congregation-settings.server.ts
@@ -1,4 +1,4 @@
-import { memberAggregate } from '~/features/publishers/index.server'
+import { endOngoingEnrolmentsOfType } from '~/features/publishers/index.server'
import { AuditAction, audit } from '~/shared/domain/audit.server'
import { setSetting } from '~/shared/domain/settings.server'
import type { TransactionClient } from '~/shared/infra/db.server'
@@ -21,13 +21,14 @@ export async function updateCongregationSettings(
)
if (data.auxiliaryPioneerProfileActivated === 'false') {
- await memberAggregate.bulkUpdateType(
- db,
- congregationId,
- actorId,
- PublisherType.PionnierAuxiliaires,
- PublisherType.Normal,
- )
+ // Turning the profile off means these members stop being permanent auxiliaries. That fact lives
+ // on the stint now, so close the ongoing ones at the current month rather than flipping a cached
+ // column — closing keeps the history of what they actually did.
+ const now = new Date()
+ await endOngoingEnrolmentsOfType(db, congregationId, actorId, PublisherType.PionnierAuxiliaires, {
+ endMonth: now.getMonth(),
+ endYear: now.getFullYear(),
+ })
}
audit({
diff --git a/app/features/settings/server/export-congregation.server.ts b/app/features/settings/server/export-congregation.server.ts
index 82972577..76de0bf8 100644
--- a/app/features/settings/server/export-congregation.server.ts
+++ b/app/features/settings/server/export-congregation.server.ts
@@ -150,7 +150,6 @@ export function buildExportSteps(db: TransactionClient, congregationId: number,
firstname: true,
lastname: true,
isPublisher: true,
- type: true,
isMale: true,
phone: true,
address: true,
diff --git a/app/features/settings/server/import-user-accounts.server.ts b/app/features/settings/server/import-user-accounts.server.ts
index 366a0a81..58a6f181 100644
--- a/app/features/settings/server/import-user-accounts.server.ts
+++ b/app/features/settings/server/import-user-accounts.server.ts
@@ -3,7 +3,6 @@ import { syncBuiltInRoleAssignments } from '~/shared/domain/built-in-roles.serve
import { ensureAdminRole } from '~/shared/domain/setup.server'
import type { TransactionClient } from '~/shared/infra/db.server'
import { createLogger } from '~/shared/infra/logger.server'
-import type { PublisherType } from '~/shared/types/publisher-type'
import { stripDiacritics } from '~/shared/utils/strip-diacritics'
import type { EntityIdMap } from './data-transfer.type'
import { readNdjsonFile } from './ndjson-archive'
@@ -63,7 +62,6 @@ export async function importMembers(
firstnameNormalized: stripDiacritics(record.firstname),
lastnameNormalized: stripDiacritics(record.lastname),
isPublisher: record.isPublisher,
- type: record.type as PublisherType,
isMale: record.isMale,
phone: record.phone,
address: record.address,
diff --git a/app/features/settings/server/link-member-to-account.server.ts b/app/features/settings/server/link-member-to-account.server.ts
index 70107018..39d0b22b 100644
--- a/app/features/settings/server/link-member-to-account.server.ts
+++ b/app/features/settings/server/link-member-to-account.server.ts
@@ -5,7 +5,6 @@ import { LimitService } from '~/shared/domain/limits.server'
import { ConflictError, NotFoundError } from '~/shared/errors/app-error.server'
import type { TransactionClient } from '~/shared/infra/db.server'
import type { AccountId } from '~/shared/types/branded'
-import type { PublisherType } from '~/shared/types/publisher-type'
export interface LinkMemberToAccountParams {
accountId: AccountId
@@ -19,7 +18,6 @@ export interface LinkMemberToAccountParams {
birthDate: Date | null
baptismDate: Date | null
isPublisher: boolean
- type: PublisherType
isHelder: boolean
isServant: boolean
isAnointed: boolean
@@ -62,7 +60,6 @@ export async function linkMemberToAccount(
birthDate: params.birthDate,
baptismDate: params.baptismDate,
isPublisher: params.isPublisher,
- type: params.type,
isHelder: params.isHelder,
isServant: params.isServant,
isAnointed: params.isAnointed,
diff --git a/app/features/settings/server/migrate-legacy-users-ndjson.server.ts b/app/features/settings/server/migrate-legacy-users-ndjson.server.ts
index 6bea77ac..2ffd8747 100644
--- a/app/features/settings/server/migrate-legacy-users-ndjson.server.ts
+++ b/app/features/settings/server/migrate-legacy-users-ndjson.server.ts
@@ -85,7 +85,6 @@ export async function migrateLegacyUsersNdjson(zip: JsZip, manifestVersion: stri
firstname: row.firstname ?? '',
lastname: row.lastname ?? '',
isPublisher: row.isPublisher,
- type: row.type ?? 'normal',
isMale: row.isMale ?? null,
phone: row.phone ?? '',
address: row.address ?? '',
diff --git a/app/shared/domain/built-in-roles.server.integration.test.ts b/app/shared/domain/built-in-roles.server.integration.test.ts
index f1bb0fe5..77a1e13d 100644
--- a/app/shared/domain/built-in-roles.server.integration.test.ts
+++ b/app/shared/domain/built-in-roles.server.integration.test.ts
@@ -62,7 +62,6 @@ beforeAll(async () => {
lastname: 'Primary',
isPublisher: true,
isMale: false,
- type: PublisherType.Normal,
congregationId: primaryCongId,
},
})
@@ -77,7 +76,6 @@ beforeAll(async () => {
isHelder: true,
// Elder requires baptism
baptismDate: new Date('2000-01-01'),
- type: PublisherType.Normal,
congregationId: otherCongId,
},
})
@@ -188,15 +186,14 @@ describe('syncBuiltInRoleAssignments (integration)', () => {
expect(vi.mocked(audit).mock.calls.length).toBe(callsAfterIdempotentRun)
})
- // The pioneer role is derived from the member's enrolment stints, not the Member.type column.
- // These two pin that: the column is deliberately contradicted in each direction.
- it('attaches the pioneer role from an ongoing enrolment even when the type column says Normal', async () => {
+ // The pioneer role is derived from the member's enrolment stints. These two pin both directions:
+ // a stint grants it, the absence of one withholds it.
+ it('attaches the pioneer role from an ongoing enrolment', async () => {
const member = await testDb.member.create({
data: {
firstname: 'Enrolled',
lastname: `Pioneer-${ts}`,
isPublisher: true,
- type: PublisherType.Normal,
baptismDate: new Date('2015-01-01'),
congregationId: primaryCongId,
},
@@ -220,13 +217,12 @@ describe('syncBuiltInRoleAssignments (integration)', () => {
expect(assignments.map(a => a.role.key)).toContain('pioneer')
})
- it('withholds the pioneer role when only the type column says pioneer and no stint exists', async () => {
+ it('withholds the pioneer role from a member with no stint at all', async () => {
const member = await testDb.member.create({
data: {
firstname: 'Stale',
lastname: `Cache-${ts}`,
isPublisher: true,
- type: PublisherType.PionnierPermanant,
baptismDate: new Date('2015-01-01'),
congregationId: primaryCongId,
},
From 7d30c9762a348b02a8dff07df2bf67f8d2f41a62 Mon Sep 17 00:00:00 2001
From: mindsers
Date: Mon, 31 Aug 2026 21:25:33 +0200
Subject: [PATCH 2/2] fix(publishers): timezone and future-dated stints in the
bulk auxiliary close
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two real defects from review, both in the path that runs when a congregation turns
off the permanent-auxiliary profile.
A future-dated stint made the whole settings save fail. The create form offers start
years from -2 to +2, so an auxiliary appointment can be dated ahead. Closing it at
the current month is an end before its start, which closeEnrolment rejects — and
because the close runs congregation-wide, that one stint threw and took every other
member's close with it. Stints that have not started are now left open and counted
in the return value rather than attempted.
The close month came from the server's clock. Closing picks a MONTH, so a server an
hour either side of midnight on the 1st dated it to the wrong one. It now takes the
congregation's clock via zonedNow, matching how pioneer-goals and the dashboard
already resolve "now" for congregation-scoped work.
Also from review:
- An integration test for the standing-type filter. The unit test asserts the `where`
shape, which cannot tell whether Prisma builds SQL that selects the right people —
and this branch already produced three column reads that typechecked and failed
only at runtime. It pins that a closed single-month auxiliary reads as Normal.
- A test that the bulk close spans several members, which the single-member cases
never exercised.
- `getPublishersWithGroup`'s filter option is now `standingType`, matching the rename
the role predicate already took; it selects on stints, not on a member field.
- The S-21 export selects only the stint fields it reads, via an ENROLMENT_PERIOD_SELECT
kept beside EnrolmentPeriod so the two cannot drift. It was fetching whole rows for
every publisher in a congregation-wide zip to read three fields.
- Eight test names still described setting `Member.type`; they assert standing status.
---
.../publishers/model/pioneer-enrolment.ts | 11 +++
.../routes/publishers/publisher-list.tsx | 2 +-
...eer-enrolment.workflow.integration.test.ts | 76 +++++++++++++++---
.../server/pioneer-enrolment.workflow.ts | 20 +++--
.../server/publishers.integration.test.ts | 79 +++++++++++++++++++
.../server/publishers.server.test.ts | 4 +-
.../publishers/server/publishers.server.ts | 11 +--
.../server/render-activity-pdf-zip.server.tsx | 9 ++-
.../ui/PublisherActivityDocument.tsx | 6 +-
.../settings/routes/congregation/settings.tsx | 11 ++-
.../congregation-settings.server.test.ts | 14 ++--
.../server/congregation-settings.server.ts | 4 +-
12 files changed, 204 insertions(+), 43 deletions(-)
diff --git a/app/features/publishers/model/pioneer-enrolment.ts b/app/features/publishers/model/pioneer-enrolment.ts
index 6fa843ef..73c712f0 100644
--- a/app/features/publishers/model/pioneer-enrolment.ts
+++ b/app/features/publishers/model/pioneer-enrolment.ts
@@ -6,6 +6,17 @@
import { PublisherType } from '~/shared/types/publisher-type'
import { type MonthRef, serviceYearMonths } from './pioneer-pace'
+// The Prisma `select` that produces an EnrolmentPeriod. Kept beside the type so a schema change
+// cannot leave a caller selecting a shape the helpers no longer accept.
+export const ENROLMENT_PERIOD_SELECT = {
+ type: true,
+ startMonth: true,
+ startYear: true,
+ endMonth: true,
+ endYear: true,
+ monthlyGoal: true,
+} as const
+
// A pioneer stint, DB-free and serialisable — mirrors the PioneerEnrolment row's period fields.
export interface EnrolmentPeriod {
type: PublisherType
diff --git a/app/features/publishers/routes/publishers/publisher-list.tsx b/app/features/publishers/routes/publishers/publisher-list.tsx
index 2e45679b..9d059239 100644
--- a/app/features/publishers/routes/publishers/publisher-list.tsx
+++ b/app/features/publishers/routes/publishers/publisher-list.tsx
@@ -53,7 +53,7 @@ export function loader({ request, context }: Route.LoaderArgs) {
return withScopeFromContext(context, async db => {
const [users, groups] = await Promise.all([
- getPublishersWithGroup(db, currentUser.congregationId, { search, groupIds, type }),
+ getPublishersWithGroup(db, currentUser.congregationId, { search, groupIds, standingType: type }),
db.publisherGroup.findMany({
where: { congregationId: currentUser.congregationId },
select: { id: true, name: true },
diff --git a/app/features/publishers/server/pioneer-enrolment.workflow.integration.test.ts b/app/features/publishers/server/pioneer-enrolment.workflow.integration.test.ts
index 522cd5d8..78ca60fa 100644
--- a/app/features/publishers/server/pioneer-enrolment.workflow.integration.test.ts
+++ b/app/features/publishers/server/pioneer-enrolment.workflow.integration.test.ts
@@ -82,7 +82,7 @@ async function standingTypeOf(memberId: number): Promise {
}
describe('pioneer-enrolment workflow (integration)', () => {
- it('an ongoing ANNUAL enrol sets Member.type and attaches the pioneer role', async () => {
+ it('an ongoing ANNUAL enrol gives the member a standing status and attaches the pioneer role', async () => {
const memberId = await makePublisher(`Annual-${ts}`)
await withScope(congId, tx =>
enrolPioneer(tx, memberId, congId, 1, { type: PublisherType.PionnierPermanant, startMonth: 8, startYear: 2025 }),
@@ -91,7 +91,7 @@ describe('pioneer-enrolment workflow (integration)', () => {
expect(await pioneerRoleAttached(memberId)).toBe(true)
})
- it('an ongoing PERMANENT-AUXILIARY enrol sets Member.type to PionnierAuxiliaires', async () => {
+ it('an ongoing PERMANENT-AUXILIARY enrol makes the standing status PionnierAuxiliaires', async () => {
const memberId = await makePublisher(`PermAux-${ts}`)
await withScope(congId, tx =>
enrolPioneer(tx, memberId, congId, 1, {
@@ -104,7 +104,7 @@ describe('pioneer-enrolment workflow (integration)', () => {
expect(await pioneerRoleAttached(memberId)).toBe(true)
})
- it('a SINGLE-MONTH auxiliary enrol leaves Member.type as Normal', async () => {
+ it('a SINGLE-MONTH auxiliary enrol leaves the standing status Normal', async () => {
const memberId = await makePublisher(`Monthly-${ts}`)
await withScope(congId, tx =>
enrolPioneer(tx, memberId, congId, 1, {
@@ -118,13 +118,13 @@ describe('pioneer-enrolment workflow (integration)', () => {
)
expect(await standingTypeOf(memberId)).toBe(PublisherType.Normal)
expect(await pioneerRoleAttached(memberId)).toBe(false)
- // The enrolment record still exists (status is read from it, not from Member.type).
+ // The enrolment record still exists — the standing status is read from it.
const enrolments = await testDb.pioneerEnrolment.findMany({ where: { memberId } })
expect(enrolments).toHaveLength(1)
expect(enrolments[0].monthlyGoal).toBe(15)
})
- it('closing the last ongoing stint reverts Member.type to Normal', async () => {
+ it('closing the last ongoing stint returns the standing status to Normal', async () => {
const memberId = await makePublisher(`Close-${ts}`)
const enrolment = await withScope(congId, tx =>
enrolPioneer(tx, memberId, congId, 1, { type: PublisherType.PionnierPermanant, startMonth: 8, startYear: 2025 }),
@@ -156,7 +156,7 @@ describe('pioneer-enrolment workflow (integration)', () => {
expect(await standingTypeOf(memberId)).toBe(PublisherType.Normal)
})
- it('removing the last ongoing stint reverts Member.type to Normal', async () => {
+ it('removing the last ongoing stint returns the standing status to Normal', async () => {
const memberId = await makePublisher(`RemoveStanding-${ts}`)
const enrolment = await withScope(congId, tx =>
enrolPioneer(tx, memberId, congId, 1, { type: PublisherType.PionnierPermanant, startMonth: 8, startYear: 2025 }),
@@ -169,9 +169,9 @@ describe('pioneer-enrolment workflow (integration)', () => {
expect(await pioneerRoleAttached(memberId)).toBe(false)
})
- // A period edit can flip a stint's shape, which is exactly what the Member.type cache keys on.
+ // A period edit can flip a stint's shape, which is what the member's standing status turns on.
// These three are the cases updateEnrolment could not previously be trusted with.
- it('adding an end date to the only ongoing stint reverts Member.type and detaches the role', async () => {
+ it('adding an end date to the only ongoing stint drops the standing status and detaches the role', async () => {
const memberId = await makePublisher(`UpdateClose-${ts}`)
const enrolment = await withScope(congId, tx =>
enrolPioneer(tx, memberId, congId, 1, {
@@ -195,7 +195,7 @@ describe('pioneer-enrolment workflow (integration)', () => {
expect(await pioneerRoleAttached(memberId)).toBe(false)
})
- it('clearing the end date reopens the stint and restores Member.type', async () => {
+ it('clearing the end date reopens the stint and restores the standing status', async () => {
const memberId = await makePublisher(`UpdateReopen-${ts}`)
const enrolment = await withScope(congId, tx =>
enrolPioneer(tx, memberId, congId, 1, {
@@ -216,7 +216,7 @@ describe('pioneer-enrolment workflow (integration)', () => {
expect(await pioneerRoleAttached(memberId)).toBe(true)
})
- it('correcting an ongoing stint`s type moves Member.type with it', async () => {
+ it('correcting an ongoing stint`s type moves the standing status with it', async () => {
const memberId = await makePublisher(`UpdateType-${ts}`)
const enrolment = await withScope(congId, tx =>
enrolPioneer(tx, memberId, congId, 1, {
@@ -303,4 +303,60 @@ describe('pioneer-enrolment workflow (integration)', () => {
expect(stints[0].endMonth).toBe(3)
expect(stints[0].endYear).toBe(2026)
})
+
+ // The create form offers start years from -2 to +2, so an appointment can be dated ahead. Closing
+ // it at the current month would be `end < start`, which closeEnrolment rejects — and the throw
+ // would take the whole settings save with it, not just this stint.
+ it('endOngoingEnrolmentsOfType leaves a future-dated stint alone instead of failing', async () => {
+ const future = await makePublisher(`BulkFuture-${ts}`)
+ const current = await makePublisher(`BulkCurrent-${ts}`)
+ await withScope(congId, tx =>
+ enrolPioneer(tx, future, congId, 1, {
+ type: PublisherType.PionnierAuxiliaires,
+ startMonth: 5,
+ startYear: 2027,
+ }),
+ )
+ await withScope(congId, tx =>
+ enrolPioneer(tx, current, congId, 1, {
+ type: PublisherType.PionnierAuxiliaires,
+ startMonth: 8,
+ startYear: 2025,
+ }),
+ )
+
+ await withScope(congId, tx =>
+ endOngoingEnrolmentsOfType(tx, congId, 1, PublisherType.PionnierAuxiliaires, { endMonth: 10, endYear: 2025 }),
+ )
+
+ // The one that had started is closed; the future one is untouched and still ongoing.
+ const closed = await testDb.pioneerEnrolment.findFirst({ where: { memberId: current, congregationId: congId } })
+ expect(closed?.endMonth).toBe(10)
+ const untouched = await testDb.pioneerEnrolment.findFirst({ where: { memberId: future, congregationId: congId } })
+ expect(untouched?.endMonth).toBeNull()
+ })
+
+ // Each affected member is synced once, not once per stint — exercised across members, since the
+ // non-overlap invariant means one member can never hold two ongoing stints.
+ it('endOngoingEnrolmentsOfType closes stints for several members', async () => {
+ const a = await makePublisher(`BulkA-${ts}`)
+ const b = await makePublisher(`BulkB-${ts}`)
+ for (const id of [a, b]) {
+ await withScope(congId, tx =>
+ enrolPioneer(tx, id, congId, 1, {
+ type: PublisherType.PionnierAuxiliaires,
+ startMonth: 8,
+ startYear: 2025,
+ }),
+ )
+ }
+
+ const closed = await withScope(congId, tx =>
+ endOngoingEnrolmentsOfType(tx, congId, 1, PublisherType.PionnierAuxiliaires, { endMonth: 10, endYear: 2025 }),
+ )
+
+ expect(closed).toEqual({ closed: 2, skippedFutureDated: expect.any(Number) })
+ expect(await pioneerRoleAttached(a)).toBe(false)
+ expect(await pioneerRoleAttached(b)).toBe(false)
+ })
})
diff --git a/app/features/publishers/server/pioneer-enrolment.workflow.ts b/app/features/publishers/server/pioneer-enrolment.workflow.ts
index 8bd70c25..9c4f4cb8 100644
--- a/app/features/publishers/server/pioneer-enrolment.workflow.ts
+++ b/app/features/publishers/server/pioneer-enrolment.workflow.ts
@@ -55,9 +55,9 @@ export async function endPioneerEnrolment(
}
// Edit a stint's period (and optionally its type). No route posts to this yet — the edit UI is a
-// follow-up; it is here because the recompute is what makes such a route safe to add. The period edit is what makes the recompute
-// load-bearing: adding an end date to the member's only ongoing stint has to drop the cache back to
-// Normal, and clearing one has to raise it again.
+// follow-up; it is here because the role re-sync is what makes such a route safe to add. A period
+// edit is what makes that load-bearing: adding an end date to the member's only ongoing stint has to
+// drop their standing status, and clearing one has to restore it.
export async function updatePioneerEnrolment(
db: TransactionClient,
enrolmentId: number,
@@ -95,11 +95,17 @@ export async function endOngoingEnrolmentsOfType(
actorId: number,
type: PublisherType,
end: { endMonth: number; endYear: number },
-): Promise {
- const ongoing = await db.pioneerEnrolment.findMany({
+): Promise<{ closed: number; skippedFutureDated: number }> {
+ // Starts on or before the end date. A stint dated ahead (the create form offers ±2 years) cannot
+ // be closed at `end` — closeEnrolment rejects an end before the start — and because this runs
+ // congregation-wide, one such stint would throw and take the entire settings save with it rather
+ // than just itself. Those are left open and reported in the return value.
+ const endAbs = end.endYear * 12 + end.endMonth
+ const allOngoing = await db.pioneerEnrolment.findMany({
where: { congregationId, type, endMonth: null, endYear: null },
- select: { id: true, memberId: true },
+ select: { id: true, memberId: true, startMonth: true, startYear: true },
})
+ const ongoing = allOngoing.filter(s => s.startYear * 12 + s.startMonth <= endAbs)
for (const stint of ongoing) {
await closeEnrolment(db, stint.id, congregationId, actorId, end)
@@ -110,5 +116,5 @@ export async function endOngoingEnrolmentsOfType(
await _syncMemberRoles(db, memberId, congregationId, actorId)
}
- return ongoing.length
+ return { closed: ongoing.length, skippedFutureDated: allOngoing.length - ongoing.length }
}
diff --git a/app/features/publishers/server/publishers.integration.test.ts b/app/features/publishers/server/publishers.integration.test.ts
index 59de1d47..c1ba2399 100644
--- a/app/features/publishers/server/publishers.integration.test.ts
+++ b/app/features/publishers/server/publishers.integration.test.ts
@@ -2,6 +2,7 @@ import { PrismaPg } from '@prisma/adapter-pg'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { PrismaClient } from '~/database/generated/client'
import type { CongregationId, MemberId } from '~/shared/types/branded'
+import { PublisherType } from '~/shared/types/publisher-type'
import { createTestCongregation, createTestUser } from '~/tests/factories'
import { getPublisherById, getPublishersWithGroup } from './publishers.server'
@@ -135,3 +136,81 @@ describe('getPublishersWithGroup search filter', () => {
expect(result).toHaveLength(2)
})
})
+
+// The type filter moved from a Member column to a condition over PioneerEnrolment stints. The unit
+// test asserts the `where` SHAPE, which cannot tell whether Prisma builds SQL that actually selects
+// the right people — and this branch already produced three column reads that typechecked and failed
+// only at runtime. So exercise it against the database.
+describe('getPublishersWithGroup — filtering by standing type (integration)', () => {
+ const ts = Date.now()
+ let congId: number
+ let permanentId: number
+ let monthlyAuxId: number
+ let plainId: number
+
+ beforeAll(async () => {
+ const cong = await createTestCongregation(testDb, { name: `TypeFilter ${ts}`, slug: `type-filter-${ts}` })
+ congId = cong.id
+
+ const permanent = await createTestUser(testDb, congId, { isPublisher: true, firstname: `Perm${ts}` })
+ permanentId = permanent.memberId ?? permanent.id
+ const monthly = await createTestUser(testDb, congId, { isPublisher: true, firstname: `Month${ts}` })
+ monthlyAuxId = monthly.memberId ?? monthly.id
+ const plain = await createTestUser(testDb, congId, { isPublisher: true, firstname: `Plain${ts}` })
+ plainId = plain.memberId ?? plain.id
+
+ await testDb.pioneerEnrolment.createMany({
+ data: [
+ // Ongoing permanent pioneer.
+ {
+ memberId: permanentId,
+ congregationId: congId,
+ type: PublisherType.PionnierPermanant,
+ startMonth: 8,
+ startYear: 2025,
+ },
+ // Single-month auxiliary: closed, so this member is NOT a standing auxiliary.
+ {
+ memberId: monthlyAuxId,
+ congregationId: congId,
+ type: PublisherType.PionnierAuxiliaires,
+ startMonth: 4,
+ startYear: 2026,
+ endMonth: 4,
+ endYear: 2026,
+ },
+ ],
+ })
+ })
+
+ afterAll(async () => {
+ await testDb.pioneerEnrolment.deleteMany({ where: { congregationId: congId } })
+ await testDb.userAccount.deleteMany({ where: { congregationId: congId } })
+ await testDb.member.deleteMany({ where: { congregationId: congId } })
+ await testDb.congregation.deleteMany({ where: { id: congId } })
+ })
+
+ it('returns only the member with an ongoing stint of that type', async () => {
+ const result = await withScope(congId, tx =>
+ getPublishersWithGroup(tx, congId, { standingType: PublisherType.PionnierPermanant }),
+ )
+ expect(result.map(r => r.id)).toEqual([permanentId])
+ })
+
+ it('returns nobody for a type nobody currently holds', async () => {
+ const result = await withScope(congId, tx =>
+ getPublishersWithGroup(tx, congId, { standingType: PublisherType.Missionnaire }),
+ )
+ expect(result).toEqual([])
+ })
+
+ // The behaviour the old column had: a single-month auxiliary is closed, so its holder reads Normal.
+ it('counts a member whose only stint is a closed single-month auxiliary as Normal', async () => {
+ const result = await withScope(congId, tx =>
+ getPublishersWithGroup(tx, congId, { standingType: PublisherType.Normal }),
+ )
+ const ids = result.map(r => r.id).sort((a, b) => a - b)
+ expect(ids).toEqual([monthlyAuxId, plainId].sort((a, b) => a - b))
+ expect(ids).not.toContain(permanentId)
+ })
+})
diff --git a/app/features/publishers/server/publishers.server.test.ts b/app/features/publishers/server/publishers.server.test.ts
index ed16d178..5a097fff 100644
--- a/app/features/publishers/server/publishers.server.test.ts
+++ b/app/features/publishers/server/publishers.server.test.ts
@@ -96,7 +96,7 @@ describe('getPublishersWithGroup', () => {
it('narrows the where clause to the provided publisher type', async () => {
vi.mocked(db.member.findMany).mockResolvedValue([] as never)
- await getPublishersWithGroup(db, 1, { type: PublisherType.PionnierPermanant })
+ await getPublishersWithGroup(db, 1, { standingType: PublisherType.PionnierPermanant })
const where = vi.mocked(db.member.findMany).mock.calls[0]?.[0]?.where
expect(where).toMatchObject({
@@ -109,7 +109,7 @@ describe('getPublishersWithGroup', () => {
await getPublishersWithGroup(db, 1, {
search: 'jean',
groupIds: [42],
- type: PublisherType.Normal,
+ standingType: PublisherType.Normal,
})
const where = vi.mocked(db.member.findMany).mock.calls[0]?.[0]?.where
diff --git a/app/features/publishers/server/publishers.server.ts b/app/features/publishers/server/publishers.server.ts
index bc3d5bb5..3be3ad54 100644
--- a/app/features/publishers/server/publishers.server.ts
+++ b/app/features/publishers/server/publishers.server.ts
@@ -1,6 +1,7 @@
import type { TransactionClient } from '~/shared/infra/db.server'
import type { CongregationId, MemberId } from '~/shared/types/branded'
import { PublisherType } from '~/shared/types/publisher-type'
+import { ENROLMENT_PERIOD_SELECT } from '../model/pioneer-enrolment'
// Includes `pioneerEnrolments` because the S-21 export ticks a pioneer box from the member's
// standing status, which is derived from the stints.
@@ -17,7 +18,7 @@ export function getPublisherById(
include: {
account: { select: { id: true, email: true, active: true } },
publisherGroup: { include: { responsible: true, deputy: true } },
- pioneerEnrolments: true,
+ pioneerEnrolments: { select: ENROLMENT_PERIOD_SELECT },
activities: {
where: {
OR: [
@@ -49,7 +50,7 @@ export async function getPublishers(
export async function getPublishersWithGroup(
db: TransactionClient,
congregationId: number,
- options?: { search?: string; groupIds?: number[]; type?: PublisherType },
+ options?: { search?: string; groupIds?: number[]; standingType?: PublisherType },
) {
const searchFilter = options?.search
? {
@@ -66,11 +67,11 @@ export async function getPublishersWithGroup(
// "Type" is the member's standing status, which lives on their stints: an ONGOING stint of that
// type, or — for Normal — no ongoing stint at all. A single-month auxiliary is closed, so it
// correctly leaves the member under Normal, exactly as the old `Member.type` column did.
- const typeFilter = !options?.type
+ const typeFilter = !options?.standingType
? {}
- : options.type === PublisherType.Normal
+ : options.standingType === PublisherType.Normal
? { pioneerEnrolments: { none: { endMonth: null } } }
- : { pioneerEnrolments: { some: { type: options.type, endMonth: null } } }
+ : { pioneerEnrolments: { some: { type: options.standingType, endMonth: null } } }
return await db.member.findMany({
where: { isPublisher: true, leftAt: null, congregationId, ...searchFilter, ...groupFilter, ...typeFilter },
diff --git a/app/features/publishers/server/render-activity-pdf-zip.server.tsx b/app/features/publishers/server/render-activity-pdf-zip.server.tsx
index 9965b76d..5f8fa581 100644
--- a/app/features/publishers/server/render-activity-pdf-zip.server.tsx
+++ b/app/features/publishers/server/render-activity-pdf-zip.server.tsx
@@ -1,11 +1,12 @@
import { pdf } from '@react-pdf/renderer'
import JsZip from 'jszip'
import pLimit from 'p-limit'
-import type { Member, PioneerEnrolment, PublisherActivity } from '~/database/generated/client'
+import type { Member, PublisherActivity } from '~/database/generated/client'
+import { ENROLMENT_PERIOD_SELECT, type EnrolmentPeriod } from '~/features/publishers/model/pioneer-enrolment'
import { PublisherActivityDocument } from '~/features/publishers/ui/PublisherActivityDocument'
import type { TransactionClient } from '~/shared/infra/db.server'
-type PublisherWithActivities = Member & { activities: PublisherActivity[]; pioneerEnrolments: PioneerEnrolment[] }
+type PublisherWithActivities = Member & { activities: PublisherActivity[]; pioneerEnrolments: EnrolmentPeriod[] }
export interface PublisherScopeOptions {
groupId?: number
@@ -35,8 +36,8 @@ export async function getPublishersWithYearActivities(
include: {
activities: { where: yearFilter },
// The S-21 sheet ticks a pioneer box from the member's standing status, which lives on the
- // stints — without these every sheet would print unticked.
- pioneerEnrolments: true,
+ // stints — without these every sheet would print unticked. Only the period fields are read.
+ pioneerEnrolments: { select: ENROLMENT_PERIOD_SELECT },
},
})
}
diff --git a/app/features/publishers/ui/PublisherActivityDocument.tsx b/app/features/publishers/ui/PublisherActivityDocument.tsx
index 852de8b2..7ac48611 100644
--- a/app/features/publishers/ui/PublisherActivityDocument.tsx
+++ b/app/features/publishers/ui/PublisherActivityDocument.tsx
@@ -1,11 +1,11 @@
import { Document, Page, Polygon, StyleSheet, Svg, Text, View } from '@react-pdf/renderer'
-import type { Member, PioneerEnrolment, PublisherActivity } from '~/database/generated/client'
-import { standingTypeFromEnrolments } from '~/features/publishers/model/pioneer-enrolment'
+import type { Member, PublisherActivity } from '~/database/generated/client'
+import { type EnrolmentPeriod, standingTypeFromEnrolments } from '~/features/publishers/model/pioneer-enrolment'
import * as m from '~/i18n/paraglide/messages'
import { PublisherType } from '~/shared/types/publisher-type'
interface PublisherActivityDocumentProps {
- publisher: Member & { activities: PublisherActivity[]; pioneerEnrolments: PioneerEnrolment[] }
+ publisher: Member & { activities: PublisherActivity[]; pioneerEnrolments: EnrolmentPeriod[] }
}
export function PublisherActivityDocument({ publisher }: PublisherActivityDocumentProps) {
diff --git a/app/features/settings/routes/congregation/settings.tsx b/app/features/settings/routes/congregation/settings.tsx
index 4a68add5..a3120106 100644
--- a/app/features/settings/routes/congregation/settings.tsx
+++ b/app/features/settings/routes/congregation/settings.tsx
@@ -22,6 +22,7 @@ import { Label } from '~/shared/ui/label'
import { PageHeader } from '~/shared/ui/PageHeader'
import { SubmitButton } from '~/shared/ui/SubmitButton'
import { UnsavedChangesDialog } from '~/shared/ui/UnsavedChangesDialog'
+import { zonedNow } from '~/shared/utils/zoned-now'
import type { Route } from './+types/settings'
export const meta: Route.MetaFunction = () => {
@@ -168,9 +169,13 @@ export async function action({ request, context }: Route.ActionArgs) {
submission.value
return withScopeFromContext(context, async db => {
- await updateCongregationSettings(db, congregation.id, actorId, {
- auxiliaryPioneerProfileActivated,
- })
+ await updateCongregationSettings(
+ db,
+ congregation.id,
+ actorId,
+ { auxiliaryPioneerProfileActivated },
+ zonedNow(congregation.timezone),
+ )
return redirect('/settings/congregation')
})
diff --git a/app/features/settings/server/congregation-settings.server.test.ts b/app/features/settings/server/congregation-settings.server.test.ts
index 2192ef72..35529703 100644
--- a/app/features/settings/server/congregation-settings.server.test.ts
+++ b/app/features/settings/server/congregation-settings.server.test.ts
@@ -32,13 +32,14 @@ beforeEach(() => {
mockDb.member.findMany.mockResolvedValue([])
})
+// A fixed instant so the asserted close month is the caller's, not whatever today happens to be.
+const NOW = new Date(2026, 2, 15)
+
describe('updateCongregationSettings', () => {
it('sets the auxiliary pioneer setting', async () => {
vi.mocked(setSetting).mockResolvedValue(undefined as never)
- await updateCongregationSettings(mockDb as never, 10, 99, {
- auxiliaryPioneerProfileActivated: 'true',
- })
+ await updateCongregationSettings(mockDb as never, 10, 99, { auxiliaryPioneerProfileActivated: 'true' }, NOW)
expect(setSetting).toHaveBeenCalledWith(mockDb, 'auxiliary-pioneer-profile-active', 'true', 10)
expect(endOngoingEnrolmentsOfType).not.toHaveBeenCalled()
@@ -52,16 +53,15 @@ describe('updateCongregationSettings', () => {
it('closes ongoing auxiliary enrolments when the profile is deactivated', async () => {
vi.mocked(setSetting).mockResolvedValue(undefined as never)
- await updateCongregationSettings(mockDb as never, 10, 99, {
- auxiliaryPioneerProfileActivated: 'false',
- })
+ await updateCongregationSettings(mockDb as never, 10, 99, { auxiliaryPioneerProfileActivated: 'false' }, NOW)
expect(endOngoingEnrolmentsOfType).toHaveBeenCalledWith(
mockDb,
10,
99,
PublisherType.PionnierAuxiliaires,
- expect.objectContaining({ endMonth: expect.any(Number), endYear: expect.any(Number) }),
+ // Derived from the caller's clock, not the server's: March 2026 is month 2, 0-indexed.
+ { endMonth: 2, endYear: 2026 },
)
})
})
diff --git a/app/features/settings/server/congregation-settings.server.ts b/app/features/settings/server/congregation-settings.server.ts
index 4b0fb8eb..9f89ff04 100644
--- a/app/features/settings/server/congregation-settings.server.ts
+++ b/app/features/settings/server/congregation-settings.server.ts
@@ -12,6 +12,9 @@ export async function updateCongregationSettings(
data: {
auxiliaryPioneerProfileActivated: string
},
+ // The congregation's own clock, not the server's. Closing a stint picks a MONTH, so a server an
+ // hour either side of midnight on the 1st would otherwise date it to the wrong one.
+ now: Date,
) {
await setSetting(
db,
@@ -24,7 +27,6 @@ export async function updateCongregationSettings(
// Turning the profile off means these members stop being permanent auxiliaries. That fact lives
// on the stint now, so close the ongoing ones at the current month rather than flipping a cached
// column — closing keeps the history of what they actually did.
- const now = new Date()
await endOngoingEnrolmentsOfType(db, congregationId, actorId, PublisherType.PionnierAuxiliaires, {
endMonth: now.getMonth(),
endYear: now.getFullYear(),