feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
@@ -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,
);
});
+70
View File
@@ -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;
}
+67 -14
View File
@@ -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"],
});
});
+39
View File
@@ -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[]>;
}
+7
View File
@@ -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) {
+14
View File
@@ -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,
});
}
+6 -1
View File
@@ -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"] },
+205
View File
@@ -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());
+5
View File
@@ -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
View File
@@ -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[] = [