Add schedule validation and service layer

This commit is contained in:
NeilClack
2026-09-11 18:16:00 -05:00
parent e6b492a335
commit 4922e941ae
4 changed files with 567 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
// Schedule service layer. Owns the database orchestration and enforcement of
// the Block business rules (at most 4 per user, at least one Proclamation,
// no overnight ranges, no overlaps). Pure validation lives in
// `$lib/schedule-utils` so the client modal can reuse it.
import { prisma } from '$lib/db';
import { validateBlock, type BlockInput } from '$lib/schedule-utils';
export {
MAX_BLOCKS,
MINUTES_PER_DAY,
SNAP_MINUTES,
MINUTE_STEP,
formatTime,
toHm,
snapMinutes,
findOverlap,
validateBlock
} from '$lib/schedule-utils';
export type { BlockInput, BlockSibling, ValidationResult } from '$lib/schedule-utils';
export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string };
function uniqueIds(ids: string[]): string[] {
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))];
}
/** Ensure the user has a Schedule row, creating the singleton on first use. */
export async function getOrCreateSchedule(userId: string) {
const existing = await prisma.schedule.findUnique({ where: { userId } });
if (existing) return existing;
return prisma.schedule.create({ data: { userId } });
}
/**
* The user's Blocks with their selected Proclamations (and frequencies for
* canonical-id mapping), ordered by start time.
*/
export async function listBlocks(userId: string) {
return prisma.block.findMany({
where: { schedule: { userId } },
orderBy: { startMinutes: 'asc' },
include: {
proclamations: {
orderBy: { sortOrder: 'asc' },
include: {
proclamation: { include: { solfeggioFrequency: true } }
}
}
}
});
}
export async function createBlock(userId: string, input: BlockInput): Promise<BlockMutationResult> {
const schedule = await getOrCreateSchedule(userId);
const siblings = await prisma.block.findMany({
where: { scheduleId: schedule.id },
select: { id: true, startMinutes: true, endMinutes: true }
});
const validation = validateBlock(input, siblings, { isCreate: true });
if (!validation.ok) return validation;
const proclamationIds = uniqueIds(input.proclamationIds);
const block = await prisma.block.create({
data: {
scheduleId: schedule.id,
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
proclamations: {
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
return { ok: true, id: block.id };
}
export async function updateBlock(
userId: string,
blockId: string,
input: BlockInput
): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { userId } },
select: { id: true, scheduleId: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
const siblings = await prisma.block.findMany({
where: { scheduleId: block.scheduleId, id: { not: blockId } },
select: { id: true, startMinutes: true, endMinutes: true }
});
const validation = validateBlock(input, siblings);
if (!validation.ok) return validation;
const proclamationIds = uniqueIds(input.proclamationIds);
await prisma.block.update({
where: { id: blockId },
data: {
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
proclamations: {
deleteMany: {},
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
return { ok: true, id: blockId };
}
export async function deleteBlock(userId: string, blockId: string): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { userId } },
select: { id: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
await prisma.block.delete({ where: { id: blockId } });
return { ok: true, id: blockId };
}