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/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/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

)} - {publisher?.type != null && publisher.type === PublisherType.Normal && ( + {publisher != null && publisher.standingType === PublisherType.Normal && (
- - {hideTypeSelect ? ( - - ) : ( -
- - - {type === PublisherType.PionnierAuxiliaires && ( -

{m.publishers_form_auxiliary_pioneer_warning()}

- )} -
- )} ) 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/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..35529703 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: { @@ -32,71 +32,36 @@ 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(mockDb.member.updateMany).not.toHaveBeenCalled() - expect(syncBuiltInRoleAssignments).not.toHaveBeenCalled() - }) - - it('resets auxiliary pioneers to normal when feature 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) + expect(endOngoingEnrolmentsOfType).not.toHaveBeenCalled() }) - it('does not sync when no auxiliary pioneers exist to reset', 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.findMany.mockResolvedValue([]) - mockDb.member.updateMany.mockResolvedValue({ count: 0 }) - await updateCongregationSettings(mockDb as never, 10, 99, { - auxiliaryPioneerProfileActivated: 'false', - }) + await updateCongregationSettings(mockDb as never, 10, 99, { auxiliaryPioneerProfileActivated: 'false' }, NOW) - expect(mockDb.member.updateMany).toHaveBeenCalled() - expect(syncBuiltInRoleAssignments).not.toHaveBeenCalled() + expect(endOngoingEnrolmentsOfType).toHaveBeenCalledWith( + mockDb, + 10, + 99, + PublisherType.PionnierAuxiliaires, + // 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 33fe372a..9f89ff04 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' @@ -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, @@ -21,13 +24,13 @@ 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. + 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, },