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); +}