feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
+14
-11
@@ -8,17 +8,20 @@
|
||||
"./client": "./src/client.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"db:push": "prisma db push --schema ./prisma/schema.prisma",
|
||||
"db:migrate": "prisma migrate dev --schema ./prisma/schema.prisma",
|
||||
"db:migrate:deploy": "prisma migrate deploy --schema ./prisma/schema.prisma",
|
||||
"db:seed": "tsx src/seed.ts",
|
||||
"db:seed:dispo-v2": "tsx src/seed-dispo-v2.ts",
|
||||
"db:seed:vacations": "dotenv -e ../../.env -- tsx src/seed-vacations.ts",
|
||||
"db:reset:dispo": "tsx src/reset-dispo-import.ts",
|
||||
"db:import:dispo": "tsx src/import-dispo-batch.ts",
|
||||
"db:excel": "tsx src/generate-excel.ts",
|
||||
"db:studio": "prisma studio --schema ./prisma/schema.prisma",
|
||||
"db:generate": "prisma generate --schema ./prisma/schema.prisma",
|
||||
"db:doctor": "node ../../scripts/db-doctor.mjs capakraken",
|
||||
"db:push": "node ../../scripts/with-env.mjs prisma db push --schema ./prisma/schema.prisma",
|
||||
"db:migrate": "node ../../scripts/with-env.mjs prisma migrate dev --schema ./prisma/schema.prisma",
|
||||
"db:migrate:deploy": "node ../../scripts/with-env.mjs prisma migrate deploy --schema ./prisma/schema.prisma",
|
||||
"db:seed": "node ../../scripts/with-env.mjs tsx src/seed.ts",
|
||||
"db:seed:holiday-demo-resources": "node ../../scripts/with-env.mjs tsx src/seed-holiday-demo-resources.ts",
|
||||
"db:seed:holidays": "node ../../scripts/with-env.mjs tsx src/seed-holiday-calendars.ts",
|
||||
"db:seed:dispo-v2": "node ../../scripts/with-env.mjs tsx src/seed-dispo-v2.ts",
|
||||
"db:seed:vacations": "node ../../scripts/with-env.mjs tsx src/seed-vacations.ts",
|
||||
"db:reset:dispo": "node ../../scripts/with-env.mjs tsx src/reset-dispo-import.ts",
|
||||
"db:import:dispo": "node ../../scripts/with-env.mjs tsx src/import-dispo-batch.ts",
|
||||
"db:excel": "node ../../scripts/with-env.mjs tsx src/generate-excel.ts",
|
||||
"db:studio": "node ../../scripts/with-env.mjs prisma studio --schema ./prisma/schema.prisma",
|
||||
"db:generate": "node ../../scripts/with-env.mjs prisma generate --schema ./prisma/schema.prisma",
|
||||
"test:unit": "tsx --test src/*.test.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -106,6 +106,12 @@ enum VacationStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum HolidayCalendarScope {
|
||||
COUNTRY
|
||||
STATE
|
||||
CITY
|
||||
}
|
||||
|
||||
enum ImportBatchStatus {
|
||||
DRAFT
|
||||
STAGING
|
||||
@@ -194,6 +200,7 @@ model User {
|
||||
broadcasts NotificationBroadcast[] @relation("broadcastSender")
|
||||
comments Comment[]
|
||||
activeSessions ActiveSession[]
|
||||
reportTemplates ReportTemplate[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -201,6 +208,32 @@ model User {
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
enum ReportTemplateEntity {
|
||||
RESOURCE
|
||||
PROJECT
|
||||
ASSIGNMENT
|
||||
RESOURCE_MONTH
|
||||
}
|
||||
|
||||
model ReportTemplate {
|
||||
id String @id @default(cuid())
|
||||
ownerId String
|
||||
name String
|
||||
description String?
|
||||
entity ReportTemplateEntity
|
||||
config Json @db.JsonB
|
||||
isShared Boolean @default(false)
|
||||
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([ownerId, updatedAt])
|
||||
@@unique([ownerId, name])
|
||||
@@map("report_templates")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
@@ -529,6 +562,7 @@ model Country {
|
||||
isActive Boolean @default(true)
|
||||
|
||||
metroCities MetroCity[]
|
||||
holidayCalendars HolidayCalendar[]
|
||||
resources Resource[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -543,7 +577,8 @@ model MetroCity {
|
||||
countryId String
|
||||
country Country @relation(fields: [countryId], references: [id])
|
||||
|
||||
resources Resource[]
|
||||
resources Resource[]
|
||||
holidayCalendars HolidayCalendar[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -552,6 +587,46 @@ model MetroCity {
|
||||
@@map("metro_cities")
|
||||
}
|
||||
|
||||
model HolidayCalendar {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
scopeType HolidayCalendarScope
|
||||
countryId String
|
||||
stateCode String?
|
||||
metroCityId String?
|
||||
isActive Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
country Country @relation(fields: [countryId], references: [id], onDelete: Cascade)
|
||||
metroCity MetroCity? @relation(fields: [metroCityId], references: [id], onDelete: Cascade)
|
||||
entries HolidayCalendarEntry[]
|
||||
|
||||
@@index([countryId, scopeType])
|
||||
@@index([countryId, stateCode])
|
||||
@@index([metroCityId])
|
||||
// Scope uniqueness is enforced via partial unique indexes in SQL migrations.
|
||||
@@map("holiday_calendars")
|
||||
}
|
||||
|
||||
model HolidayCalendarEntry {
|
||||
id String @id @default(cuid())
|
||||
holidayCalendarId String
|
||||
date DateTime @db.Date
|
||||
name String
|
||||
isRecurringAnnual Boolean @default(false)
|
||||
source String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
holidayCalendar HolidayCalendar @relation(fields: [holidayCalendarId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([holidayCalendarId, date])
|
||||
@@index([date])
|
||||
@@map("holiday_calendar_entries")
|
||||
}
|
||||
|
||||
// ─── Org Unit Hierarchy ─────────────────────────────────────────────────────
|
||||
|
||||
model OrgUnit {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { assertDestructiveDbAllowed } from "./destructive-db-guard.js";
|
||||
import { assertSafeSeedTarget } from "./safe-destructive-env.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
|
||||
function setEnv(values: Record<string, string | undefined>) {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
});
|
||||
|
||||
test("assertDestructiveDbAllowed allows an explicitly confirmed disposable capakraken test database", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_test",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken_test",
|
||||
});
|
||||
|
||||
const target = assertDestructiveDbAllowed({
|
||||
commandName: "db:test",
|
||||
allowedDatabaseNames: ["capakraken_test"],
|
||||
});
|
||||
|
||||
assert.equal(target.databaseName, "capakraken_test");
|
||||
assert.equal(target.hostname, "localhost");
|
||||
});
|
||||
|
||||
test("assertDestructiveDbAllowed rejects protected live database names even if allowlisted", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => assertDestructiveDbAllowed({
|
||||
commandName: "db:test",
|
||||
allowedDatabaseNames: ["capakraken"],
|
||||
}),
|
||||
/explicitly protected/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("assertDestructiveDbAllowed rejects missing confirmation", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_e2e",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "wrong_db",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => assertDestructiveDbAllowed({
|
||||
commandName: "db:test",
|
||||
allowedDatabaseNames: ["capakraken_e2e"],
|
||||
}),
|
||||
/CONFIRM_DESTRUCTIVE_DB_NAME=capakraken_e2e/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("assertDestructiveDbAllowed rejects missing destructive allow flag", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/capakraken_ci",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: undefined,
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "capakraken_ci",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => assertDestructiveDbAllowed({
|
||||
commandName: "db:test",
|
||||
allowedDatabaseNames: ["capakraken_ci"],
|
||||
}),
|
||||
/ALLOW_DESTRUCTIVE_DB_TOOLS=true/u,
|
||||
);
|
||||
});
|
||||
|
||||
test("assertSafeSeedTarget rejects legacy planarchy disposable databases", () => {
|
||||
setEnv({
|
||||
DATABASE_URL: "postgresql://tester:secret@localhost:5432/planarchy_test",
|
||||
ALLOW_DESTRUCTIVE_DB_TOOLS: "true",
|
||||
CONFIRM_DESTRUCTIVE_DB_NAME: "planarchy_test",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => assertSafeSeedTarget("db:seed"),
|
||||
/not in the destructive-tool allowlist/u,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { URL } from "node:url";
|
||||
|
||||
interface DestructiveGuardOptions {
|
||||
commandName: string;
|
||||
allowedDatabaseNames?: string[];
|
||||
requireConfirmation?: boolean;
|
||||
}
|
||||
|
||||
const PROTECTED_DATABASE_NAMES = new Set(["capakraken", "planarchy"]);
|
||||
|
||||
function parseDatabaseUrl(rawUrl: string) {
|
||||
const parsed = new URL(rawUrl);
|
||||
const databaseName = parsed.pathname.replace(/^\/+/, "");
|
||||
|
||||
return {
|
||||
protocol: parsed.protocol,
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port,
|
||||
databaseName,
|
||||
username: decodeURIComponent(parsed.username),
|
||||
};
|
||||
}
|
||||
|
||||
function formatTarget(target: ReturnType<typeof parseDatabaseUrl>) {
|
||||
const port = target.port ? `:${target.port}` : "";
|
||||
return `${target.protocol}//${target.username}@${target.hostname}${port}/${target.databaseName}`;
|
||||
}
|
||||
|
||||
export function assertDestructiveDbAllowed({
|
||||
commandName,
|
||||
allowedDatabaseNames = [],
|
||||
requireConfirmation = true,
|
||||
}: DestructiveGuardOptions) {
|
||||
const rawUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!rawUrl) {
|
||||
throw new Error(`${commandName} aborted: DATABASE_URL is not configured.`);
|
||||
}
|
||||
|
||||
const target = parseDatabaseUrl(rawUrl);
|
||||
const allowFlag = process.env.ALLOW_DESTRUCTIVE_DB_TOOLS === "true";
|
||||
const confirmationDb = process.env.CONFIRM_DESTRUCTIVE_DB_NAME;
|
||||
const allowlisted = allowedDatabaseNames.includes(target.databaseName);
|
||||
|
||||
if (PROTECTED_DATABASE_NAMES.has(target.databaseName)) {
|
||||
throw new Error(
|
||||
`${commandName} aborted: database '${target.databaseName}' is explicitly protected from destructive tooling. Target=${formatTarget(target)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowlisted) {
|
||||
throw new Error(
|
||||
`${commandName} aborted: database '${target.databaseName}' is not in the destructive-tool allowlist (${allowedDatabaseNames.join(", ") || "none"}). Target=${formatTarget(target)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowFlag) {
|
||||
throw new Error(
|
||||
`${commandName} aborted: set ALLOW_DESTRUCTIVE_DB_TOOLS=true to allow destructive database operations. Target=${formatTarget(target)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (requireConfirmation && confirmationDb !== target.databaseName) {
|
||||
throw new Error(
|
||||
`${commandName} aborted: set CONFIRM_DESTRUCTIVE_DB_NAME=${target.databaseName} to confirm the destructive target. Current value=${confirmationDb ?? "<unset>"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
@@ -215,12 +215,63 @@ async function buildProjectsSheet(wb: ExcelJS.Workbook) {
|
||||
}
|
||||
|
||||
async function buildAllocationsSheet(wb: ExcelJS.Workbook) {
|
||||
const allocations = await prisma.allocation.findMany({
|
||||
orderBy: [{ project: { startDate: "asc" } }, { resource: { displayName: "asc" } }],
|
||||
include: {
|
||||
resource: { select: { eid: true, displayName: true, chapter: true } },
|
||||
project: { select: { shortCode: true, name: true } },
|
||||
},
|
||||
const [assignments, demandRequirements] = await Promise.all([
|
||||
prisma.assignment.findMany({
|
||||
orderBy: [{ project: { startDate: "asc" } }, { resource: { displayName: "asc" } }],
|
||||
include: {
|
||||
resource: { select: { eid: true, displayName: true, chapter: true } },
|
||||
project: { select: { shortCode: true, name: true, startDate: true } },
|
||||
},
|
||||
}),
|
||||
prisma.demandRequirement.findMany({
|
||||
orderBy: [{ project: { startDate: "asc" } }, { role: "asc" }],
|
||||
include: {
|
||||
project: { select: { shortCode: true, name: true, startDate: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const allocations = [
|
||||
...assignments.map((assignment) => ({
|
||||
projectStartDate: assignment.project.startDate,
|
||||
projectCode: assignment.project.shortCode,
|
||||
projectName: assignment.project.name,
|
||||
eid: assignment.resource?.eid ?? "",
|
||||
resourceName: assignment.resource?.displayName ?? "",
|
||||
chapter: assignment.resource?.chapter ?? "",
|
||||
role: assignment.role ?? "",
|
||||
startDate: assignment.startDate,
|
||||
endDate: assignment.endDate,
|
||||
hoursPerDay: assignment.hoursPerDay,
|
||||
dailyCostCents: assignment.dailyCostCents,
|
||||
status: assignment.status,
|
||||
})),
|
||||
...demandRequirements.map((demandRequirement) => ({
|
||||
projectStartDate: demandRequirement.project.startDate,
|
||||
projectCode: demandRequirement.project.shortCode,
|
||||
projectName: demandRequirement.project.name,
|
||||
eid: "",
|
||||
resourceName: "",
|
||||
chapter: "",
|
||||
role: demandRequirement.role ?? "",
|
||||
startDate: demandRequirement.startDate,
|
||||
endDate: demandRequirement.endDate,
|
||||
hoursPerDay: demandRequirement.hoursPerDay,
|
||||
dailyCostCents: 0,
|
||||
status: demandRequirement.status,
|
||||
})),
|
||||
].sort((left, right) => {
|
||||
const startDiff = left.projectStartDate.getTime() - right.projectStartDate.getTime();
|
||||
if (startDiff !== 0) {
|
||||
return startDiff;
|
||||
}
|
||||
|
||||
const resourceDiff = left.resourceName.localeCompare(right.resourceName);
|
||||
if (resourceDiff !== 0) {
|
||||
return resourceDiff;
|
||||
}
|
||||
|
||||
return left.role.localeCompare(right.role);
|
||||
});
|
||||
|
||||
const ws = wb.addWorksheet("Allocations", {
|
||||
@@ -245,12 +296,12 @@ async function buildAllocationsSheet(wb: ExcelJS.Workbook) {
|
||||
for (const alloc of allocations) {
|
||||
const row: AnyRow = ws.getRow(rowIdx);
|
||||
row.values = [
|
||||
alloc.project.shortCode,
|
||||
alloc.project.name,
|
||||
alloc.resource?.eid ?? "",
|
||||
alloc.resource?.displayName ?? "",
|
||||
alloc.resource?.chapter ?? "",
|
||||
alloc.role ?? "",
|
||||
alloc.projectCode,
|
||||
alloc.projectName,
|
||||
alloc.eid,
|
||||
alloc.resourceName,
|
||||
alloc.chapter,
|
||||
alloc.role,
|
||||
fmtDate(alloc.startDate),
|
||||
fmtDate(alloc.endDate),
|
||||
alloc.hoursPerDay,
|
||||
@@ -268,11 +319,13 @@ async function buildAllocationsSheet(wb: ExcelJS.Workbook) {
|
||||
}
|
||||
|
||||
async function buildSummarySheet(wb: ExcelJS.Workbook) {
|
||||
const [resourceCount, projectCount, allocationCount] = await Promise.all([
|
||||
const [resourceCount, projectCount, assignmentCount, demandRequirementCount] = await Promise.all([
|
||||
prisma.resource.count(),
|
||||
prisma.project.count(),
|
||||
prisma.allocation.count(),
|
||||
prisma.assignment.count(),
|
||||
prisma.demandRequirement.count(),
|
||||
]);
|
||||
const allocationCount = assignmentCount + demandRequirementCount;
|
||||
|
||||
const ws = wb.addWorksheet("Summary");
|
||||
ws.columns = [
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildHolidayCalendarSeedDefinitions } from "./holiday-calendar-seed-data.js";
|
||||
|
||||
function getDefinition(
|
||||
definitions: ReturnType<typeof buildHolidayCalendarSeedDefinitions>,
|
||||
predicate: (definition: ReturnType<typeof buildHolidayCalendarSeedDefinitions>[number]) => boolean,
|
||||
) {
|
||||
const definition = definitions.find(predicate);
|
||||
assert.ok(definition, "expected holiday seed definition to exist");
|
||||
return definition;
|
||||
}
|
||||
|
||||
test("builds country, state, and city holiday seeds for available profiles", () => {
|
||||
const definitions = buildHolidayCalendarSeedDefinitions({
|
||||
availableCountryCodes: ["DE", "ES", "IN", "US"],
|
||||
availableCitiesByCountry: {
|
||||
DE: ["Augsburg", "Berlin", "Hamburg", "Muenchen", "Stuttgart"],
|
||||
ES: ["Barcelona", "Madrid"],
|
||||
IN: ["Bangalore", "Mumbai"],
|
||||
US: ["Los Angeles", "New York"],
|
||||
},
|
||||
activeGermanStates: ["BW", "BY", "HH"],
|
||||
years: [2026, 2027],
|
||||
});
|
||||
|
||||
const byCalendar = getDefinition(
|
||||
definitions,
|
||||
(definition) => definition.scopeType === "STATE" && definition.countryCode === "DE" && definition.stateCode === "BY",
|
||||
);
|
||||
const nwCalendar = getDefinition(
|
||||
definitions,
|
||||
(definition) => definition.scopeType === "STATE" && definition.countryCode === "DE" && definition.stateCode === "NW",
|
||||
);
|
||||
const madridCalendar = getDefinition(
|
||||
definitions,
|
||||
(definition) => definition.scopeType === "CITY" && definition.countryCode === "ES" && definition.cityName === "Madrid",
|
||||
);
|
||||
const augsburgCalendar = getDefinition(
|
||||
definitions,
|
||||
(definition) => definition.scopeType === "CITY" && definition.countryCode === "DE" && definition.cityName === "Augsburg",
|
||||
);
|
||||
const usCalendar = getDefinition(
|
||||
definitions,
|
||||
(definition) => definition.scopeType === "COUNTRY" && definition.countryCode === "US",
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
byCalendar.entries.some((entry) => entry.date === "2026-01-06" && entry.name === "Heilige Drei Könige"),
|
||||
);
|
||||
assert.ok(
|
||||
byCalendar.entries.some((entry) => entry.date === "2027-08-15" && entry.name === "Mariä Himmelfahrt"),
|
||||
);
|
||||
assert.ok(
|
||||
nwCalendar.entries.some((entry) => entry.date === "2026-06-04" && entry.name === "Fronleichnam"),
|
||||
);
|
||||
assert.ok(
|
||||
!nwCalendar.entries.some((entry) => entry.date === "2026-01-06"),
|
||||
);
|
||||
assert.ok(
|
||||
madridCalendar.entries.some((entry) => entry.date === "2026-05-15" && entry.name === "San Isidro"),
|
||||
);
|
||||
assert.ok(
|
||||
augsburgCalendar.entries.some((entry) => entry.date === "2027-08-08" && entry.name === "Augsburger Friedensfest"),
|
||||
);
|
||||
assert.ok(
|
||||
usCalendar.entries.some((entry) => entry.date === "2026-07-03" && entry.name === "Independence Day"),
|
||||
);
|
||||
assert.ok(
|
||||
usCalendar.entries.some((entry) => entry.date === "2027-12-24" && entry.name === "Christmas Day"),
|
||||
);
|
||||
});
|
||||
|
||||
test("only includes city calendars for cities that exist in the database", () => {
|
||||
const definitions = buildHolidayCalendarSeedDefinitions({
|
||||
availableCountryCodes: ["DE", "ES"],
|
||||
availableCitiesByCountry: {
|
||||
DE: ["Berlin"],
|
||||
ES: ["Madrid"],
|
||||
},
|
||||
activeGermanStates: ["BY"],
|
||||
years: [2026, 2027],
|
||||
});
|
||||
|
||||
assert.ok(definitions.some((definition) => definition.countryCode === "DE" && definition.scopeType === "STATE"));
|
||||
assert.ok(definitions.some((definition) => definition.countryCode === "ES" && definition.scopeType === "CITY" && definition.cityName === "Madrid"));
|
||||
assert.ok(!definitions.some((definition) => definition.countryCode === "ES" && definition.scopeType === "CITY" && definition.cityName === "Barcelona"));
|
||||
assert.ok(!definitions.some((definition) => definition.countryCode === "US"));
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { getPublicHolidays } from "@capakraken/shared";
|
||||
|
||||
export type HolidayCalendarSeedScope = "COUNTRY" | "STATE" | "CITY";
|
||||
|
||||
export type HolidayCalendarSeedEntry = {
|
||||
date: string;
|
||||
name: string;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
export type HolidayCalendarSeedDefinition = {
|
||||
name: string;
|
||||
scopeType: HolidayCalendarSeedScope;
|
||||
countryCode: string;
|
||||
stateCode?: string;
|
||||
cityName?: string;
|
||||
priority: number;
|
||||
entries: HolidayCalendarSeedEntry[];
|
||||
};
|
||||
|
||||
type SeedContext = {
|
||||
availableCountryCodes: string[];
|
||||
availableCitiesByCountry: Record<string, string[]>;
|
||||
activeGermanStates?: string[];
|
||||
years: number[];
|
||||
};
|
||||
|
||||
type SimpleHolidayDefinition = {
|
||||
name: string;
|
||||
resolveDate: (year: number) => string;
|
||||
};
|
||||
|
||||
const COUNTRY_PRIORITY = 10;
|
||||
const STATE_PRIORITY = 20;
|
||||
const CITY_PRIORITY = 30;
|
||||
|
||||
function toIsoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dateUtc(year: number, month: number, day: number): Date {
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
function addDays(date: Date, amount: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setUTCDate(next.getUTCDate() + amount);
|
||||
return next;
|
||||
}
|
||||
|
||||
function computeEasterSunday(year: number): Date {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return dateUtc(year, month, day);
|
||||
}
|
||||
|
||||
function nthWeekdayOfMonth(year: number, month: number, weekday: number, occurrence: number): string {
|
||||
const firstDay = dateUtc(year, month, 1);
|
||||
const delta = (weekday - firstDay.getUTCDay() + 7) % 7;
|
||||
const day = 1 + delta + ((occurrence - 1) * 7);
|
||||
return toIsoDate(dateUtc(year, month, day));
|
||||
}
|
||||
|
||||
function lastWeekdayOfMonth(year: number, month: number, weekday: number): string {
|
||||
const lastDay = dateUtc(year, month + 1, 0);
|
||||
const delta = (lastDay.getUTCDay() - weekday + 7) % 7;
|
||||
lastDay.setUTCDate(lastDay.getUTCDate() - delta);
|
||||
return toIsoDate(lastDay);
|
||||
}
|
||||
|
||||
function observedUsFixedHoliday(year: number, month: number, day: number): string {
|
||||
const holiday = dateUtc(year, month, day);
|
||||
const weekday = holiday.getUTCDay();
|
||||
|
||||
if (weekday === 6) {
|
||||
return toIsoDate(addDays(holiday, -1));
|
||||
}
|
||||
if (weekday === 0) {
|
||||
return toIsoDate(addDays(holiday, 1));
|
||||
}
|
||||
|
||||
return toIsoDate(holiday);
|
||||
}
|
||||
|
||||
function buildEntries(years: number[], definitions: SimpleHolidayDefinition[]): HolidayCalendarSeedEntry[] {
|
||||
const entries = new Map<string, HolidayCalendarSeedEntry>();
|
||||
|
||||
for (const year of years) {
|
||||
for (const definition of definitions) {
|
||||
const date = definition.resolveDate(year);
|
||||
entries.set(date, {
|
||||
date,
|
||||
name: definition.name,
|
||||
isRecurringAnnual: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...entries.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
function buildGermanCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(
|
||||
years,
|
||||
years.flatMap((year) =>
|
||||
getPublicHolidays(year)
|
||||
.filter((holiday) => holiday.federal)
|
||||
.map((holiday) => ({
|
||||
name: holiday.name,
|
||||
resolveDate: () => holiday.date,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildGermanStateEntries(years: number[], stateCode: string): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(
|
||||
years,
|
||||
years.flatMap((year) =>
|
||||
getPublicHolidays(year, stateCode)
|
||||
.filter((holiday) => !holiday.federal)
|
||||
.map((holiday) => ({
|
||||
name: holiday.name,
|
||||
resolveDate: () => holiday.date,
|
||||
})),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildSpanishCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, [
|
||||
{ name: "Ano Nuevo", resolveDate: (year) => toIsoDate(dateUtc(year, 1, 1)) },
|
||||
{ name: "Epifania del Senor", resolveDate: (year) => toIsoDate(dateUtc(year, 1, 6)) },
|
||||
{ name: "Viernes Santo", resolveDate: (year) => toIsoDate(addDays(computeEasterSunday(year), -2)) },
|
||||
{ name: "Fiesta del Trabajo", resolveDate: (year) => toIsoDate(dateUtc(year, 5, 1)) },
|
||||
{ name: "Asuncion de la Virgen", resolveDate: (year) => toIsoDate(dateUtc(year, 8, 15)) },
|
||||
{ name: "Fiesta Nacional de Espana", resolveDate: (year) => toIsoDate(dateUtc(year, 10, 12)) },
|
||||
{ name: "Todos los Santos", resolveDate: (year) => toIsoDate(dateUtc(year, 11, 1)) },
|
||||
{ name: "Dia de la Constitucion", resolveDate: (year) => toIsoDate(dateUtc(year, 12, 6)) },
|
||||
{ name: "Inmaculada Concepcion", resolveDate: (year) => toIsoDate(dateUtc(year, 12, 8)) },
|
||||
{ name: "Navidad", resolveDate: (year) => toIsoDate(dateUtc(year, 12, 25)) },
|
||||
]);
|
||||
}
|
||||
|
||||
function buildIndianCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, [
|
||||
{ name: "Republic Day", resolveDate: (year) => toIsoDate(dateUtc(year, 1, 26)) },
|
||||
{ name: "Good Friday", resolveDate: (year) => toIsoDate(addDays(computeEasterSunday(year), -2)) },
|
||||
{ name: "Independence Day", resolveDate: (year) => toIsoDate(dateUtc(year, 8, 15)) },
|
||||
{ name: "Gandhi Jayanti", resolveDate: (year) => toIsoDate(dateUtc(year, 10, 2)) },
|
||||
]);
|
||||
}
|
||||
|
||||
function buildUsCountryEntries(years: number[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, [
|
||||
{ name: "New Year's Day", resolveDate: (year) => observedUsFixedHoliday(year, 1, 1) },
|
||||
{ name: "Martin Luther King Jr. Day", resolveDate: (year) => nthWeekdayOfMonth(year, 1, 1, 3) },
|
||||
{ name: "Memorial Day", resolveDate: (year) => lastWeekdayOfMonth(year, 5, 1) },
|
||||
{ name: "Independence Day", resolveDate: (year) => observedUsFixedHoliday(year, 7, 4) },
|
||||
{ name: "Labor Day", resolveDate: (year) => nthWeekdayOfMonth(year, 9, 1, 1) },
|
||||
{ name: "Thanksgiving Day", resolveDate: (year) => nthWeekdayOfMonth(year, 11, 4, 4) },
|
||||
{ name: "Christmas Day", resolveDate: (year) => observedUsFixedHoliday(year, 12, 25) },
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeCountryCodes(countryCodes: string[]): Set<string> {
|
||||
return new Set(countryCodes.map((countryCode) => countryCode.trim().toUpperCase()));
|
||||
}
|
||||
|
||||
function normalizeCityLookup(availableCitiesByCountry: Record<string, string[]>): Map<string, Set<string>> {
|
||||
const lookup = new Map<string, Set<string>>();
|
||||
|
||||
for (const [countryCode, cityNames] of Object.entries(availableCitiesByCountry)) {
|
||||
lookup.set(countryCode.trim().toUpperCase(), new Set(cityNames));
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
function hasCity(cityLookup: Map<string, Set<string>>, countryCode: string, cityName: string): boolean {
|
||||
return cityLookup.get(countryCode)?.has(cityName) ?? false;
|
||||
}
|
||||
|
||||
function germanStateDisplayName(stateCode: string): string {
|
||||
switch (stateCode) {
|
||||
case "BW":
|
||||
return "Baden-Wuerttemberg";
|
||||
case "BY":
|
||||
return "Bayern";
|
||||
case "HH":
|
||||
return "Hamburg";
|
||||
case "NW":
|
||||
return "Nordrhein-Westfalen";
|
||||
default:
|
||||
return stateCode;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCityEntries(years: number[], definitions: SimpleHolidayDefinition[]): HolidayCalendarSeedEntry[] {
|
||||
return buildEntries(years, definitions);
|
||||
}
|
||||
|
||||
export function buildHolidayCalendarSeedDefinitions(
|
||||
context: SeedContext,
|
||||
): HolidayCalendarSeedDefinition[] {
|
||||
const availableCountries = normalizeCountryCodes(context.availableCountryCodes);
|
||||
const cityLookup = normalizeCityLookup(context.availableCitiesByCountry);
|
||||
const definitions: HolidayCalendarSeedDefinition[] = [];
|
||||
|
||||
if (availableCountries.has("DE")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Deutschland 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "DE",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildGermanCountryEntries(context.years),
|
||||
});
|
||||
|
||||
const germanStates = new Set(
|
||||
[...(context.activeGermanStates ?? []), "BY", "NW"]
|
||||
.map((stateCode) => stateCode.trim().toUpperCase())
|
||||
.filter((stateCode) => ["BW", "BY", "HH", "NW"].includes(stateCode)),
|
||||
);
|
||||
|
||||
for (const stateCode of [...germanStates].sort()) {
|
||||
const entries = buildGermanStateEntries(context.years, stateCode);
|
||||
if (entries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
definitions.push({
|
||||
name: `Referenzfeiertage Deutschland - ${germanStateDisplayName(stateCode)} 2026-2027`,
|
||||
scopeType: "STATE",
|
||||
countryCode: "DE",
|
||||
stateCode,
|
||||
priority: STATE_PRIORITY,
|
||||
entries,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasCity(cityLookup, "DE", "Augsburg")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Deutschland - Augsburg 2026-2027",
|
||||
scopeType: "CITY",
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
priority: CITY_PRIORITY,
|
||||
entries: buildCityEntries(context.years, [
|
||||
{ name: "Augsburger Friedensfest", resolveDate: (year) => toIsoDate(dateUtc(year, 8, 8)) },
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableCountries.has("ES")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Spanien 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "ES",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildSpanishCountryEntries(context.years),
|
||||
});
|
||||
|
||||
if (hasCity(cityLookup, "ES", "Madrid")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Spanien - Madrid 2026-2027",
|
||||
scopeType: "CITY",
|
||||
countryCode: "ES",
|
||||
cityName: "Madrid",
|
||||
priority: CITY_PRIORITY,
|
||||
entries: buildEntries(context.years, [
|
||||
{ name: "San Isidro", resolveDate: (year) => toIsoDate(dateUtc(year, 5, 15)) },
|
||||
{ name: "Nuestra Senora de la Almudena", resolveDate: (year) => toIsoDate(dateUtc(year, 11, 9)) },
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasCity(cityLookup, "ES", "Barcelona")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Spanien - Barcelona 2026-2027",
|
||||
scopeType: "CITY",
|
||||
countryCode: "ES",
|
||||
cityName: "Barcelona",
|
||||
priority: CITY_PRIORITY,
|
||||
entries: buildEntries(context.years, [
|
||||
{ name: "La Merce", resolveDate: (year) => toIsoDate(dateUtc(year, 9, 24)) },
|
||||
{ name: "Santa Eulalia", resolveDate: (year) => toIsoDate(dateUtc(year, 2, 12)) },
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (availableCountries.has("IN")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage Indien 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "IN",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildIndianCountryEntries(context.years),
|
||||
});
|
||||
}
|
||||
|
||||
if (availableCountries.has("US")) {
|
||||
definitions.push({
|
||||
name: "Referenzfeiertage USA 2026-2027",
|
||||
scopeType: "COUNTRY",
|
||||
countryCode: "US",
|
||||
priority: COUNTRY_PRIORITY,
|
||||
entries: buildUsCountryEntries(context.years),
|
||||
});
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { getHolidayDemoCityNamesByCountry, getHolidayDemoProfileForIndex } from "./holiday-demo-profiles.js";
|
||||
|
||||
test("getHolidayDemoProfileForIndex rotates across the demo holiday profiles", () => {
|
||||
assert.deepEqual(getHolidayDemoProfileForIndex(0), {
|
||||
countryCode: "DE",
|
||||
stateCode: "BW",
|
||||
cityName: "Stuttgart",
|
||||
});
|
||||
assert.deepEqual(getHolidayDemoProfileForIndex(5), {
|
||||
countryCode: "DE",
|
||||
stateCode: "BY",
|
||||
cityName: "Augsburg",
|
||||
});
|
||||
assert.deepEqual(getHolidayDemoProfileForIndex(12), {
|
||||
countryCode: "DE",
|
||||
stateCode: "BW",
|
||||
cityName: "Stuttgart",
|
||||
});
|
||||
});
|
||||
|
||||
test("getHolidayDemoCityNamesByCountry exposes the required demo cities", () => {
|
||||
assert.deepEqual(getHolidayDemoCityNamesByCountry(), {
|
||||
DE: ["Augsburg", "Berlin", "Hamburg", "Koeln", "Muenchen", "Stuttgart"],
|
||||
ES: ["Barcelona", "Madrid"],
|
||||
IN: ["Bangalore", "Mumbai"],
|
||||
US: ["Los Angeles", "New York"],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export type HolidayDemoProfile = {
|
||||
countryCode: "DE" | "ES" | "IN" | "US";
|
||||
stateCode: string | null;
|
||||
cityName: string;
|
||||
};
|
||||
|
||||
export const HOLIDAY_DEMO_PROFILES: HolidayDemoProfile[] = [
|
||||
{ countryCode: "DE", stateCode: "BW", cityName: "Stuttgart" },
|
||||
{ countryCode: "DE", stateCode: "BY", cityName: "Muenchen" },
|
||||
{ countryCode: "DE", stateCode: "NW", cityName: "Koeln" },
|
||||
{ countryCode: "DE", stateCode: "HH", cityName: "Hamburg" },
|
||||
{ countryCode: "DE", stateCode: "BE", cityName: "Berlin" },
|
||||
{ countryCode: "DE", stateCode: "BY", cityName: "Augsburg" },
|
||||
{ countryCode: "ES", stateCode: "MD", cityName: "Madrid" },
|
||||
{ countryCode: "ES", stateCode: "CT", cityName: "Barcelona" },
|
||||
{ countryCode: "IN", stateCode: "KA", cityName: "Bangalore" },
|
||||
{ countryCode: "IN", stateCode: "MH", cityName: "Mumbai" },
|
||||
{ countryCode: "US", stateCode: "CA", cityName: "Los Angeles" },
|
||||
{ countryCode: "US", stateCode: "NY", cityName: "New York" },
|
||||
];
|
||||
|
||||
export function getHolidayDemoProfileForIndex(index: number): HolidayDemoProfile {
|
||||
return HOLIDAY_DEMO_PROFILES[index % HOLIDAY_DEMO_PROFILES.length]!;
|
||||
}
|
||||
|
||||
export function getHolidayDemoCityNamesByCountry() {
|
||||
const grouped = new Map<string, Set<string>>();
|
||||
|
||||
for (const profile of HOLIDAY_DEMO_PROFILES) {
|
||||
const cityNames = grouped.get(profile.countryCode) ?? new Set<string>();
|
||||
cityNames.add(profile.cityName);
|
||||
grouped.set(profile.countryCode, cityNames);
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
[...grouped.entries()]
|
||||
.map(([countryCode, cityNames]) => [countryCode, [...cityNames].sort()] as const),
|
||||
) as Record<HolidayDemoProfile["countryCode"], string[]>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import { SystemRole } from "@capakraken/shared";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { assertDestructiveDbAllowed } from "./destructive-db-guard.js";
|
||||
import { loadWorkspaceEnv, resolveWorkspacePath } from "./load-workspace-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
@@ -143,6 +144,10 @@ async function bootstrapPlatform(adminEmail: string, adminPassword: string, admi
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const target = assertDestructiveDbAllowed({
|
||||
commandName: "db:reset:dispo",
|
||||
allowedDatabaseNames: ["capakraken_test", "capakraken_e2e", "capakraken_ci"],
|
||||
});
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!options.force) {
|
||||
@@ -153,6 +158,8 @@ async function main() {
|
||||
throw new Error("DATABASE_URL is not configured.");
|
||||
}
|
||||
|
||||
console.warn(`Resetting disposable database '${target.databaseName}'.`);
|
||||
|
||||
let backupPath: string | null = null;
|
||||
|
||||
if (options.skipBackup) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { assertDestructiveDbAllowed } from "./destructive-db-guard.js";
|
||||
|
||||
const TEST_DATABASE_NAMES = [
|
||||
"capakraken_test",
|
||||
"capakraken_e2e",
|
||||
"capakraken_ci",
|
||||
];
|
||||
|
||||
export function assertSafeSeedTarget(commandName: string) {
|
||||
return assertDestructiveDbAllowed({
|
||||
commandName,
|
||||
allowedDatabaseNames: TEST_DATABASE_NAMES,
|
||||
});
|
||||
}
|
||||
@@ -9,17 +9,22 @@ import {
|
||||
DISPO_UTILIZATION_CATEGORIES,
|
||||
} from "@capakraken/shared";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { loadWorkspaceEnv } from "./load-workspace-env.js";
|
||||
import { assertSafeSeedTarget } from "./safe-destructive-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
assertSafeSeedTarget("db:seed:dispo-v2");
|
||||
console.log("Seeding Dispo v2 reference data...");
|
||||
|
||||
// ─── Countries + Metro Cities ─────────────────────────────────────────────
|
||||
|
||||
const countries = [
|
||||
{ code: "CR", name: "Costa Rica", dailyWorkingHours: 8, cities: ["Costa Rica"] },
|
||||
{ code: "DE", name: "Germany", dailyWorkingHours: 8, cities: ["Bonn", "Frankfurt", "Hamburg", "Munich", "Stuttgart"] },
|
||||
{ code: "DE", name: "Germany", dailyWorkingHours: 8, cities: ["Augsburg", "Berlin", "Bonn", "Frankfurt", "Hamburg", "Koeln", "Muenchen", "Stuttgart"] },
|
||||
{ code: "HU", name: "Hungary", dailyWorkingHours: 8, cities: ["Hungary"] },
|
||||
{ code: "IN", name: "India", dailyWorkingHours: 9, cities: ["India"] },
|
||||
{ code: "IT", name: "Italy", dailyWorkingHours: 8, cities: ["Italy"] },
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { PrismaClient, type HolidayCalendarEntry } from "@prisma/client";
|
||||
import { buildHolidayCalendarSeedDefinitions } from "./holiday-calendar-seed-data.js";
|
||||
import { loadWorkspaceEnv } from "./load-workspace-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const YEARS = [2026, 2027];
|
||||
const SEED_SOURCE = "seed:holiday-calendars:2026-2027";
|
||||
|
||||
type ExistingCalendar = {
|
||||
id: string;
|
||||
entries: Pick<HolidayCalendarEntry, "id" | "date" | "source">[];
|
||||
};
|
||||
|
||||
function toUtcDate(isoDate: string): Date {
|
||||
return new Date(`${isoDate}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function dateKey(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function findScopedCalendar(input: {
|
||||
countryId: string;
|
||||
scopeType: "COUNTRY" | "STATE" | "CITY";
|
||||
stateCode?: string | undefined;
|
||||
metroCityId?: string | undefined;
|
||||
}): Promise<ExistingCalendar | null> {
|
||||
return prisma.holidayCalendar.findFirst({
|
||||
where: {
|
||||
countryId: input.countryId,
|
||||
scopeType: input.scopeType,
|
||||
stateCode: input.scopeType === "STATE" ? input.stateCode ?? null : null,
|
||||
metroCityId: input.scopeType === "CITY" ? input.metroCityId ?? null : null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
entries: {
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
source: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Seeding holiday calendars for 2026-2027...");
|
||||
|
||||
const countries = await prisma.country.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
metroCities: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { code: "asc" },
|
||||
});
|
||||
|
||||
const activeGermanStatesRows = await prisma.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
country: { code: "DE" },
|
||||
federalState: { not: null },
|
||||
},
|
||||
select: { federalState: true },
|
||||
distinct: ["federalState"],
|
||||
});
|
||||
|
||||
const definitions = buildHolidayCalendarSeedDefinitions({
|
||||
availableCountryCodes: countries.map((country) => country.code),
|
||||
availableCitiesByCountry: Object.fromEntries(
|
||||
countries.map((country) => [
|
||||
country.code,
|
||||
country.metroCities.map((city) => city.name),
|
||||
]),
|
||||
),
|
||||
activeGermanStates: activeGermanStatesRows
|
||||
.map((row) => row.federalState)
|
||||
.filter((stateCode): stateCode is string => Boolean(stateCode)),
|
||||
years: YEARS,
|
||||
});
|
||||
|
||||
const countryByCode = new Map(countries.map((country) => [country.code, country]));
|
||||
const cityByCountryAndName = new Map(
|
||||
countries.flatMap((country) =>
|
||||
country.metroCities.map((city) => [`${country.code}:${city.name}`, city] as const),
|
||||
),
|
||||
);
|
||||
|
||||
let createdCalendars = 0;
|
||||
let reusedCalendars = 0;
|
||||
let createdEntries = 0;
|
||||
let updatedEntries = 0;
|
||||
let skippedManualEntries = 0;
|
||||
|
||||
for (const definition of definitions) {
|
||||
const country = countryByCode.get(definition.countryCode);
|
||||
if (!country) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metroCity = definition.cityName
|
||||
? cityByCountryAndName.get(`${definition.countryCode}:${definition.cityName}`)
|
||||
: null;
|
||||
|
||||
if (definition.scopeType === "CITY" && !metroCity) {
|
||||
console.warn(
|
||||
`Skipping city calendar ${definition.name}: city ${definition.cityName ?? "?"} not found.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let calendar = await findScopedCalendar({
|
||||
countryId: country.id,
|
||||
scopeType: definition.scopeType,
|
||||
stateCode: definition.stateCode,
|
||||
metroCityId: metroCity?.id,
|
||||
});
|
||||
|
||||
if (!calendar) {
|
||||
calendar = await prisma.holidayCalendar.create({
|
||||
data: {
|
||||
name: definition.name,
|
||||
scopeType: definition.scopeType,
|
||||
countryId: country.id,
|
||||
stateCode: definition.scopeType === "STATE" ? definition.stateCode ?? null : null,
|
||||
metroCityId: definition.scopeType === "CITY" ? metroCity?.id ?? null : null,
|
||||
priority: definition.priority,
|
||||
isActive: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
entries: {
|
||||
select: {
|
||||
id: true,
|
||||
date: true,
|
||||
source: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
createdCalendars += 1;
|
||||
} else {
|
||||
reusedCalendars += 1;
|
||||
}
|
||||
|
||||
const entriesByDate = new Map(calendar.entries.map((entry) => [dateKey(entry.date), entry]));
|
||||
|
||||
for (const entry of definition.entries) {
|
||||
const existingEntry = entriesByDate.get(entry.date);
|
||||
|
||||
if (!existingEntry) {
|
||||
await prisma.holidayCalendarEntry.create({
|
||||
data: {
|
||||
holidayCalendarId: calendar.id,
|
||||
date: toUtcDate(entry.date),
|
||||
name: entry.name,
|
||||
isRecurringAnnual: entry.isRecurringAnnual,
|
||||
source: SEED_SOURCE,
|
||||
},
|
||||
});
|
||||
createdEntries += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existingEntry.source && existingEntry.source !== SEED_SOURCE) {
|
||||
skippedManualEntries += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.holidayCalendarEntry.update({
|
||||
where: { id: existingEntry.id },
|
||||
data: {
|
||||
name: entry.name,
|
||||
isRecurringAnnual: entry.isRecurringAnnual,
|
||||
source: SEED_SOURCE,
|
||||
},
|
||||
});
|
||||
updatedEntries += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` calendars created: ${createdCalendars}`);
|
||||
console.log(` calendars reused: ${reusedCalendars}`);
|
||||
console.log(` entries created: ${createdEntries}`);
|
||||
console.log(` entries updated: ${updatedEntries}`);
|
||||
console.log(` manual entries preserved: ${skippedManualEntries}`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,125 @@
|
||||
import { PrismaClient, type Prisma } from "@prisma/client";
|
||||
import { getHolidayDemoCityNamesByCountry, getHolidayDemoProfileForIndex } from "./holiday-demo-profiles.js";
|
||||
import { loadWorkspaceEnv } from "./load-workspace-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type CountryRecord = {
|
||||
id: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
function asJsonObject(value: Prisma.JsonValue | null | undefined): Record<string, Prisma.JsonValue> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return value as Record<string, Prisma.JsonValue>;
|
||||
}
|
||||
|
||||
async function ensureCities(countryByCode: Map<string, CountryRecord>) {
|
||||
const cityNamesByCountry = getHolidayDemoCityNamesByCountry();
|
||||
const cityMap = new Map<string, { id: string }>();
|
||||
|
||||
for (const [countryCode, cityNames] of Object.entries(cityNamesByCountry)) {
|
||||
const country = countryByCode.get(countryCode);
|
||||
if (!country) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const cityName of cityNames) {
|
||||
const city = await prisma.metroCity.upsert({
|
||||
where: {
|
||||
countryId_name: {
|
||||
countryId: country.id,
|
||||
name: cityName,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
countryId: country.id,
|
||||
name: cityName,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
cityMap.set(`${countryCode}:${cityName}`, city);
|
||||
}
|
||||
}
|
||||
|
||||
return cityMap;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Normalizing active resources for holiday demo profiles...");
|
||||
|
||||
const countrySeeds = [
|
||||
{ code: "DE", name: "Germany", dailyWorkingHours: 8 },
|
||||
{ code: "ES", name: "Spain", dailyWorkingHours: 8 },
|
||||
{ code: "IN", name: "India", dailyWorkingHours: 9 },
|
||||
{ code: "US", name: "United States", dailyWorkingHours: 8 },
|
||||
] as const;
|
||||
const countries = [];
|
||||
|
||||
for (const countrySeed of countrySeeds) {
|
||||
countries.push(await prisma.country.upsert({
|
||||
where: { code: countrySeed.code },
|
||||
update: { name: countrySeed.name, dailyWorkingHours: countrySeed.dailyWorkingHours },
|
||||
create: countrySeed,
|
||||
select: { id: true, code: true },
|
||||
}));
|
||||
}
|
||||
|
||||
const countryByCode = new Map(countries.map((country) => [country.code, country] as const));
|
||||
|
||||
const cityByProfile = await ensureCities(countryByCode);
|
||||
const resources = await prisma.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
eid: true,
|
||||
dynamicFields: true,
|
||||
},
|
||||
orderBy: { eid: "asc" },
|
||||
});
|
||||
|
||||
let updated = 0;
|
||||
|
||||
for (const [index, resource] of resources.entries()) {
|
||||
const profile = getHolidayDemoProfileForIndex(index);
|
||||
const country = countryByCode.get(profile.countryCode)!;
|
||||
const metroCity = cityByProfile.get(`${profile.countryCode}:${profile.cityName}`);
|
||||
|
||||
if (!metroCity) {
|
||||
throw new Error(`Missing metro city for profile ${profile.countryCode}/${profile.cityName}`);
|
||||
}
|
||||
|
||||
const dynamicFields = asJsonObject(resource.dynamicFields);
|
||||
|
||||
await prisma.resource.update({
|
||||
where: { id: resource.id },
|
||||
data: {
|
||||
countryId: country.id,
|
||||
metroCityId: metroCity.id,
|
||||
federalState: profile.stateCode,
|
||||
dynamicFields: {
|
||||
...dynamicFields,
|
||||
city: profile.cityName,
|
||||
holidayCountryCode: profile.countryCode,
|
||||
holidayStateCode: profile.stateCode,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
console.log(`Updated ${updated} active resources.`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => void prisma.$disconnect());
|
||||
@@ -8,6 +8,10 @@
|
||||
*/
|
||||
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { loadWorkspaceEnv } from "./load-workspace-env.js";
|
||||
import { assertSafeSeedTarget } from "./safe-destructive-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -75,6 +79,7 @@ const YEARS = [2025, 2026, 2027];
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
assertSafeSeedTarget("db:seed:vacations");
|
||||
// Get admin user to act as approver (fall back to manager, then any user)
|
||||
const admin =
|
||||
(await prisma.user.findFirst({ where: { systemRole: "ADMIN" }, select: { id: true } })) ??
|
||||
|
||||
+32
-10
@@ -12,6 +12,11 @@ import {
|
||||
} from "@capakraken/shared";
|
||||
import { PrismaClient, type Prisma, type Resource, type Project } from "@prisma/client";
|
||||
import { hash } from "@node-rs/argon2";
|
||||
import { getHolidayDemoProfileForIndex } from "./holiday-demo-profiles.js";
|
||||
import { loadWorkspaceEnv } from "./load-workspace-env.js";
|
||||
import { assertSafeSeedTarget } from "./safe-destructive-env.js";
|
||||
|
||||
loadWorkspaceEnv();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -273,10 +278,11 @@ function parseAllocationType(s: string): AllocationType {
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.warn("Seeding Planarchy with 3D studio example data...");
|
||||
const target = assertSafeSeedTarget("db:seed");
|
||||
console.warn(`Seeding CapaKraken example data into ${target.databaseName} (${target.hostname}${target.port ? `:${target.port}` : ""})...`);
|
||||
|
||||
// ── 1. Delete all data (keep users) ────────────────────────────────────────
|
||||
console.warn("Deleting existing data...");
|
||||
console.warn(`Deleting existing data from disposable seed target '${target.databaseName}'...`);
|
||||
await prisma.auditLog.deleteMany({});
|
||||
await prisma.notification.deleteMany({});
|
||||
// Estimates (deep hierarchy)
|
||||
@@ -362,7 +368,7 @@ async function main() {
|
||||
|
||||
const cityMap = new Map<string, string>(); // cityName → id
|
||||
for (const [countryId, cities] of [
|
||||
[countryDE.id, ["Stuttgart", "Hamburg", "Muenchen", "Berlin"]],
|
||||
[countryDE.id, ["Augsburg", "Berlin", "Hamburg", "Koeln", "Muenchen", "Stuttgart"]],
|
||||
[countryIN.id, ["Bangalore", "Mumbai"]],
|
||||
[countryES.id, ["Madrid", "Barcelona"]],
|
||||
[countryUS.id, ["New York", "Los Angeles"]],
|
||||
@@ -871,8 +877,16 @@ async function main() {
|
||||
// ── 5. Create resources ────────────────────────────────────────────────────
|
||||
const resourceMap = new Map<string, Resource>();
|
||||
|
||||
for (const row of RESOURCE_DATA) {
|
||||
const [eid, chapter, typeOfWork, clientUnit, city, employeeType, lcr, ucr, fraction, availDays, chargeability] = row;
|
||||
const countryIdByCode = new Map<string, string>([
|
||||
["DE", countryDE.id],
|
||||
["ES", countryES.id],
|
||||
["IN", countryIN.id],
|
||||
["US", countryUS.id],
|
||||
]);
|
||||
|
||||
for (const [index, row] of RESOURCE_DATA.entries()) {
|
||||
const [eid, chapter, typeOfWork, clientUnit, , employeeType, lcr, ucr, fraction, availDays, chargeability] = row;
|
||||
const holidayProfile = getHolidayDemoProfileForIndex(index);
|
||||
|
||||
const displayName = eid
|
||||
.split(".")
|
||||
@@ -886,8 +900,8 @@ async function main() {
|
||||
const skills = computeSkills(chapter, typeOfWork, lcr);
|
||||
|
||||
// Dispo v2: resolve FKs
|
||||
const resCountryId = countryDE.id; // all seed resources are Germany
|
||||
const resMetroCityId = cityMap.get(city) ?? null;
|
||||
const resCountryId = countryIdByCode.get(holidayProfile.countryCode) ?? countryDE.id;
|
||||
const resMetroCityId = cityMap.get(holidayProfile.cityName) ?? null;
|
||||
|
||||
// chapter → orgUnit mapping
|
||||
const chapterToOrgUnit: Record<string, string> = {
|
||||
@@ -943,9 +957,17 @@ async function main() {
|
||||
chargeabilityTarget: chargeability * 100,
|
||||
availability: availability as unknown as Prisma.InputJsonValue,
|
||||
skills: skills as unknown as Prisma.InputJsonValue,
|
||||
dynamicFields: { clientUnit, workType: typeOfWork, city, employeeType },
|
||||
dynamicFields: {
|
||||
clientUnit,
|
||||
workType: typeOfWork,
|
||||
city: holidayProfile.cityName,
|
||||
employeeType,
|
||||
holidayCountryCode: holidayProfile.countryCode,
|
||||
holidayStateCode: holidayProfile.stateCode,
|
||||
},
|
||||
blueprintId: resourceBlueprint.id,
|
||||
countryId: resCountryId,
|
||||
federalState: holidayProfile.stateCode,
|
||||
...(resMetroCityId ? { metroCityId: resMetroCityId } : {}),
|
||||
...(resOrgUnitId ? { orgUnitId: resOrgUnitId } : {}),
|
||||
...(resMgmtGroupId ? { managementLevelGroupId: resMgmtGroupId } : {}),
|
||||
@@ -1037,7 +1059,7 @@ async function main() {
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
headcount: number;
|
||||
status: string;
|
||||
status: AllocationStatus;
|
||||
}
|
||||
|
||||
const DEMAND_SEEDS: DemandSeed[] = [
|
||||
@@ -1092,7 +1114,7 @@ async function main() {
|
||||
end: string;
|
||||
hoursPerDay: number;
|
||||
percentage: number;
|
||||
status: string;
|
||||
status: AllocationStatus;
|
||||
}
|
||||
|
||||
const ASSIGNMENT_SEEDS: AssignmentSeed[] = [
|
||||
|
||||
Reference in New Issue
Block a user