Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/features/events/index.server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Public server-only surface of the events feature.

export { getNextDaysOffs } from './server/days-off.server'
export { duplicateTemplate } from './server/duplicate-template.server'
export {
deleteTemplate,
deleteTemplatePart,
deleteTemplateServicePart,
duplicateTemplate,
getTemplateById,
getTemplates,
isTemplateResponsible,
Expand Down
184 changes: 184 additions & 0 deletions app/features/events/server/duplicate-template.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('~/shared/infra/db.server', () => ({
unscopedDb: {
eventTemplate: { findFirst: vi.fn(), create: vi.fn() },
templatePartAllowedRole: { createMany: vi.fn() },
templateServicePartAllowedRole: { createMany: vi.fn() },
},
}))

const { duplicateTemplate } = await import('./duplicate-template.server')
const { unscopedDb: db } = await import('~/shared/infra/db.server')

beforeEach(() => {
vi.resetAllMocks()
})

describe('duplicateTemplate', () => {
it('returns null when source template not found', async () => {
vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(null as never)

const result = await duplicateTemplate(db, 99, 1)

expect(result).toBeNull()
expect(vi.mocked(db.eventTemplate.create)).not.toHaveBeenCalled()
})

// System templates are looked up by `key` at runtime. Duplicating them would
// produce a row with an untethered `-copy-<ts>` suffix; the UI hides the
// action but this is the server-side belt-and-suspenders check.
it('returns null when the source is a system template', async () => {
vi.mocked(db.eventTemplate.findFirst).mockResolvedValue({
id: 1,
key: 'day-off',
name: 'Absence',
parts: [],
serviceParts: [],
} as never)

const result = await duplicateTemplate(db, 1, 1)

expect(result).toBeNull()
expect(vi.mocked(db.eventTemplate.create)).not.toHaveBeenCalled()
})

it('copies allowed-role lists from source parts and service roles to the duplicate', async () => {
const source = {
id: 5,
name: 'Reunion',
key: 'midweek',
description: '',
weekDay: 2,
isRecurring: true,
parts: [
{
id: 10,
name: 'Discours',
section: '',
track: '',
order: 1,
durationMin: 30,
allowExternalSpeaker: false,
allowedRoles: [
{ roleId: 100, asKind: 'speaker' },
{ roleId: 200, asKind: 'reader' },
],
},
{
id: 11,
name: 'Cantique',
section: '',
track: '',
order: 2,
durationMin: 5,
allowExternalSpeaker: false,
allowedRoles: [],
},
],
serviceParts: [
{ id: 20, name: 'Son', key: 'sono', allowedRoles: [{ roleId: 300 }] },
{ id: 21, name: 'Stage', key: 'stage', allowedRoles: [] },
],
}
vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(source as never)

const duplicated = {
id: 99,
name: 'Reunion (copie)',
parts: [
{ id: 510, order: 1 },
{ id: 511, order: 2 },
],
serviceParts: [
{ id: 520, name: 'Son' },
{ id: 521, name: 'Stage' },
],
}
vi.mocked(db.eventTemplate.create).mockResolvedValue(duplicated as never)
vi.mocked(db.templatePartAllowedRole.createMany).mockResolvedValue({ count: 2 } as never)
vi.mocked(db.templateServicePartAllowedRole.createMany).mockResolvedValue({ count: 1 } as never)

await duplicateTemplate(db, 5, 7)

// Speaker role for the first part
expect(vi.mocked(db.templatePartAllowedRole.createMany)).toHaveBeenCalledWith({
data: [{ partId: 510, roleId: 100, asKind: 'speaker', congregationId: 7 }],
skipDuplicates: true,
})
// Reader role for the first part
expect(vi.mocked(db.templatePartAllowedRole.createMany)).toHaveBeenCalledWith({
data: [{ partId: 510, roleId: 200, asKind: 'reader', congregationId: 7 }],
skipDuplicates: true,
})
// Service-role allowed-roles for the first service role
expect(vi.mocked(db.templateServicePartAllowedRole.createMany)).toHaveBeenCalledWith({
data: [{ servicePartId: 520, roleId: 300, congregationId: 7 }],
skipDuplicates: true,
})
// Empty lists are skipped
expect(vi.mocked(db.templatePartAllowedRole.createMany)).toHaveBeenCalledTimes(2)
expect(vi.mocked(db.templateServicePartAllowedRole.createMany)).toHaveBeenCalledTimes(1)
})

// A duplicated template must carry the source's per-part role labels; without
// this, admins who clone a template lose their custom labels silently.
it('copies speakerLabel and readerLabel from source parts to the duplicate (Layer 4)', async () => {
const source = {
id: 5,
name: 'Reunion',
key: 'midweek',
description: '',
weekDay: 2,
isRecurring: true,
parts: [
{
id: 10,
name: 'Bible reading',
section: '',
track: '',
order: 1,
durationMin: 5,
allowExternalSpeaker: false,
// Distinct sentinels per part so an ordering regression in the copy
// loop (swapping parts[0] and parts[1]) fails visibly.
speakerLabel: 'STUDENT-SENTINEL-P1',
readerLabel: null,
allowedRoles: [],
},
{
id: 11,
name: 'Return visit',
section: '',
track: '',
order: 2,
durationMin: 10,
allowExternalSpeaker: false,
speakerLabel: 'STUDENT-SENTINEL-P2',
readerLabel: 'HOUSEHOLDER-SENTINEL-P2',
allowedRoles: [],
},
],
serviceParts: [],
}
vi.mocked(db.eventTemplate.findFirst).mockResolvedValue(source as never)
vi.mocked(db.eventTemplate.create).mockResolvedValue({
id: 99,
name: 'Reunion (copie)',
parts: [],
serviceParts: [],
} as never)

await duplicateTemplate(db, 5, 7)

const createCall = vi.mocked(db.eventTemplate.create).mock.calls[0][0] as {
data: { parts: { create: Array<{ speakerLabel: string | null; readerLabel: string | null }> } }
}
const createdParts = createCall.data.parts.create
expect(createdParts[0]).toMatchObject({ speakerLabel: 'STUDENT-SENTINEL-P1', readerLabel: null })
expect(createdParts[1]).toMatchObject({
speakerLabel: 'STUDENT-SENTINEL-P2',
readerLabel: 'HOUSEHOLDER-SENTINEL-P2',
})
})
})
91 changes: 91 additions & 0 deletions app/features/events/server/duplicate-template.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { isSystemTemplate } from '~/features/events/model/event-template.type'
import type { TransactionClient } from '~/shared/infra/db.server'

export async function duplicateTemplate(db: TransactionClient, templateId: number, congregationId: number) {
const source = await db.eventTemplate.findFirst({
where: { id: templateId, congregationId },
include: {
parts: {
orderBy: { order: 'asc' },
include: { allowedRoles: true },
},
serviceParts: { include: { allowedRoles: true } },
},
})
if (!source) return null

// System templates are looked up by key at runtime — duplicating them just
// clutters the list with an untethered `-copy-<ts>` row. The UI hides the
// Duplicate button; this is the server-side match.
if (isSystemTemplate(source.key)) return null

const duplicated = await db.eventTemplate.create({
data: {
name: `${source.name} (copie)`,
key: `${source.key}-copy-${Date.now()}`,
description: source.description,
weekDay: source.weekDay,
isRecurring: source.isRecurring,
startTime: source.startTime,
endTime: source.endTime,
congregationId,
parts: {
create: source.parts.map(part => ({
name: part.name,
section: part.section,
track: part.track,
order: part.order,
durationMin: part.durationMin,
allowExternalSpeaker: part.allowExternalSpeaker,
speakerLabel: part.speakerLabel,
readerLabel: part.readerLabel,
congregationId,
})),
},
serviceParts: {
create: source.serviceParts.map(role => ({
name: role.name,
key: `${role.key}-copy-${Date.now()}`,
congregationId,
})),
},
},
include: {
parts: { orderBy: { order: 'asc' } },
serviceParts: { orderBy: { name: 'asc' } },
},
})

const sourcePartsByOrder = new Map(source.parts.map(p => [p.order, p]))
const sourceServicePartsByName = new Map(source.serviceParts.map(r => [r.name, r]))

for (const newPart of duplicated.parts) {
const sourcePart = sourcePartsByOrder.get(newPart.order)
if (!sourcePart) continue
const speakerRoleIds = sourcePart.allowedRoles.filter(r => r.asKind === 'speaker').map(r => r.roleId)
const readerRoleIds = sourcePart.allowedRoles.filter(r => r.asKind === 'reader').map(r => r.roleId)
if (speakerRoleIds.length > 0) {
await db.templatePartAllowedRole.createMany({
data: speakerRoleIds.map(roleId => ({ partId: newPart.id, roleId, asKind: 'speaker', congregationId })),
skipDuplicates: true,
})
}
if (readerRoleIds.length > 0) {
await db.templatePartAllowedRole.createMany({
data: readerRoleIds.map(roleId => ({ partId: newPart.id, roleId, asKind: 'reader', congregationId })),
skipDuplicates: true,
})
}
}

for (const newRole of duplicated.serviceParts) {
const sourceRole = sourceServicePartsByName.get(newRole.name)
if (!sourceRole || sourceRole.allowedRoles.length === 0) continue
await db.templateServicePartAllowedRole.createMany({
data: sourceRole.allowedRoles.map(r => ({ servicePartId: newRole.id, roleId: r.roleId, congregationId })),
skipDuplicates: true,
})
}

return duplicated
}
Loading