From 6e5a0a32e353dde0aed60a9d5d4317d8fa06ea18 Mon Sep 17 00:00:00 2001 From: Jeremy Zongker Date: Thu, 20 Aug 2026 22:15:10 -0500 Subject: [PATCH] Add positionId to plan items Order-of-service items can now reference one of the plan's positions so the assigned volunteer's name can be shown beside the item. Plan copy remaps positionId to the copied plan's new position ids (null when positions aren't copied). Fixes ChurchApps/ChurchAppsSupport#988 --- .../doing/controllers/PlanController.ts | 13 +++--- src/modules/doing/models/PlanItem.ts | 1 + .../doing/repositories/PlanItemRepo.ts | 4 +- .../__tests__/PlanItemRepo.test.ts | 43 +++++++++++++++++++ .../doing/2026-08-20_plan_item_position.ts | 9 ++++ 5 files changed, 63 insertions(+), 7 deletions(-) create mode 100644 src/modules/doing/repositories/__tests__/PlanItemRepo.test.ts create mode 100644 tools/migrations/doing/2026-08-20_plan_item_position.ts diff --git a/src/modules/doing/controllers/PlanController.ts b/src/modules/doing/controllers/PlanController.ts index 368c5334..18206924 100644 --- a/src/modules/doing/controllers/PlanController.ts +++ b/src/modules/doing/controllers/PlanController.ts @@ -101,7 +101,7 @@ export class PlanController extends DoingBaseController { return result; } - private async copyTimesAndPositions(churchId: string, sourcePlanId: string, targetPlan: Plan, oldPlan: Plan, copyAssignments: boolean): Promise> { + private async copyTimesAndPositions(churchId: string, sourcePlanId: string, targetPlan: Plan, oldPlan: Plan, copyAssignments: boolean): Promise<{ timeIdMap: Map; positionIdMap: Map }> { const times: Time[] = await this.repos.time.loadByPlanId(churchId, sourcePlanId) as Time[]; const positions: Position[] = await this.repos.position.loadByPlanId(churchId, sourcePlanId) as Position[]; const positionIdMap = new Map(); @@ -144,10 +144,10 @@ export class PlanController extends DoingBaseController { } await Promise.all(promises); - return timeIdMap; + return { timeIdMap, positionIdMap }; } - private async copyServiceOrderItems(churchId: string, sourcePlanId: string, targetPlanId: string): Promise> { + private async copyServiceOrderItems(churchId: string, sourcePlanId: string, targetPlanId: string, positionIdMap: Map): Promise> { const planItems: PlanItem[] = await this.repos.planItem.loadForPlan(churchId, sourcePlanId) as PlanItem[]; const planItemIdMap = new Map(); @@ -156,6 +156,7 @@ export class PlanController extends DoingBaseController { const oldId = item.id; item.id = undefined; item.planId = targetPlanId; + item.positionId = positionIdMap.get(item.positionId || ""); const savedItem = await this.repos.planItem.save(item); if (oldId) planItemIdMap.set(oldId, savedItem.id || ""); } @@ -168,6 +169,7 @@ export class PlanController extends DoingBaseController { item.id = undefined; item.planId = targetPlanId; item.parentId = newParentId; + item.positionId = positionIdMap.get(item.positionId || ""); const savedItem = await this.repos.planItem.save(item); if (oldId) planItemIdMap.set(oldId, savedItem.id || ""); } @@ -265,14 +267,15 @@ export class PlanController extends DoingBaseController { const plan = await this.repos.plan.save(p); let timeIdMap = new Map(); + let positionIdMap = new Map(); let planItemIdMap = new Map(); if (copyMode !== "none") { - timeIdMap = await this.copyTimesAndPositions(au.churchId, id, plan, oldPlan, copyMode === "all"); + ({ timeIdMap, positionIdMap } = await this.copyTimesAndPositions(au.churchId, id, plan, oldPlan, copyMode === "all")); } if (copyServiceOrder) { - planItemIdMap = await this.copyServiceOrderItems(au.churchId, id, plan.id!); + planItemIdMap = await this.copyServiceOrderItems(au.churchId, id, plan.id!, positionIdMap); } if (timeIdMap.size > 0 && planItemIdMap.size > 0) { diff --git a/src/modules/doing/models/PlanItem.ts b/src/modules/doing/models/PlanItem.ts index 0de094f1..7dc770f0 100644 --- a/src/modules/doing/models/PlanItem.ts +++ b/src/modules/doing/models/PlanItem.ts @@ -6,6 +6,7 @@ export class PlanItem { public sort?: number; public itemType?: string; public relatedId?: string; + public positionId?: string; public label?: string; public description?: string; public seconds?: number; diff --git a/src/modules/doing/repositories/PlanItemRepo.ts b/src/modules/doing/repositories/PlanItemRepo.ts index 3aa475c5..750fbe97 100644 --- a/src/modules/doing/repositories/PlanItemRepo.ts +++ b/src/modules/doing/repositories/PlanItemRepo.ts @@ -11,12 +11,12 @@ export class PlanItemRepo { private async create(model: PlanItem): Promise { model.id = UniqueIdHelper.shortId(); - await getDb().insertInto("planItems").values({ id: model.id, churchId: model.churchId, planId: model.planId, parentId: model.parentId, sort: model.sort, itemType: model.itemType, relatedId: model.relatedId, label: model.label, description: model.description, seconds: model.seconds, link: model.link, providerId: model.providerId, providerPath: model.providerPath, providerContentPath: model.providerContentPath, thumbnailUrl: model.thumbnailUrl }).execute(); + await getDb().insertInto("planItems").values({ id: model.id, churchId: model.churchId, planId: model.planId, parentId: model.parentId, sort: model.sort, itemType: model.itemType, relatedId: model.relatedId, positionId: model.positionId, label: model.label, description: model.description, seconds: model.seconds, link: model.link, providerId: model.providerId, providerPath: model.providerPath, providerContentPath: model.providerContentPath, thumbnailUrl: model.thumbnailUrl }).execute(); return model; } private async update(model: PlanItem): Promise { - await getDb().updateTable("planItems").set({ planId: model.planId, parentId: model.parentId, sort: model.sort, itemType: model.itemType, relatedId: model.relatedId, label: model.label, description: model.description, seconds: model.seconds, link: model.link, providerId: model.providerId, providerPath: model.providerPath, providerContentPath: model.providerContentPath, thumbnailUrl: model.thumbnailUrl }).where("id", "=", model.id).where("churchId", "=", model.churchId).execute(); + await getDb().updateTable("planItems").set({ planId: model.planId, parentId: model.parentId, sort: model.sort, itemType: model.itemType, relatedId: model.relatedId, positionId: model.positionId, label: model.label, description: model.description, seconds: model.seconds, link: model.link, providerId: model.providerId, providerPath: model.providerPath, providerContentPath: model.providerContentPath, thumbnailUrl: model.thumbnailUrl }).where("id", "=", model.id).where("churchId", "=", model.churchId).execute(); return model; } diff --git a/src/modules/doing/repositories/__tests__/PlanItemRepo.test.ts b/src/modules/doing/repositories/__tests__/PlanItemRepo.test.ts new file mode 100644 index 00000000..07b2ca7f --- /dev/null +++ b/src/modules/doing/repositories/__tests__/PlanItemRepo.test.ts @@ -0,0 +1,43 @@ +import "reflect-metadata"; +jest.mock("../../db/index", () => ({ getDb: jest.fn() })); +jest.mock("@churchapps/apihelper", () => ({ UniqueIdHelper: { shortId: () => "gen_id" } })); + +import { getDb } from "../../db/index.js"; +import { PlanItemRepo } from "../PlanItemRepo.js"; + +function recordingDb() { + const calls: { method: string; args: any[] }[] = []; + const proxy: any = new Proxy({}, { + get(_t, prop) { + if (typeof prop === "symbol" || prop === "then") return undefined; + if (prop === "execute") return async () => []; + if (prop === "executeTakeFirst") return async () => null; + return (...args: any[]) => { calls.push({ method: prop as string, args }); return proxy; }; + } + }); + return { proxy, calls }; +} + +const firstArg = (calls: { method: string; args: any[] }[], method: string) => calls.find((c) => c.method === method)?.args[0]; + +describe("PlanItemRepo positionId", () => { + afterEach(() => jest.restoreAllMocks()); + + it("persists positionId on create", async () => { + const { proxy, calls } = recordingDb(); + (getDb as jest.Mock).mockReturnValue(proxy); + const saved = await new PlanItemRepo().save({ churchId: "c1", planId: "pl1", label: "Sermon", positionId: "pos1" }); + expect(saved.id).toBe("gen_id"); + expect(firstArg(calls, "insertInto")).toBe("planItems"); + expect(firstArg(calls, "values")).toMatchObject({ id: "gen_id", planId: "pl1", positionId: "pos1" }); + }); + + it("persists positionId on update", async () => { + const { proxy, calls } = recordingDb(); + (getDb as jest.Mock).mockReturnValue(proxy); + await new PlanItemRepo().save({ id: "pi1", churchId: "c1", planId: "pl1", label: "Sermon", positionId: "pos2" }); + expect(calls.some((c) => c.method === "insertInto")).toBe(false); + expect(firstArg(calls, "updateTable")).toBe("planItems"); + expect(firstArg(calls, "set")).toMatchObject({ positionId: "pos2" }); + }); +}); diff --git a/tools/migrations/doing/2026-08-20_plan_item_position.ts b/tools/migrations/doing/2026-08-20_plan_item_position.ts new file mode 100644 index 00000000..c06d1e42 --- /dev/null +++ b/tools/migrations/doing/2026-08-20_plan_item_position.ts @@ -0,0 +1,9 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE planItems ADD COLUMN positionId CHAR(11) NULL`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE planItems DROP COLUMN positionId`.execute(db); +}