diff --git a/src/__tests__/plan.test.ts b/src/__tests__/plan.test.ts new file mode 100644 index 0000000..da150e0 --- /dev/null +++ b/src/__tests__/plan.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import prisma from "../config/database"; +import { resolveCell } from "../services/plan.service"; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +/** Note-prefix for test-created rows so we can clean them up safely. */ +const N = "wh_plan_"; + +let adminUserId: number; + +beforeAll(async () => { + // Pick the first admin user from the test database as our fixture. + // The users -> roles relation is named `roles` in the Prisma schema + // (users.role_id references roles.id). + const admin = await prisma.users.findFirst({ + where: { roles: { name: "admin" } }, + }); + if (!admin) throw new Error("Test setup: admin user not found"); + adminUserId = admin.id; +}); + +beforeEach(async () => { + // Clean up any leftover test data from previous runs. We only touch rows + // whose `note` contains our test prefix, so we never disturb real data. + await prisma.work_plan_entries.deleteMany({ + where: { note: { contains: N } }, + }); + await prisma.work_plan_overrides.deleteMany({ + where: { note: { contains: N } }, + }); +}); + +afterAll(async () => { + // Final cleanup so we never leave soft-deleted test rows behind. + await prisma.work_plan_entries.deleteMany({ + where: { note: { contains: N } }, + }); + await prisma.work_plan_overrides.deleteMany({ + where: { note: { contains: N } }, + }); +}); + +// --------------------------------------------------------------------------- +// resolveCell +// --------------------------------------------------------------------------- + +describe("plan.service.resolveCell", () => { + it("returns null when nothing covers the date", async () => { + // No entry, no override for (adminUserId, "2099-01-01") + const result = await resolveCell(adminUserId, "2099-01-01"); + expect(result).toBeNull(); + }); + + it("returns the entry that covers the date", async () => { + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-01"), + date_to: new Date("2099-06-10"), + category: "work", + note: `${N}PLC upgrade`, + created_by: adminUserId, + }, + }); + const result = await resolveCell(adminUserId, "2099-06-05"); + expect(result).not.toBeNull(); + expect(result?.source).toBe("entry"); + expect(result?.note).toBe(`${N}PLC upgrade`); + expect(result?.category).toBe("work"); + expect(result?.rangeFrom).toBe("2099-06-01"); + expect(result?.rangeTo).toBe("2099-06-10"); + }); + + it("returns the override for that day, not the entry", async () => { + // An entry covers 2099-06-01..2099-06-10, but an override on 2099-06-05 + // takes precedence. + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-01"), + date_to: new Date("2099-06-10"), + category: "work", + note: `${N}PLC upgrade`, + created_by: adminUserId, + }, + }); + await prisma.work_plan_overrides.create({ + data: { + user_id: adminUserId, + shift_date: new Date("2099-06-05"), + category: "leave", + note: `${N}Volno po noční`, + created_by: adminUserId, + }, + }); + const result = await resolveCell(adminUserId, "2099-06-05"); + expect(result).not.toBeNull(); + expect(result?.source).toBe("override"); + expect(result?.note).toBe(`${N}Volno po noční`); + expect(result?.category).toBe("leave"); + }); + + it("ignores soft-deleted entries and overrides", async () => { + await prisma.work_plan_entries.create({ + data: { + user_id: adminUserId, + date_from: new Date("2099-06-01"), + date_to: new Date("2099-06-10"), + category: "work", + note: `${N}deleted entry`, + is_deleted: true, + created_by: adminUserId, + }, + }); + const result = await resolveCell(adminUserId, "2099-06-05"); + expect(result).toBeNull(); + }); +}); diff --git a/src/services/plan.service.ts b/src/services/plan.service.ts new file mode 100644 index 0000000..5d5f707 --- /dev/null +++ b/src/services/plan.service.ts @@ -0,0 +1,98 @@ +import prisma from "../config/database"; + +/** + * Resolved plan cell for a single (user, date). Either from a range entry + * (work_plan_entries) or from a single-day override (work_plan_overrides). + */ +export interface ResolvedCell { + /** Where the cell came from. */ + source: "entry" | "override"; + /** ID of the work_plan_entries row, or null when source is "override". */ + entryId: number | null; + /** ID of the work_plan_overrides row, or null when source is "entry". */ + overrideId: number | null; + user_id: number; + /** The date the cell resolves to (echoes the input, YYYY-MM-DD). */ + shift_date: string; + project_id: number | null; + category: string; + note: string; + /** Original entry range, or null when source is "override". */ + rangeFrom: string | null; + rangeTo: string | null; +} + +/** + * Compute the effective plan cell for (userId, dateStr). + * + * Precedence: override > entry > null. + * Soft-deleted rows are ignored (is_deleted = false filter). + * If multiple entries cover the same day, the latest by created_at wins + * and a warning is logged. + * + * dateStr must be a YYYY-MM-DD string. The function builds a JS Date + * from it and Prisma handles timezone conversion against the MySQL + * @db.Date columns. + */ +export async function resolveCell( + userId: number, + dateStr: string, +): Promise { + const date = new Date(dateStr); + + // 1. Single-day override for this exact date + const override = await prisma.work_plan_overrides.findFirst({ + where: { user_id: userId, shift_date: date, is_deleted: false }, + }); + if (override) { + return { + source: "override", + entryId: null, + overrideId: override.id, + user_id: override.user_id, + shift_date: dateStr, + project_id: override.project_id, + category: override.category, + note: override.note, + rangeFrom: null, + rangeTo: null, + }; + } + + // 2. Range entry that covers the day + const entries = await prisma.work_plan_entries.findMany({ + where: { + user_id: userId, + date_from: { lte: date }, + date_to: { gte: date }, + is_deleted: false, + }, + orderBy: { created_at: "desc" }, + }); + + if (entries.length === 0) return null; + + if (entries.length > 1) { + // Multiple entries overlap. Use the latest. A persistent audit-log + // entry would be ideal here, but logAudit() currently requires a + // FastifyRequest — services have no request. Use console.warn as a + // stand-in until a service-context audit logger is introduced. + console.warn( + `[plan.service] multiple entries cover user ${userId} on ${dateStr}; using the latest (entry id ${entries[0].id})`, + ); + } + + const entry = entries[0]; + return { + source: "entry", + entryId: entry.id, + overrideId: null, + user_id: entry.user_id, + shift_date: dateStr, + project_id: entry.project_id, + category: entry.category, + note: entry.note, + rangeFrom: entry.date_from.toISOString().slice(0, 10), + rangeTo: entry.date_to.toISOString().slice(0, 10), + }; +}