refactor(vacay): vacation plans, entries, shares and holiday calendars through repositories

This commit is contained in:
jubnl
2026-09-23 21:13:35 +02:00
parent 66b7e6440d
commit 8b94a5a03e
17 changed files with 1728 additions and 358 deletions
@@ -1,4 +1,65 @@
import type { VacayCompanyHolidays } from '../entities/VacayCompanyHolidays.entity';
import { TrekRepository } from './_shared/trek-repository';
export class VacayCompanyHolidaysRepository extends TrekRepository<VacayCompanyHolidays> {}
export class VacayCompanyHolidaysRepository extends TrekRepository<VacayCompanyHolidays> {
/**
* VC29/VC67 — `SELECT date FROM vacay_company_holidays WHERE plan_id = ?`
* (`updatePlan`'s clear-on-enable, narrower columns) and `SELECT date, note
* FROM vacay_company_holidays WHERE plan_id = ?` (`dissolvePlan`'s
* migration read, wider columns). One method returning both columns —
* `updatePlan` destructures only `date` off the result, same rows either
* way.
*/
async listForPlan(planId: number): Promise<{ date: string; note: string | null }[]> {
const rows = await this.find({ plan: planId }, { fields: ['date', 'note'] });
return rows.map((row) => ({ date: row.date, note: row.note ?? null }));
}
/** VC93/VC112 — `SELECT date FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ? ORDER BY date` (`getSharedCalendars`) and `SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ?` (`getEntries`, unordered, full row). Two distinct statements — two methods. */
async listDatesForRange(planId: number, start: string, end: string): Promise<{ date: string }[]> {
const rows = await this.find({ plan: planId, date: { $gte: start, $lt: end } }, { fields: ['date'], orderBy: { date: 'asc' } });
return rows.map((row) => ({ date: row.date }));
}
/** VC112 — `SELECT * FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ?` (`getEntries`, full row, unordered). */
async listForRange(planId: number, start: string, end: string): Promise<{ id: number; plan_id: number; date: string; note: string | null }[]> {
const rows = await this.find({ plan: planId, date: { $gte: start, $lt: end } });
return rows.map((row) => ({ id: row.id, plan_id: row.plan_id, date: row.date, note: row.note ?? null }));
}
/** VC24-sibling: VC25 — `DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?` (`applyHolidayCalendars`'s auto-clear). */
async deleteForPlanAndDate(planId: number, date: string): Promise<void> {
await this.nativeDelete({ plan: planId, date });
}
/** VC104 — `DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ?` (`deleteYear`, the intersection-of-member-windows range). */
async deleteForRange(planId: number, start: string, end: string): Promise<void> {
await this.nativeDelete({ plan: planId, date: { $gte: start, $lt: end } });
}
/** VC70/VC73 — `INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)` (`dissolvePlan`'s two migration branches — identical statement). */
async insertIgnore(planId: number, date: string, note: string): Promise<void> {
await this.upsert({ plan: planId, date, note }, { onConflictFields: ['plan', 'date'], onConflictAction: 'ignore' });
}
/**
* VC118 — `SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date
* = ?` (`toggleCompanyHoliday`'s existing-row read). Named
* `findByPlanAndDate`, not `find` — `EntityRepository#find` already exists
* with an incompatible signature.
*/
async findByPlanAndDate(planId: number, date: string): Promise<{ id: number } | null> {
const row = await this.findOne({ plan: planId, date }, { fields: ['id'] });
return row ? { id: row.id } : null;
}
/** VC119 — `DELETE FROM vacay_company_holidays WHERE id = ?` (`toggleCompanyHoliday`'s clear-on-repeat-click). */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
/** VC120 — `INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)` (`toggleCompanyHoliday`'s new row). */
async insertHoliday(planId: number, date: string, note: string): Promise<void> {
await this.insert({ plan: planId, date, note });
}
}
@@ -1,4 +1,194 @@
import { dateAdd } from '../dialect/sql-functions';
import type { VacayEntries } from '../entities/VacayEntries.entity';
import { TrekRepository } from './_shared/trek-repository';
export class VacayEntriesRepository extends TrekRepository<VacayEntries> {}
interface VacayEntriesKyselyDB {
vacay_entries: { id: number; plan_id: number; user_id: number; date: string; note: string | null; fraction: number; kind: string };
}
/** VC111's joined grid projection (`getEntries`) — the full entry row plus the author's username and color. */
export interface VacayEntryWithPersonRow {
id: number;
plan_id: number;
user_id: number;
date: string;
note: string | null;
fraction: number;
kind: string;
person_name: string;
person_color: string;
}
/** SQLite's `date(<col>, '+N days')` — the same calendar-day arithmetic `dateAdd` spells in SQL (§13), computed in JS for {@link VacayEntriesRepository.shiftForOwnerWindow}'s collision pre-check. Verified against SQLite's own `date()` modifier: both walk the proleptic Gregorian calendar by whole days, so they agree on every input (the same reasoning R9's VC4 date-diff verification gives). */
function shiftIsoDate(isoDate: string, days: number): string {
const d = new Date(`${isoDate}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export class VacayEntriesRepository extends TrekRepository<VacayEntries> {
/**
* VC1 — `SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction
* END), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND
* date >= ? AND date < ?` (`usedDays`). Kysely's expression builder
* (`eb.case()`/`eb.fn.sum`/`eb.fn.coalesce`, the `BudgetItems.repository.ts
* #getPerPersonSummary` precedent) — the `ELSE fraction` branch is a
* COLUMN reference, which `sql-functions.ts#caseWhenEquals` cannot express
* (its `whenTrue`/`whenFalse` are bound VALUES, not column refs), so this
* is built directly, no `sql` tag.
*/
async sumFraction(userId: number, planId: number, start: string, end: string): Promise<number> {
const row = await this.kysely<VacayEntriesKyselyDB>()
.selectFrom('vacay_entries')
.select((eb) => eb.fn.coalesce(eb.fn.sum<number>(eb.case().when('kind', '=', 'comp').then(0).else(eb.ref('fraction')).end()), eb.val(0)).as('used'))
.where('user_id', '=', userId)
.where('plan_id', '=', planId)
.where('date', '>=', start)
.where('date', '<', end)
.executeTakeFirst();
return row?.used ?? 0;
}
/** VC2 — `SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ? AND kind = 'comp'` (`compUsedDays`). */
async sumCompFraction(userId: number, planId: number, start: string, end: string): Promise<number> {
const row = await this.kysely<VacayEntriesKyselyDB>()
.selectFrom('vacay_entries')
.select((eb) => eb.fn.coalesce(eb.fn.sum<number>('fraction'), eb.val(0)).as('used'))
.where('user_id', '=', userId)
.where('plan_id', '=', planId)
.where('date', '>=', start)
.where('date', '<', end)
.where('kind', '=', 'comp')
.executeTakeFirst();
return row?.used ?? 0;
}
/**
* VC5 (`shiftOwnerEntriesForTripWindow`) — `UPDATE OR IGNORE vacay_entries
* SET date = date(date, ? || ' days') WHERE plan_id = ? AND user_id = ? AND
* date BETWEEN ? AND ?`. SQLite's per-statement `OR IGNORE`
* conflict-resolution clause applies to UPDATE as well as INSERT, but
* neither Kysely's `UpdateQueryBuilder` (only `.modifyEnd()`, which appends
* AFTER the whole statement, not after the `UPDATE` keyword) nor MikroORM's
* `nativeUpdate` exposes it — a genuine builder gap, not a helper this
* plan's R9 anticipated (R9's own VC5 note only covers the date
* EXPRESSION, via the existing `dateAdd`, not this conflict-resolution
* clause). Reproduced as a read-then-conditionally-write loop instead:
* read every candidate row, and for each one whose shifted date would
* collide with an existing `(user, plan, date)` row, skip it — `OR
* IGNORE`'s own per-row behavior, exactly — otherwise write it with
* `dateAdd` (still the SQL-computed value, per R9). Low-volume,
* request-scoped (a trip's own leave entries on a date-window move, never
* a hot path), so the per-row round trip is not a concern.
*/
async shiftForOwnerWindow(planId: number, userId: number, start: string, end: string, offset: number): Promise<void> {
const platform = this.getEntityManager().getPlatform();
const rows = await this.find({ plan: planId, user: userId, date: { $gte: start, $lte: end } }, { fields: ['id', 'date'] });
for (const row of rows) {
const shiftedDate = shiftIsoDate(row.date, offset);
const collision = await this.findOne({ plan: planId, user: userId, date: shiftedDate }, { fields: ['id'] });
if (collision) continue;
await this.nativeUpdate({ id: row.id }, { date: dateAdd(platform, 'date', offset) });
}
}
/** VC24/VC30/VC121 — `DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?` (`applyHolidayCalendars`'s auto-clear, `updatePlan`'s company-holiday-enable clear, `toggleCompanyHoliday`'s clear — three identical-text call sites). */
async deleteForPlanAndDate(planId: number, date: string): Promise<void> {
await this.nativeDelete({ plan: planId, date });
}
/** VC54/VC69/VC72 — `UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?` (`acceptInvite`'s migration-in, `dissolvePlan`'s two migration-out branches — three identical-text call sites). */
async updatePlanIdForUser(newPlanId: number, oldPlanId: number, userId: number): Promise<void> {
await this.nativeUpdate({ plan: oldPlanId, user: userId }, { plan: newPlanId });
}
/** VC101 — `SELECT DISTINCT user_id FROM vacay_entries WHERE plan_id = ?` (`deleteYear`'s author enumeration — reads authors off the entries themselves, catching orphans the member list would miss). */
async listAuthorsForPlan(planId: number): Promise<{ user_id: number }[]> {
const rows = await this.kysely<VacayEntriesKyselyDB>()
.selectFrom('vacay_entries')
.select('user_id')
.distinct()
.where('plan_id', '=', planId)
.execute();
return rows.map((row) => ({ user_id: row.user_id }));
}
/**
* VC102 (`deleteYear`) — `DELETE FROM vacay_entries WHERE plan_id = ? AND
* user_id = ? AND date >= ? AND date < ?`. The window is computed PER
* AUTHOR by the caller (each `resolveYearWindow(user_id, year)` call inside
* the per-author loop — #737-aware, since members can be on differently
* shaped leave years) and passed in already resolved; this method never
* re-derives it, so a shortcut that computed the window once and reused it
* across authors would be a caller-side bug, not something this method
* could introduce.
*/
async deleteForRange(planId: number, userId: number, start: string, end: string): Promise<void> {
await this.nativeDelete({ plan: planId, user: userId, date: { $gte: start, $lt: end } });
}
/**
* VC111 (`getEntries`) — `SELECT e.*, u.username as person_name,
* COALESCE(c.color, '#6366f1') as person_color FROM vacay_entries e JOIN
* users u ON e.user_id = u.id LEFT JOIN vacay_user_colors c ON c.user_id =
* e.user_id AND c.plan_id = e.plan_id WHERE e.plan_id = ? AND e.date >= ?
* AND e.date < ?`. Kysely two-condition `LEFT JOIN`
* (`BudgetItems.repository.ts#getPerPersonSummary`'s
* `.onRef(...).onRef(...)` shape).
*/
async listForRangeWithPerson(planId: number, start: string, end: string): Promise<VacayEntryWithPersonRow[]> {
interface EntriesWithPersonKyselyDB {
vacay_entries: { id: number; plan_id: number; user_id: number; date: string; note: string | null; fraction: number; kind: string };
users: { id: number; username: string };
vacay_user_colors: { id: number; user_id: number; plan_id: number; color: string | null };
}
return this.kysely<EntriesWithPersonKyselyDB>()
.selectFrom('vacay_entries as e')
.innerJoin('users as u', 'u.id', 'e.user_id')
.leftJoin('vacay_user_colors as c', (join) => join.onRef('c.user_id', '=', 'e.user_id').onRef('c.plan_id', '=', 'e.plan_id'))
.select((eb) => [
'e.id', 'e.plan_id', 'e.user_id', 'e.date', 'e.note', 'e.fraction', 'e.kind',
'u.username as person_name',
eb.fn.coalesce('c.color', eb.val('#6366f1')).as('person_color'),
])
.where('e.plan_id', '=', planId)
.where('e.date', '>=', start)
.where('e.date', '<', end)
.execute();
}
/** VC92 (`getSharedCalendars`) — `SELECT date, fraction, kind FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ? ORDER BY date`, over the OWNER's plan and the VIEWER's leave-year window. */
async listForOwnerRange(planId: number, userId: number, start: string, end: string): Promise<{ date: string; fraction: number; kind: string | null }[]> {
const rows = await this.find(
{ plan: planId, user: userId, date: { $gte: start, $lt: end } },
{ fields: ['date', 'fraction', 'kind'], orderBy: { date: 'asc' } },
);
return rows.map((row) => ({ date: row.date, fraction: row.fraction, kind: row.kind ?? null }));
}
/**
* VC114 — `SELECT id, fraction, kind FROM vacay_entries WHERE user_id = ?
* AND date = ? AND plan_id = ?` (`toggleEntry`'s existing-entry read).
* Named `findByUserDatePlan`, not `find` — `EntityRepository#find` already
* exists with an incompatible signature.
*/
async findByUserDatePlan(userId: number, date: string, planId: number): Promise<{ id: number; fraction: number; kind: string | null } | null> {
const row = await this.findOne({ user: userId, date, plan: planId }, { fields: ['id', 'fraction', 'kind'] });
return row ? { id: row.id, fraction: row.fraction, kind: row.kind ?? null } : null;
}
/** VC115 — `DELETE FROM vacay_entries WHERE id = ?` (`toggleEntry`'s clear-on-repeat-click). */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
/** VC116 — `UPDATE vacay_entries SET fraction = ?, kind = ? WHERE id = ?` (`toggleEntry`'s in-place conversion). */
async updateFractionKind(id: number, fraction: number, kind: string): Promise<void> {
await this.nativeUpdate({ id }, { fraction, kind });
}
/** VC117 — `INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)` (`toggleEntry`'s new entry — `note` is always `''` from this path). */
async insertEntry(planId: number, userId: number, date: string, note: string, fraction: number, kind: string): Promise<void> {
await this.insert({ plan: planId, user: userId, date, note, fraction, kind });
}
}
@@ -1,12 +1,36 @@
import type { VacayHolidayCalendars } from '../entities/VacayHolidayCalendars.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
/** A `vacay_holiday_calendars` row as the legacy `SELECT *` returned it (Plan 3f Task 5). */
export interface VacayHolidayCalendarRow {
id: number;
plan_id: number;
type: 'public_holiday' | 'school_holiday';
region: string;
label: string | null;
color: string;
sort_order: number;
}
const _vacayHolidayCalendarRowKeys: AssertRowKeys<VacayHolidayCalendarRow, VacayHolidayCalendars> = true;
/** `updateHolidayCalendar` (VC41)'s conditionally-included-fields patch — §15.9's JS-assembled SET list, mapped onto `nativeUpdate`'s own `undefined`-means-omit semantics, same shape as `VacayPlansRepository.update`. */
export interface VacayHolidayCalendarPatch {
region?: string;
type?: 'public_holiday' | 'school_holiday';
label?: string | null;
color?: string;
sort_order?: number;
}
/**
* Plan 3f Task 2 (`SchoolHolidaysService.deleteRegion`) appends EXACTLY this
* one additive method — Task 5 (vacay) builds the rest of this repository
* around it later, per the plan's own file-ownership rule for this shared
* file (`.superpowers/sdd/2026-09-25-orm-phase3f/task-2-brief.md`); no other
* method, no class-level change beyond this.
* Plan 3f Task 2 (`SchoolHolidaysService.deleteRegion`) appended
* {@link VacayHolidayCalendarsRepository.existsForSchoolRegion} first — kept
* untouched, exactly as landed; Task 5 (vacay) appends the rest of this
* repository around it below, per the plan's own file-ownership rule for
* this shared file (`.superpowers/sdd/2026-09-25-orm-phase3f/task-2-brief.md`
* / `task-5-brief.md`).
*/
export class VacayHolidayCalendarsRepository extends TrekRepository<VacayHolidayCalendars> {
/**
@@ -23,4 +47,81 @@ export class VacayHolidayCalendarsRepository extends TrekRepository<VacayHoliday
const row = await this.findOne({ type: 'school_holiday', region }, { fields: ['id'] });
return row !== null;
}
// ---------------------------------------------------------------------
// Plan 3f Task 5 (`VacayService`) — additive, appended after Task 2's own
// method above (untouched).
// ---------------------------------------------------------------------
/** VC22 — `SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? AND type = 'public_holiday' ORDER BY sort_order, id` (`applyHolidayCalendars`). */
async listPublicForPlan(planId: number): Promise<VacayHolidayCalendarRow[]> {
const rows = await this.find({ plan: planId, type: 'public_holiday' }, { orderBy: { sort_order: 'asc', id: 'asc' } });
return rows.map((row) => toRow(row) as VacayHolidayCalendarRow);
}
/**
* VC37/VC131 — `SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?
* ORDER BY sort_order, id` (`updatePlan`'s re-select, `getPlanData` — two
* identical-text call sites, one method).
*/
async listForPlan(planId: number): Promise<VacayHolidayCalendarRow[]> {
const rows = await this.find({ plan: planId }, { orderBy: { sort_order: 'asc', id: 'asc' } });
return rows.map((row) => toRow(row) as VacayHolidayCalendarRow);
}
/** VC26 — `SELECT id FROM vacay_holiday_calendars WHERE plan_id = ?` (`migrateHolidayCalendars`'s one-time legacy-region migration guard — existence only, any row). */
async existsForPlan(planId: number): Promise<boolean> {
const row = await this.findOne({ plan: planId }, { fields: ['id'] });
return row !== null;
}
/**
* VC27/VC38 — `INSERT INTO vacay_holiday_calendars (plan_id, region, label,
* color, sort_order) VALUES (?, ?, NULL, ?, 0)` (`migrateHolidayCalendars`,
* `type` defaulted by the schema) and `INSERT INTO vacay_holiday_calendars
* (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?,
* ?)` (`addHolidayCalendar`, `type` explicit). One flexible method — the
* legacy migration insert's implicit schema-default `type` and the
* explicit insert's bound `type` write the identical column value when the
* caller passes `'public_holiday'` for the former. Named `insertCalendar`,
* not `insert` — `EntityRepository#insert` already exists with an
* incompatible signature.
*/
async insertCalendar(planId: number, type: 'public_holiday' | 'school_holiday', region: string, label: string | null, color: string, sortOrder: number): Promise<number> {
return this.insert({ plan: planId, type, region, label, color, sort_order: sortOrder });
}
/**
* VC39/VC42 — `SELECT * FROM vacay_holiday_calendars WHERE id = ?`
* (`addHolidayCalendar`'s post-insert re-select, `updateHolidayCalendar`'s
* post-write re-select — identical statement, two call sites). Named
* `findById`, not `find` — `EntityRepository#find` already exists with an
* incompatible signature.
*/
async findById(id: number): Promise<VacayHolidayCalendarRow | null> {
const row = await this.findOne({ id });
return row ? (toRow(row) as VacayHolidayCalendarRow) : null;
}
/**
* VC40/VC43 — `SELECT * FROM vacay_holiday_calendars WHERE id = ? AND
* plan_id = ?` — the plan-scoping guard (`updateHolidayCalendar`'s own
* read, `deleteHolidayCalendar`'s own read — identical statement, two call
* sites).
*/
async findScopedForPlan(id: number, planId: number): Promise<VacayHolidayCalendarRow | null> {
const row = await this.findOne({ id, plan: planId });
return row ? (toRow(row) as VacayHolidayCalendarRow) : null;
}
/** VC41 — `UPDATE vacay_holiday_calendars SET ${dynamic} WHERE id = ?`, §15.9's conditionally-included-fields shape (up to 5 optional columns). */
async update(id: number, patch: VacayHolidayCalendarPatch): Promise<void> {
if (Object.keys(patch).length === 0) return;
await this.nativeUpdate({ id }, { ...patch });
}
/** VC44 — `DELETE FROM vacay_holiday_calendars WHERE id = ?` (`deleteHolidayCalendar`, called only after {@link findScopedForPlan} found a scoped row). */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
}
@@ -1,4 +1,190 @@
import type { VacayPlanMembers } from '../entities/VacayPlanMembers.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class VacayPlanMembersRepository extends TrekRepository<VacayPlanMembers> {}
/** A `vacay_plan_members` row as the legacy `SELECT *` returned it (VC51). */
export interface VacayPlanMemberRow {
id: number;
plan_id: number;
user_id: number;
status: string | null;
created_at: string | null;
}
const _vacayPlanMemberRowKeys: AssertRowKeys<VacayPlanMemberRow, VacayPlanMembers> = true;
/** VC17/VC129/VC130's joined member+user projections. */
export interface VacayMemberUserRow {
id: number;
username: string;
email: string;
}
/** VC129 — outgoing pending invites (`getPlanData`). */
export interface VacayPendingOutgoingRow {
id: number;
user_id: number;
username: string;
email: string;
created_at: string | null;
}
/** VC130 — incoming pending invites (`getPlanData`). */
export interface VacayPendingIncomingRow {
id: number;
plan_id: number;
username: string;
email: string;
created_at: string | null;
}
export class VacayPlanMembersRepository extends TrekRepository<VacayPlanMembers> {
/**
* VC13/VC76 — `SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND
* status = 'accepted'` (`getActivePlan`, `peekActivePlan`). No `fields`
* narrowing: `plan_id` is the `persist(false)` shadow of the `plan`
* relation — narrowing to JUST a `persist(false)` mirror column silently
* drops it from the result (verified directly).
*/
async findAcceptedPlanId(userId: number): Promise<{ plan_id: number } | null> {
const row = await this.findOne({ user: userId, status: 'accepted' });
return row ? { plan_id: row.plan_id } : null;
}
/** VC17 — `SELECT u.id, u.username, u.email FROM vacay_plan_members m JOIN users u ON m.user_id = u.id WHERE m.plan_id = ? AND m.status = 'accepted'` (`getPlanUsers`'s accepted-member half). */
async listAcceptedWithUsers(planId: number): Promise<VacayMemberUserRow[]> {
return this.qb('m')
.join('m.user', 'u')
.select(['u.id', 'u.username', 'u.email'])
.where({ 'm.plan': planId, 'm.status': 'accepted' })
.execute<VacayMemberUserRow[]>('all', false);
}
/**
* VC19/VC68 — `SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND
* status = 'accepted'` (`notifyPlanUsers`, `dissolvePlan`'s owner branch).
* No `fields` narrowing — same `persist(false)`-mirror trap as
* {@link findAcceptedPlanId}.
*/
async listAcceptedUserIds(planId: number): Promise<{ user_id: number }[]> {
const rows = await this.find({ plan: planId, status: 'accepted' });
return rows.map((row) => ({ user_id: row.user_id }));
}
/** VC48 — `SELECT id, status FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?` (`sendInvite`'s dedup guard — any status). */
async findMembership(planId: number, userId: number): Promise<{ id: number; status: string | null } | null> {
const row = await this.findOne({ plan: planId, user: userId }, { fields: ['id', 'status'] });
return row ? { id: row.id, status: row.status ?? null } : null;
}
/** VC49 — `SELECT id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'` (`sendInvite`'s single-fusion guard). */
async findAcceptedForUser(userId: number): Promise<{ id: number } | null> {
const row = await this.findOne({ user: userId, status: 'accepted' }, { fields: ['id'] });
return row ? { id: row.id } : null;
}
/** VC50 — `INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, 'pending')`. */
async insertPending(planId: number, userId: number): Promise<void> {
await this.insert({ plan: planId, user: userId, status: 'pending' });
}
/**
* VC47 (`sendInvite`'s invite-target lookup) — `SELECT id, username FROM
* users WHERE id = ? AND COALESCE(is_guest, 0) = 0` — a guest is never
* invitable (#1362-class). `UsersRepository` is a concurrently in-flight
* file owned by another Plan 3f task at the time of this write and outside
* this task's Named file set (same reasoning as `VacayPlansRepository
* .findVacayUser`'s docstring) — a direct `users`-table Kysely read here
* instead.
*/
async findInvitableUser(id: number): Promise<{ id: number; username: string } | null> {
interface UsersKyselyDB {
users: { id: number; username: string; is_guest: number | null };
}
const row = await this.kysely<UsersKyselyDB>()
.selectFrom('users')
.select(['id', 'username'])
.where('id', '=', id)
.where((eb) => eb(eb.fn.coalesce('is_guest', eb.val(0)), '=', 0))
.executeTakeFirst();
return row ? { id: row.id, username: row.username } : null;
}
/** VC51 — `SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'` (`acceptInvite`'s own pending-invite read). */
async findPending(planId: number, userId: number): Promise<VacayPlanMemberRow | null> {
const row = await this.findOne({ plan: planId, user: userId, status: 'pending' });
return row ? (toRow(row) as VacayPlanMemberRow) : null;
}
/** VC52 — `UPDATE vacay_plan_members SET status = 'accepted' WHERE id = ?`. */
async accept(id: number): Promise<void> {
await this.nativeUpdate({ id }, { status: 'accepted' });
}
/** VC65/VC66 — `DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'` (`declineInvite`'s own withdrawal, `cancelInvite`'s owner-initiated withdrawal — identical statement, `targetUserId` in place of `userId`). */
async deletePending(planId: number, userId: number): Promise<void> {
await this.nativeDelete({ plan: planId, user: userId, status: 'pending' });
}
/** VC71 — `DELETE FROM vacay_plan_members WHERE plan_id = ?` (`dissolvePlan`'s owner branch). */
async deleteForPlan(planId: number): Promise<void> {
await this.nativeDelete({ plan: planId });
}
/** VC74 — `DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?` (`dissolvePlan`'s member branch). */
async deleteForPlanAndUser(planId: number, userId: number): Promise<void> {
await this.nativeDelete({ plan: planId, user: userId });
}
/** VC129 — `SELECT m.id, m.user_id, u.username, u.email, m.created_at FROM vacay_plan_members m JOIN users u ON m.user_id = u.id WHERE m.plan_id = ? AND m.status = 'pending'` (`getPlanData`'s outgoing invites). */
async listPendingForPlan(planId: number): Promise<VacayPendingOutgoingRow[]> {
return this.qb('m')
.join('m.user', 'u')
.select(['m.id', 'u.id as user_id', 'u.username', 'u.email', 'm.created_at'])
.where({ 'm.plan': planId, 'm.status': 'pending' })
.execute<VacayPendingOutgoingRow[]>('all', false);
}
/** VC130 — `SELECT m.id, m.plan_id, u.username, u.email, m.created_at FROM vacay_plan_members m JOIN vacay_plans p ON m.plan_id = p.id JOIN users u ON p.owner_id = u.id WHERE m.user_id = ? AND m.status = 'pending'` (`getPlanData`'s incoming invites — the OWNER's username/email, not the member's). */
async listPendingForUser(userId: number): Promise<VacayPendingIncomingRow[]> {
return this.qb('m')
.join('m.plan', 'p')
.join('p.owner', 'u')
.select(['m.id', 'p.id as plan_id', 'u.username', 'u.email', 'm.created_at'])
.where({ 'm.user': userId, 'm.status': 'pending' })
.execute<VacayPendingIncomingRow[]>('all', false);
}
/**
* VC75 (`getAvailableUsers`, the fusion-candidate picker) — `SELECT u.id,
* u.username, u.email FROM users u WHERE u.id != ? AND COALESCE(u.is_guest,
* 0) = 0 AND u.id NOT IN (SELECT user_id FROM vacay_plan_members WHERE
* plan_id = ?) AND u.id NOT IN (SELECT user_id FROM vacay_plan_members
* WHERE status = 'accepted') AND u.id NOT IN (SELECT owner_id FROM
* vacay_plans WHERE id IN (SELECT plan_id FROM vacay_plan_members WHERE
* status = 'accepted')) ORDER BY u.username`. Three nested `NOT IN`
* subqueries (§13) — Kysely, no `sql` tag, every operand builder-expressed.
*/
async listAvailableForFusion(userId: number, planId: number): Promise<VacayMemberUserRow[]> {
interface FusionCandidateKyselyDB {
users: { id: number; username: string; email: string; is_guest: number | null };
vacay_plan_members: { id: number; plan_id: number; user_id: number; status: string | null };
vacay_plans: { id: number; owner_id: number };
}
return this.kysely<FusionCandidateKyselyDB>()
.selectFrom('users as u')
.select(['u.id', 'u.username', 'u.email'])
.where('u.id', '!=', userId)
.where((eb) => eb(eb.fn.coalesce('u.is_guest', eb.val(0)), '=', 0))
.where('u.id', 'not in', (eb) => eb.selectFrom('vacay_plan_members').select('user_id').where('plan_id', '=', planId))
.where('u.id', 'not in', (eb) => eb.selectFrom('vacay_plan_members').select('user_id').where('status', '=', 'accepted'))
.where('u.id', 'not in', (eb) =>
eb
.selectFrom('vacay_plans')
.select('owner_id')
.where('id', 'in', (eb2) => eb2.selectFrom('vacay_plan_members').select('plan_id').where('status', '=', 'accepted')),
)
.orderBy('u.username', 'asc')
.execute();
}
}
@@ -1,4 +1,123 @@
import type { VacayPlans } from '../entities/VacayPlans.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class VacayPlansRepository extends TrekRepository<VacayPlans> {}
/**
* A `vacay_plans` row as the legacy `SELECT *` returned it (Plan 3f Task 5,
* inventory §2, VC7-131's `vacay_plans` group). `owner_id` is the
* `persist(false)` shadow of the `owner` relation (§14's FK-mirror TRAP —
* every plain `find`/`findOne` below reads the whole row, so it always
* hydrates); `created_at` is the one column `VacayService`'s own hand-written
* `VacayPlan` interface never declared (nothing reads it), kept here anyway
* so {@link AssertRowKeys} still pins every scalar column of the entity.
*/
export interface VacayPlanRow {
id: number;
owner_id: number;
block_weekends: number | null;
holidays_enabled: number | null;
holidays_region: string | null;
school_holidays_enabled: number | null;
company_holidays_enabled: number | null;
carry_over_enabled: number | null;
created_at: string | null;
weekend_days: string | null;
week_start: number;
}
const _vacayPlanRowKeys: AssertRowKeys<VacayPlanRow, VacayPlans> = true;
/** `updatePlan` (VC28)'s conditionally-included-fields patch — §15.9: a JS-assembled SET list, not the presence-sentinel idiom, maps directly onto `nativeUpdate`'s own `undefined`-means-omit semantics. */
export interface VacayPlanPatch {
block_weekends?: number;
holidays_enabled?: number;
holidays_region?: string | null;
school_holidays_enabled?: number;
company_holidays_enabled?: number;
carry_over_enabled?: number;
weekend_days?: string;
week_start?: number;
}
export class VacayPlansRepository extends TrekRepository<VacayPlans> {
/** VC7/VC9/VC78 — `SELECT * FROM vacay_plans WHERE owner_id = ?` (one method, three identical-text call sites: `getOwnPlan`'s pre-insert read, its post-insert re-select, and `peekActivePlan`'s owner fallback). */
async findByOwner(ownerId: number): Promise<VacayPlanRow | null> {
const plan = await this.findOne({ owner: ownerId });
return plan ? (toRow(plan) as VacayPlanRow) : null;
}
/**
* VC14 — `SELECT * FROM vacay_plans WHERE id = ?`, the hottest statement in
* this file: seven identical-text call sites (`getActivePlan`,
* `getPlanUsers`, `peekActivePlan`, `updatePlan`'s pre- and post-write
* reads, `addYear`, `deleteYear`, `toggleEntry`, `getStats` — VC14/15/31/
* 36/77/97/107/113/122), all ONE method.
*/
async findById(id: number): Promise<VacayPlanRow | null> {
const plan = await this.findOne({ id });
return plan ? (toRow(plan) as VacayPlanRow) : null;
}
/**
* VC18/VC103 — `SELECT owner_id FROM vacay_plans WHERE id = ?`
* (`notifyPlanUsers`, `deleteYear`'s carry-over recompute). No `fields`
* narrowing: `owner_id` is the `persist(false)` shadow of the `owner`
* relation, and narrowing a `find`/`findOne` to JUST a `persist(false)`
* mirror column silently drops it from the result (verified directly, the
* same trap `PlaceRatingsRepository.listForPlaces`'s docstring names for
* the join-alias case) — a full read costs one extra row's worth of
* columns, never a wrong answer.
*/
async findOwnerId(id: number): Promise<{ owner_id: number } | null> {
const row = await this.findOne({ id });
return row ? { owner_id: row.owner_id } : null;
}
/** VC53 — `SELECT id FROM vacay_plans WHERE owner_id = ?` (`acceptInvite`'s own-plan-to-migrate-from lookup) — a NARROWER column set than {@link findByOwner}'s VC7/9/78 (no `username`/`email`/etc., just `id`), a genuinely distinct statement. */
async findIdByOwner(ownerId: number): Promise<{ id: number } | null> {
const row = await this.findOne({ owner: ownerId }, { fields: ['id'] });
return row ? { id: row.id } : null;
}
/** VC21 — `SELECT holidays_enabled FROM vacay_plans WHERE id = ?` (`applyHolidayCalendars`'s feature gate). */
async getHolidaysEnabled(id: number): Promise<number | null> {
const row = await this.findOne({ id }, { fields: ['holidays_enabled'] });
return row?.holidays_enabled ?? null;
}
/**
* VC8 — `INSERT INTO vacay_plans (owner_id) VALUES (?)` (`getOwnPlan`'s
* lazy-create; every other column takes the schema's own default). Named
* `insertForOwner`, not `insert` — `EntityRepository#insert` already
* exists with an incompatible signature (`Users.repository.ts::insertUser`'s
* precedent).
*/
async insertForOwner(ownerId: number): Promise<number> {
return this.insert({ owner: ownerId });
}
/** VC28 — `UPDATE vacay_plans SET ${dynamic column list} WHERE id = ?`, §15.9's conditionally-included-fields shape: only the keys present in `patch` are written, matching the legacy JS-assembled SET list exactly (an absent key is truly untouched, never a presence-sentinel `CASE WHEN`). */
async update(id: number, patch: VacayPlanPatch): Promise<void> {
if (Object.keys(patch).length === 0) return;
await this.nativeUpdate({ id }, { ...patch });
}
/**
* VC16 (`getPlanUsers`'s owner lookup) — `SELECT id, username, email FROM
* users WHERE id = ?`. `UsersRepository` is a concurrently in-flight file
* owned by another Plan 3f task at the time of this write (Task 4's own
* additive block) — this task's Named file set does not include it, so
* this read stays here (a same-shape precedent to `VacayPlanMembersRepository
* .listAvailableForFusion`'s direct `users`-table Kysely read: a
* repository is not required to route every cross-table read through the
* table's "owning" repository, only to own its OWN table's writes) rather
* than risk a conflicting concurrent edit to a file this task does not own.
*/
async findVacayUser(id: number): Promise<{ id: number; username: string; email: string } | null> {
interface UsersKyselyDB {
users: { id: number; username: string; email: string };
}
const row = await this.kysely<UsersKyselyDB>().selectFrom('users').select(['id', 'username', 'email']).where('id', '=', id).executeTakeFirst();
return row ?? null;
}
}
@@ -1,4 +1,180 @@
import type { VacayShares } from '../entities/VacayShares.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class VacaySharesRepository extends TrekRepository<VacayShares> {}
/** A `vacay_shares` row as the legacy `SELECT *` returned it. */
export interface VacayShareRow {
id: number;
owner_id: number;
user_id: number;
hidden: number;
created_at: string | null;
}
const _vacayShareRowKeys: AssertRowKeys<VacayShareRow, VacayShares> = true;
/** VC81/VC82/VC91's joined share+user projections. */
export interface VacayOutgoingShareRow {
id: number;
user_id: number;
username: string;
}
export interface VacayIncomingShareRow {
id: number;
owner_id: number;
hidden: number;
username: string;
}
export class VacaySharesRepository extends TrekRepository<VacayShares> {
/** VC84 — `SELECT id FROM vacay_shares WHERE owner_id = ? AND user_id = ?` (`shareCalendar`'s dedup guard). */
async findByOwnerAndUser(ownerId: number, userId: number): Promise<{ id: number } | null> {
const row = await this.findOne({ owner: ownerId, user: userId }, { fields: ['id'] });
return row ? { id: row.id } : null;
}
/**
* VC83 (`shareCalendar`'s share-target lookup) — `SELECT id FROM users
* WHERE id = ? AND COALESCE(is_guest, 0) = 0` — a NARROWER column set than
* `VacayPlanMembersRepository.findInvitableUser`'s VC47 (no `username`), a
* genuinely distinct statement (inventory §2's own note), not a reuse.
* `UsersRepository` is a concurrently in-flight file owned by another
* Plan 3f task at the time of this write and outside this task's Named
* file set (same reasoning as `VacayPlansRepository.findVacayUser`'s
* docstring) — a direct `users`-table Kysely read here instead.
*/
async existsInvitableUser(id: number): Promise<boolean> {
interface UsersKyselyDB {
users: { id: number; is_guest: number | null };
}
const row = await this.kysely<UsersKyselyDB>()
.selectFrom('users')
.select('id')
.where('id', '=', id)
.where((eb) => eb(eb.fn.coalesce('is_guest', eb.val(0)), '=', 0))
.executeTakeFirst();
return row !== undefined;
}
/** VC81 — `SELECT s.id, s.user_id, u.username FROM vacay_shares s JOIN users u ON s.user_id = u.id WHERE s.owner_id = ? ORDER BY s.id` (`listShares`'s outgoing half — usernames only, emails withheld). */
async listOutgoing(ownerId: number): Promise<VacayOutgoingShareRow[]> {
return this.qb('s')
.join('s.user', 'u')
.select(['s.id', 'u.id as user_id', 'u.username'])
.where({ 's.owner': ownerId })
.orderBy({ 's.id': 'asc' })
.execute<VacayOutgoingShareRow[]>('all', false);
}
/** VC82/VC91 — `SELECT s.id, s.owner_id, s.hidden, u.username FROM vacay_shares s JOIN users u ON s.owner_id = u.id WHERE s.user_id = ? ORDER BY s.id` (`listShares`'s incoming half, `getSharedCalendars`'s share list — identical statement, two call sites). */
async listIncoming(userId: number): Promise<VacayIncomingShareRow[]> {
return this.qb('s')
.join('s.owner', 'u')
.select(['s.id', 'u.id as owner_id', 's.hidden', 'u.username'])
.where({ 's.user': userId })
.orderBy({ 's.id': 'asc' })
.execute<VacayIncomingShareRow[]>('all', false);
}
/**
* VC85 — `INSERT INTO vacay_shares (owner_id, user_id) VALUES (?, ?)`
* (`shareCalendar`). Named `insertShare`, not `insert` —
* `EntityRepository#insert` already exists with an incompatible signature.
*/
async insertShare(ownerId: number, userId: number): Promise<void> {
await this.insert({ owner: ownerId, user: userId });
}
/**
* R6 — `findScopedForRemoval(id, userId)`: `WHERE id = ? AND (owner_id = ?
* OR user_id = ?)`, mirroring `removeShare`'s wider ownership-or-viewer
* check (VC86, today an UNSCOPED `SELECT * FROM vacay_shares WHERE id = ?`
* followed by a JS check — `share.owner_id !== userId && share.user_id !==
* userId` — that runs before any mutation either way). This method scopes
* the SAME check IN the SQL statement itself: a share row belonging to
* neither the owner NOR the viewer never comes back, so the caller (which
* then deletes-by-id via {@link deleteById}) cannot act on a foreign row
* even if a future change to the service dropped the JS check. Behaviorally
* identical to today on every input (the JS check ran first either way);
* flagged per R6/the plan's "For the user" note as a genuine SQL-shape
* tightening, not silent parity.
*/
async findScopedForRemoval(id: number, userId: number): Promise<VacayShareRow | null> {
const row = await this.findOne({ id, $or: [{ owner: userId }, { user: userId }] });
return row ? (toRow(row) as VacayShareRow) : null;
}
/**
* R6 — `findScopedForHide(id, userId)`: `WHERE id = ? AND user_id = ?`,
* mirroring `setShareHidden`'s NARROWER viewer-only check (VC88, today the
* same unscoped `SELECT * FROM vacay_shares WHERE id = ?` plus a JS check —
* `share.user_id !== userId` only, the OWNER may not hide their own
* outgoing share). Deliberately a DIFFERENT predicate from
* {@link findScopedForRemoval}, not the same method with a mode flag — an
* owner's own row must NOT match this one even though it would match
* `findScopedForRemoval`.
*/
async findScopedForHide(id: number, userId: number): Promise<VacayShareRow | null> {
const row = await this.findOne({ id, user: userId });
return row ? (toRow(row) as VacayShareRow) : null;
}
/** VC87 — `DELETE FROM vacay_shares WHERE id = ?` (`removeShare`, called only after {@link findScopedForRemoval} found a scoped row). */
async deleteById(id: number): Promise<void> {
await this.nativeDelete({ id });
}
/** VC89 — `UPDATE vacay_shares SET hidden = ? WHERE id = ?` (`setShareHidden`, called only after {@link findScopedForHide} found a scoped row). */
async setHidden(id: number, hidden: boolean): Promise<void> {
await this.nativeUpdate({ id }, { hidden: hidden ? 1 : 0 });
}
/** VC20 — `SELECT DISTINCT user_id FROM vacay_shares WHERE owner_id IN (${dynamic})` (`notifyShareViewers`'s fan-out list). Empty `ownerIds` short-circuits before any query, matching the caller's own guard. */
async listDistinctViewerIdsForOwners(ownerIds: number[]): Promise<number[]> {
if (ownerIds.length === 0) return [];
interface VacaySharesKyselyDB {
vacay_shares: { id: number; owner_id: number; user_id: number; hidden: number; created_at: string | null };
}
const rows = await this.kysely<VacaySharesKyselyDB>()
.selectFrom('vacay_shares')
.select('user_id')
.distinct()
.where('owner_id', 'in', ownerIds)
.execute();
return rows.map((row) => row.user_id);
}
/**
* VC90 (`getShareAvailableUsers`, the share-candidate picker) — `SELECT
* u.id, u.username FROM users u WHERE u.id != ? AND COALESCE(u.is_guest, 0)
* = 0 AND u.id NOT IN (SELECT user_id FROM vacay_shares WHERE owner_id = ?)
* AND u.id NOT IN (SELECT owner_id FROM vacay_plans WHERE id = ? UNION
* SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status =
* 'accepted') ORDER BY u.username`. A `UNION` nested inside a `NOT IN`
* subquery (§13) — Kysely, no `sql` tag.
*/
async listAvailableForShare(userId: number, planId: number): Promise<{ id: number; username: string }[]> {
interface ShareCandidateKyselyDB {
users: { id: number; username: string; is_guest: number | null };
vacay_shares: { id: number; owner_id: number; user_id: number };
vacay_plans: { id: number; owner_id: number };
vacay_plan_members: { id: number; plan_id: number; user_id: number; status: string | null };
}
return this.kysely<ShareCandidateKyselyDB>()
.selectFrom('users as u')
.select(['u.id', 'u.username'])
.where('u.id', '!=', userId)
.where((eb) => eb(eb.fn.coalesce('u.is_guest', eb.val(0)), '=', 0))
.where('u.id', 'not in', (eb) => eb.selectFrom('vacay_shares').select('user_id').where('owner_id', '=', userId))
.where('u.id', 'not in', (eb) =>
eb
.selectFrom('vacay_plans')
.select('owner_id')
.where('id', '=', planId)
.union(eb.selectFrom('vacay_plan_members').select('user_id as owner_id').where('plan_id', '=', planId).where('status', '=', 'accepted')),
)
.orderBy('u.username', 'asc')
.execute();
}
}
@@ -1,4 +1,52 @@
import type { VacayUserColors } from '../entities/VacayUserColors.entity';
import { TrekRepository } from './_shared/trek-repository';
export class VacayUserColorsRepository extends TrekRepository<VacayUserColors> {}
export class VacayUserColorsRepository extends TrekRepository<VacayUserColors> {
/** VC79 — `SELECT color FROM vacay_user_colors WHERE plan_id = ?` (`viewerColors`). */
async listForPlan(planId: number): Promise<{ color: string | null }[]> {
const rows = await this.find({ plan: planId }, { fields: ['color'] });
return rows.map((row) => ({ color: row.color ?? null }));
}
/**
* VC57/VC60/VC80/VC124/VC128 — `SELECT color FROM vacay_user_colors WHERE
* user_id = ? AND plan_id = ?` (five identical-text call sites:
* `acceptInvite`'s own-color read and its post-remap re-read,
* `shareDisplayColor`, `getStats`, `getPlanData`'s per-member map). Named
* `findColor`, not `find` — `EntityRepository#find` already exists with an
* incompatible signature.
*/
async findColor(userId: number, planId: number): Promise<{ color: string | null } | null> {
const row = await this.findOne({ user: userId, plan: planId }, { fields: ['color'] });
return row ? { color: row.color ?? null } : null;
}
/** VC59 — `SELECT color FROM vacay_user_colors WHERE plan_id = ? AND user_id != ?` (`acceptInvite`'s taken-colors collection). */
async listOtherColors(planId: number, excludeUserId: number): Promise<{ color: string | null }[]> {
const rows = await this.find({ plan: planId, user: { $ne: excludeUserId } }, { fields: ['color'] });
return rows.map((row) => ({ color: row.color ?? null }));
}
/**
* VC12/VC58/VC62 — `INSERT OR IGNORE INTO vacay_user_colors (user_id,
* plan_id, color) VALUES (?, ?, ?)` (`getOwnPlan`'s lazy default color,
* `acceptInvite`'s own-color migration and its no-collision else-branch —
* three identical-text call sites, one method).
*/
async insertIgnore(userId: number, planId: number, color: string): Promise<void> {
await this.upsert({ user: userId, plan: planId, color }, { onConflictFields: ['user', 'plan'], onConflictAction: 'ignore' });
}
/**
* VC46/VC61 — `INSERT INTO vacay_user_colors (user_id, plan_id, color)
* VALUES (?, ?, ?) ON CONFLICT(user_id, plan_id) DO UPDATE SET color =
* excluded.color` (`setUserColor`'s own write, `acceptInvite`'s
* collision-remap write — identical statement, two call sites).
*/
async upsertColor(userId: number, planId: number, color: string): Promise<void> {
await this.upsert(
{ user: userId, plan: planId, color },
{ onConflictFields: ['user', 'plan'], onConflictAction: 'merge', onConflictMergeFields: ['color'] },
);
}
}
@@ -1,4 +1,48 @@
import type { VacayUserSettings } from '../entities/VacayUserSettings.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class VacayUserSettingsRepository extends TrekRepository<VacayUserSettings> {}
/** A `vacay_user_settings` row as the legacy `SELECT *` returned it — `user` is the entity's primary key (`[PrimaryKeyProp]?: 'user'`), so `user_id` is its `persist(false)` shadow scalar. */
export interface VacayUserSettingsRow {
user_id: number;
year_type: string;
year_start_month: number;
year_start_day: number;
hire_date: string | null;
}
const _vacayUserSettingsRowKeys: AssertRowKeys<VacayUserSettingsRow, VacayUserSettings> = true;
export class VacayUserSettingsRepository extends TrekRepository<VacayUserSettings> {
/**
* VC3 — `SELECT * FROM vacay_user_settings WHERE user_id = ?`
* (`getUserYearSettings`). Named `findForUser`, not `find` —
* `EntityRepository#find` already exists with an incompatible signature.
*/
async findForUser(userId: number): Promise<VacayUserSettingsRow | null> {
const row = await this.findOne({ user: userId });
return row ? (toRow(row) as VacayUserSettingsRow) : null;
}
/**
* VC6 — `INSERT INTO vacay_user_settings (user_id, year_type,
* year_start_month, year_start_day, hire_date) VALUES (?, ?, ?, ?, ?) ON
* CONFLICT(user_id) DO UPDATE SET year_type = excluded.year_type,
* year_start_month = excluded.year_start_month, year_start_day =
* excluded.year_start_day, hire_date = excluded.hire_date`
* (`updateYearSettings`). `user` is the entity's own primary key, so
* `onConflictFields: ['user']` targets it directly — the default `'merge'`
* action already updates every OTHER column present in the payload (all
* four here, matching the legacy statement's own exhaustive `SET` list),
* so no `onConflictMergeFields` narrowing is needed (unlike
* `VacayUserYearsRepository.upsertCarriedOver`, which deliberately narrows
* to ONE column). Named `upsertSettings`, not `upsert` —
* `EntityRepository#upsert` already exists with an incompatible signature.
*/
async upsertSettings(userId: number, yearType: string, yearStartMonth: number, yearStartDay: number, hireDate: string | null): Promise<void> {
await this.upsert(
{ user: userId, year_type: yearType, year_start_month: yearStartMonth, year_start_day: yearStartDay, hire_date: hireDate },
{ onConflictFields: ['user'], onConflictAction: 'merge' },
);
}
}
@@ -1,4 +1,104 @@
import type { VacayUserYears } from '../entities/VacayUserYears.entity';
import { toRow, type AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
export class VacayUserYearsRepository extends TrekRepository<VacayUserYears> {}
/** A `vacay_user_years` row as the legacy `SELECT *` returned it. */
export interface VacayUserYearRow {
id: number;
user_id: number;
plan_id: number;
year: number;
vacation_days: number | null;
carried_over: number | null;
}
const _vacayUserYearRowKeys: AssertRowKeys<VacayUserYearRow, VacayUserYears> = true;
export class VacayUserYearsRepository extends TrekRepository<VacayUserYears> {
/**
* VC34/VC98/VC109/VC123 — `SELECT * FROM vacay_user_years WHERE user_id = ?
* AND plan_id = ? AND year = ?` (four identical-text call sites:
* `updatePlan`'s carry-over chain, `addYear`, `deleteYear`, `getStats`).
* Named `findForYear`, not `find` — `EntityRepository#find` already exists
* with an incompatible signature.
*/
async findForYear(userId: number, planId: number, year: number): Promise<VacayUserYearRow | null> {
const row = await this.findOne({ user: userId, plan: planId, year });
return row ? (toRow(row) as VacayUserYearRow) : null;
}
/** VC55 — `SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ?` (`acceptInvite`'s own-plan-to-date migration read, no `year` filter). */
async listForUserAndPlan(userId: number, planId: number): Promise<VacayUserYearRow[]> {
const rows = await this.find({ user: userId, plan: planId });
return rows.map((row) => toRow(row) as VacayUserYearRow);
}
/**
* VC11/VC56/VC64/VC99 — `INSERT OR IGNORE INTO vacay_user_years (user_id,
* plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, ?)`, one
* method for all four call sites (`getOwnPlan`'s lazy seed, `acceptInvite`'s
* carried-over migration, its all-plan-years seed, `addYear`'s per-user
* seed) — two sites bind `vacationDays`/`carriedOver` as literal `30`/`0`
* at the CALL SITE, one binds the actual prior values, one binds a
* JS-computed carry-over; the VALUES an `INSERT OR IGNORE` writes are
* identical whichever way the caller spells them (a literal `30` in the
* legacy SQL text inserts the exact same row a bound `30` does), so a
* single flexible method, not four near-duplicates.
*/
async insertIgnore(userId: number, planId: number, year: number, vacationDays: number, carriedOver: number): Promise<void> {
await this.upsert(
{ user: userId, plan: planId, year, vacation_days: vacationDays, carried_over: carriedOver },
{ onConflictFields: ['user', 'plan', 'year'], onConflictAction: 'ignore' },
);
}
/**
* VC35/VC126 — `INSERT INTO vacay_user_years (user_id, plan_id, year,
* vacation_days, carried_over) VALUES (?, ?, ?, 30, ?) ON CONFLICT(user_id,
* plan_id, year) DO UPDATE SET carried_over = ?` (`updatePlan`'s
* carry-over recompute chain, `getStats`'s per-request write —
* byte-identical statement, two call sites). `onConflictMergeFields:
* ['carried_over']` is load-bearing (`PlaceRatingsRepository.upsertRating`'s
* precedent): without it `em.upsert`'s default merge set would also touch
* `vacation_days` on a conflict, which the legacy `DO UPDATE SET
* carried_over = ?` never does — `vacation_days: 30` in the payload below
* is only ever written on the INSERT branch, exactly as the legacy
* statement's own literal `30` is.
*/
async upsertCarriedOver(userId: number, planId: number, year: number, carriedOver: number): Promise<void> {
await this.upsert(
{ user: userId, plan: planId, year, vacation_days: 30, carried_over: carriedOver },
{ onConflictFields: ['user', 'plan', 'year'], onConflictAction: 'merge', onConflictMergeFields: ['carried_over'] },
);
}
/**
* VC127 — `INSERT INTO vacay_user_years (user_id, plan_id, year,
* vacation_days, carried_over) VALUES (?, ?, ?, ?, 0) ON CONFLICT(user_id,
* plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days`
* (`updateStats`) — a distinct conflict ACTION from {@link upsertCarriedOver}
* (merges `vacation_days`, leaves `carried_over` untouched on conflict; the
* literal `carried_over: 0` below is INSERT-branch-only, same reasoning).
*/
async upsertVacationDays(userId: number, planId: number, year: number, vacationDays: number): Promise<void> {
await this.upsert(
{ user: userId, plan: planId, year, vacation_days: vacationDays, carried_over: 0 },
{ onConflictFields: ['user', 'plan', 'year'], onConflictAction: 'merge', onConflictMergeFields: ['vacation_days'] },
);
}
/** VC32 — `UPDATE vacay_user_years SET carried_over = 0 WHERE plan_id = ?` (`updatePlan`'s carry-over-disabled reset). */
async resetCarriedOverForPlan(planId: number): Promise<void> {
await this.nativeUpdate({ plan: planId }, { carried_over: 0 });
}
/** VC105 — `DELETE FROM vacay_user_years WHERE plan_id = ? AND year = ?` (`deleteYear`). */
async deleteForYear(planId: number, year: number): Promise<void> {
await this.nativeDelete({ plan: planId, year });
}
/** VC110 — `UPDATE vacay_user_years SET carried_over = ? WHERE user_id = ? AND plan_id = ? AND year = ?` (`deleteYear`'s year+1 carry-over recompute). */
async updateCarriedOver(userId: number, planId: number, year: number, carriedOver: number): Promise<void> {
await this.nativeUpdate({ user: userId, plan: planId, year }, { carried_over: carriedOver });
}
}
@@ -1,4 +1,55 @@
import type { VacayYears } from '../entities/VacayYears.entity';
import { TrekRepository } from './_shared/trek-repository';
export class VacayYearsRepository extends TrekRepository<VacayYears> {}
export class VacayYearsRepository extends TrekRepository<VacayYears> {
/**
* VC23/VC33/VC63/VC94 — `SELECT year FROM vacay_years WHERE plan_id = ?`
* (VC23/VC63, unordered — `applyHolidayCalendars`, `acceptInvite`'s year
* seeding, both iterate the set without caring about order) and `... ORDER
* BY year` (VC33/VC94 — `updatePlan`'s carry-over chain, which walks
* consecutive years and DOES need ascending order, and `listYears`, whose
* caller renders them in order). One method, always ordered: adding
* `ORDER BY year` to the two order-independent call sites changes nothing
* about the ROW SET either reads (same rows, just enumerated consistently)
* — flagged here as a non-behavioral consolidation, not a data-shape
* change, so four near-duplicate statements don't become four near-
* duplicate methods.
*/
async listForPlan(planId: number): Promise<number[]> {
const rows = await this.find({ plan: planId }, { fields: ['year'], orderBy: { year: 'asc' } });
return rows.map((row) => row.year);
}
/** VC95/VC106 — `SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?` (`addYear`'s duplicate-year guard, `deleteYear`'s "does year+1 still exist" check). */
async exists(planId: number, year: number): Promise<boolean> {
const row = await this.findOne({ plan: planId, year }, { fields: ['id'] });
return row !== null;
}
/** VC10 — `INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)` (`getOwnPlan`'s lazy year-seed). */
async insertIgnore(planId: number, year: number): Promise<void> {
await this.upsert({ plan: planId, year }, { onConflictFields: ['plan', 'year'], onConflictAction: 'ignore' });
}
/**
* VC96 — `INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)` (`addYear`'s
* plain insert, already guarded by {@link exists} outside the transaction —
* no `OR IGNORE` needed here, unlike {@link insertIgnore} above). Named
* `insertYear`, not `insert` — `EntityRepository#insert` already exists
* with an incompatible signature.
*/
async insertYear(planId: number, year: number): Promise<void> {
await this.insert({ plan: planId, year });
}
/** VC108 — `SELECT year FROM vacay_years WHERE plan_id = ? AND year < ? ORDER BY year DESC LIMIT 1` (`deleteYear`'s new-previous-year lookup). */
async previousYear(planId: number, beforeYear: number): Promise<number | null> {
const row = await this.findOne({ plan: planId, year: { $lt: beforeYear } }, { fields: ['year'], orderBy: { year: 'desc' } });
return row?.year ?? null;
}
/** VC100 — `DELETE FROM vacay_years WHERE plan_id = ? AND year = ?`. */
async deleteForPlanAndYear(planId: number, year: number): Promise<void> {
await this.nativeDelete({ plan: planId, year });
}
}
+32 -1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { VacayController } from './vacay.controller';
import { VacayService } from './vacay.service';
import { VacayRpc } from './vacay.rpc';
@@ -7,14 +8,44 @@ import { VacayMcp } from './vacay.mcp';
import { AuthModule } from '../auth/auth.module';
import { AddonsModule } from '../addons/addons.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { VacayPlans } from '../../db/entities/VacayPlans.entity';
import { VacayPlanMembers } from '../../db/entities/VacayPlanMembers.entity';
import { VacayYears } from '../../db/entities/VacayYears.entity';
import { VacayUserYears } from '../../db/entities/VacayUserYears.entity';
import { VacayUserColors } from '../../db/entities/VacayUserColors.entity';
import { VacayEntries } from '../../db/entities/VacayEntries.entity';
import { VacayCompanyHolidays } from '../../db/entities/VacayCompanyHolidays.entity';
import { VacayHolidayCalendars } from '../../db/entities/VacayHolidayCalendars.entity';
import { VacayShares } from '../../db/entities/VacayShares.entity';
import { VacayUserSettings } from '../../db/entities/VacayUserSettings.entity';
import { SchoolHolidayRegions } from '../../db/entities/SchoolHolidayRegions.entity';
/**
* Vacay addon domain (S1 — Phase 2 trip sub-domain). Registered in AppModule.
* VacayService is exported for the plugin host surface (VacayRpc);
* VacayMcp carries the DI-discovered MCP tools/resources.
*
* `MikroOrmModule.forFeature([...])` (Plan 3f Task 5) registers every
* repository `VacayService`'s constructor injects via `@InjectRepository`:
* the ten vacay tables plus `SchoolHolidayRegions` (Task 2's, additive —
* `validateManualRegion`'s VC45 cross-domain read consumes its
* `existsForCountry` method; this module never writes to that table). No
* OTHER module constructs `VacayService` directly (`TripsModule` only
* imports `VacayModule` and injects the exported provider), so this is the
* only `forFeature` array the BOOT GATE requires updating.
*/
@Module({
imports: [NotificationsModule, AuthModule, PluginGuardsModule, AddonsModule],
imports: [
NotificationsModule,
AuthModule,
PluginGuardsModule,
AddonsModule,
MikroOrmModule.forFeature([
VacayPlans, VacayPlanMembers, VacayYears, VacayUserYears, VacayUserColors,
VacayEntries, VacayCompanyHolidays, VacayHolidayCalendars, VacayShares, VacayUserSettings,
SchoolHolidayRegions,
]),
],
controllers: [VacayController],
providers: [VacayService, VacayMcp, VacayRpc],
exports: [VacayService],
File diff suppressed because it is too large Load Diff
+23 -2
View File
@@ -149,6 +149,11 @@ import {
createTestSchoolHolidayCountriesRepo, createTestSchoolHolidayRegionsRepo,
createTestSchoolHolidayPeriodsRepo, createTestVacayHolidayCalendarsRepo,
} from './school-holidays-repos';
import {
createTestVacayPlansRepo, createTestVacayPlanMembersRepo, createTestVacayYearsRepo, createTestVacayUserYearsRepo,
createTestVacayUserColorsRepo, createTestVacayEntriesRepo, createTestVacayCompanyHolidaysRepo,
createTestVacaySharesRepo, createTestVacayUserSettingsRepo,
} from './vacay-repos';
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
import { Users } from '../../src/db/entities/Users.entity';
@@ -289,7 +294,15 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
daysService,
permissionsService,
budgetService,
new VacayService(dbService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection)),
new VacayService(
await createTestVacayPlansRepo(dbService.connection), await createTestVacayPlanMembersRepo(dbService.connection),
await createTestVacayYearsRepo(dbService.connection), await createTestVacayUserYearsRepo(dbService.connection),
await createTestVacayUserColorsRepo(dbService.connection), await createTestVacayEntriesRepo(dbService.connection),
await createTestVacayCompanyHolidaysRepo(dbService.connection), await createTestVacayHolidayCalendarsRepo(dbService.connection),
await createTestVacaySharesRepo(dbService.connection), await createTestVacayUserSettingsRepo(dbService.connection),
await createTestSchoolHolidayRegionsRepo(dbService.connection),
realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection),
),
realtimeService,
new UnsplashService(appSettings, usersRepo, new RuntimeEnvService(), generalStorage),
generalStorage,
@@ -367,7 +380,15 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new AccommodationsMcp(accommodationsService, placesService, authService, guards, await createTestUnitOfWork(dbService.connection)),
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
new CollabMcp(collabService, authService, addonsService, guards),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection)), authService, addonsService),
new VacayMcp(new VacayService(
await createTestVacayPlansRepo(dbService.connection), await createTestVacayPlanMembersRepo(dbService.connection),
await createTestVacayYearsRepo(dbService.connection), await createTestVacayUserYearsRepo(dbService.connection),
await createTestVacayUserColorsRepo(dbService.connection), await createTestVacayEntriesRepo(dbService.connection),
await createTestVacayCompanyHolidaysRepo(dbService.connection), await createTestVacayHolidayCalendarsRepo(dbService.connection),
await createTestVacaySharesRepo(dbService.connection), await createTestVacayUserSettingsRepo(dbService.connection),
await createTestSchoolHolidayRegionsRepo(dbService.connection),
realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection),
), authService, addonsService),
new SchoolHolidaysMcp(
new SchoolHolidaysService(
await createTestSchoolHolidayCountriesRepo(dbService.connection),
+15 -1
View File
@@ -101,6 +101,12 @@ import {
createTestPackingTemplateItemsRepo,
} from './packing-repos';
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from './todo-repos';
import { createTestVacayHolidayCalendarsRepo, createTestSchoolHolidayRegionsRepo } from './school-holidays-repos';
import {
createTestVacayPlansRepo, createTestVacayPlanMembersRepo, createTestVacayYearsRepo, createTestVacayUserYearsRepo,
createTestVacayUserColorsRepo, createTestVacayEntriesRepo, createTestVacayCompanyHolidaysRepo,
createTestVacaySharesRepo, createTestVacayUserSettingsRepo,
} from './vacay-repos';
/**
* Hand-wired counterpart of the PluginsModule DI graph for no-Nest tests
@@ -147,7 +153,15 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
await createTestCollabPollVotesRepo(dbs.connection), await createTestCollabLinksRepo(dbs.connection), await createTestCollabMessagesRepo(dbs.connection),
await createTestTripsRepo(dbs.connection),
);
const vacay = new VacayService(dbs, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
const vacay = new VacayService(
await createTestVacayPlansRepo(dbs.connection), await createTestVacayPlanMembersRepo(dbs.connection),
await createTestVacayYearsRepo(dbs.connection), await createTestVacayUserYearsRepo(dbs.connection),
await createTestVacayUserColorsRepo(dbs.connection), await createTestVacayEntriesRepo(dbs.connection),
await createTestVacayCompanyHolidaysRepo(dbs.connection), await createTestVacayHolidayCalendarsRepo(dbs.connection),
await createTestVacaySharesRepo(dbs.connection), await createTestVacayUserSettingsRepo(dbs.connection),
await createTestSchoolHolidayRegionsRepo(dbs.connection),
realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection),
);
const days = new DaysService(
dbs,
permissions,
+72
View File
@@ -0,0 +1,72 @@
import type Database from 'better-sqlite3';
import { sharedTestOrm } from './test-uow';
import { VacayPlans } from '../../src/db/entities/VacayPlans.entity';
import type { VacayPlansRepository } from '../../src/db/repositories/VacayPlans.repository';
import { VacayPlanMembers } from '../../src/db/entities/VacayPlanMembers.entity';
import type { VacayPlanMembersRepository } from '../../src/db/repositories/VacayPlanMembers.repository';
import { VacayYears } from '../../src/db/entities/VacayYears.entity';
import type { VacayYearsRepository } from '../../src/db/repositories/VacayYears.repository';
import { VacayUserYears } from '../../src/db/entities/VacayUserYears.entity';
import type { VacayUserYearsRepository } from '../../src/db/repositories/VacayUserYears.repository';
import { VacayUserColors } from '../../src/db/entities/VacayUserColors.entity';
import type { VacayUserColorsRepository } from '../../src/db/repositories/VacayUserColors.repository';
import { VacayEntries } from '../../src/db/entities/VacayEntries.entity';
import type { VacayEntriesRepository } from '../../src/db/repositories/VacayEntries.repository';
import { VacayCompanyHolidays } from '../../src/db/entities/VacayCompanyHolidays.entity';
import type { VacayCompanyHolidaysRepository } from '../../src/db/repositories/VacayCompanyHolidays.repository';
import { VacayShares } from '../../src/db/entities/VacayShares.entity';
import type { VacaySharesRepository } from '../../src/db/repositories/VacayShares.repository';
import { VacayUserSettings } from '../../src/db/entities/VacayUserSettings.entity';
import type { VacayUserSettingsRepository } from '../../src/db/repositories/VacayUserSettings.repository';
/**
* Plan 3f Task 5 (`VacayService`) test-only repository factories, bound to a
* suite's own better-sqlite3 handle via the SAME memoised `sharedTestOrm`
* `test-uow.ts` exports — the `school-holidays-repos.ts`/`todo-repos.ts`
* precedent this file copies verbatim: a repository built here and a
* `UnitOfWork` built from `createTestUnitOfWork` (that file) resolve the
* IDENTICAL context-resolving `EntityManager`, required so a repository from
* a SECOND, independent ORM instance never writes outside the open
* transaction.
*
* `VacayHolidayCalendarsRepository`'s own factory
* (`createTestVacayHolidayCalendarsRepo`) already exists in
* `school-holidays-repos.ts` (built by Task 2, before this task landed) and
* `SchoolHolidayRegionsRepository`'s (`createTestSchoolHolidayRegionsRepo`)
* likewise — both reused from there, not duplicated here.
*/
export function createTestVacayPlansRepo(db: Database.Database): Promise<VacayPlansRepository> {
return sharedTestOrm(db).then((t) => t.repo(VacayPlans));
}
export function createTestVacayPlanMembersRepo(db: Database.Database): Promise<VacayPlanMembersRepository> {
return sharedTestOrm(db).then((t) => t.repo(VacayPlanMembers));
}
export function createTestVacayYearsRepo(db: Database.Database): Promise<VacayYearsRepository> {
return sharedTestOrm(db).then((t) => t.repo(VacayYears));
}
export function createTestVacayUserYearsRepo(db: Database.Database): Promise<VacayUserYearsRepository> {
return sharedTestOrm(db).then((t) => t.repo(VacayUserYears));
}
export function createTestVacayUserColorsRepo(db: Database.Database): Promise<VacayUserColorsRepository> {
return sharedTestOrm(db).then((t) => t.repo(VacayUserColors));
}
export function createTestVacayEntriesRepo(db: Database.Database): Promise<VacayEntriesRepository> {
return sharedTestOrm(db).then((t) => t.repo(VacayEntries));
}
export function createTestVacayCompanyHolidaysRepo(db: Database.Database): Promise<VacayCompanyHolidaysRepository> {
return sharedTestOrm(db).then((t) => t.repo(VacayCompanyHolidays));
}
export function createTestVacaySharesRepo(db: Database.Database): Promise<VacaySharesRepository> {
return sharedTestOrm(db).then((t) => t.repo(VacayShares));
}
export function createTestVacayUserSettingsRepo(db: Database.Database): Promise<VacayUserSettingsRepository> {
return sharedTestOrm(db).then((t) => t.repo(VacayUserSettings));
}
+15 -1
View File
@@ -113,6 +113,12 @@ import {
createTestPackingTemplateItemsRepo,
} from '../../helpers/packing-repos';
import { createTestTodoItemsRepo, createTestTodoCategoryAssigneesRepo } from '../../helpers/todo-repos';
import {
createTestVacayPlansRepo, createTestVacayPlanMembersRepo, createTestVacayYearsRepo, createTestVacayUserYearsRepo,
createTestVacayUserColorsRepo, createTestVacayEntriesRepo, createTestVacayCompanyHolidaysRepo,
createTestVacaySharesRepo, createTestVacayUserSettingsRepo,
} from '../../helpers/vacay-repos';
import { createTestVacayHolidayCalendarsRepo, createTestSchoolHolidayRegionsRepo } from '../../helpers/school-holidays-repos';
// Real sibling services over the same in-memory DB — updateTrip's date-shift
// resyncs and the summary/bundle aggregation run their actual SQL.
@@ -205,7 +211,15 @@ beforeAll(async () => {
daysSvc,
new PermissionsService(await createTestAppSettingsRepo(dbs().connection), await createTestUnitOfWork(dbs().connection)),
budgetSvc,
new VacayService(dbs(), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
new VacayService(
await createTestVacayPlansRepo(dbs().connection), await createTestVacayPlanMembersRepo(dbs().connection),
await createTestVacayYearsRepo(dbs().connection), await createTestVacayUserYearsRepo(dbs().connection),
await createTestVacayUserColorsRepo(dbs().connection), await createTestVacayEntriesRepo(dbs().connection),
await createTestVacayCompanyHolidaysRepo(dbs().connection), await createTestVacayHolidayCalendarsRepo(dbs().connection),
await createTestVacaySharesRepo(dbs().connection), await createTestVacayUserSettingsRepo(dbs().connection),
await createTestSchoolHolidayRegionsRepo(dbs().connection),
new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection),
),
new RealtimeService(),
undefined as never, // unsplash — not exercised here
coversFx.storage,
+187 -16
View File
@@ -32,11 +32,16 @@ import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { notificationsStub } from '../../helpers/notifications';
import { createTestUnitOfWork } from '../../helpers/test-uow';
import {
createTestVacayPlansRepo, createTestVacayPlanMembersRepo, createTestVacayYearsRepo, createTestVacayUserYearsRepo,
createTestVacayUserColorsRepo, createTestVacayEntriesRepo, createTestVacayCompanyHolidaysRepo,
createTestVacaySharesRepo, createTestVacayUserSettingsRepo,
} from '../../helpers/vacay-repos';
import { createTestVacayHolidayCalendarsRepo, createTestSchoolHolidayRegionsRepo } from '../../helpers/school-holidays-repos';
// VACAY-SVC-001 through VACAY-SVC-066 moved 1:1 from the legacy
// tests/unit/services/vacayService.test.ts (the named-function imports became
@@ -51,7 +56,15 @@ let svc: VacayService;
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb));
svc = new VacayService(
await createTestVacayPlansRepo(testDb), await createTestVacayPlanMembersRepo(testDb),
await createTestVacayYearsRepo(testDb), await createTestVacayUserYearsRepo(testDb),
await createTestVacayUserColorsRepo(testDb), await createTestVacayEntriesRepo(testDb),
await createTestVacayCompanyHolidaysRepo(testDb), await createTestVacayHolidayCalendarsRepo(testDb),
await createTestVacaySharesRepo(testDb), await createTestVacayUserSettingsRepo(testDb),
await createTestSchoolHolidayRegionsRepo(testDb),
new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb),
);
});
beforeEach(() => {
@@ -499,6 +512,77 @@ describe('deleteYear', () => {
.all(plan.id, `${targetYear}-%`);
expect(entries).toHaveLength(0);
});
it('VACAY-SVC-030a (VC102 parity): touches only each author\'s OWN leave-year window, not one shared window across a fused plan', async () => {
// Two members of the SAME plan on genuinely different leave-year shapes
// (#737): userA stays on the default calendar year, userB is fiscal
// (Apr 1 start). deleteYear(planId, 2026) must delete userA's entry that
// falls in A's calendar-2026 window and userB's entry that falls in B's
// fiscal-2026 window, while leaving each author's OWN neighbouring-period
// entry untouched — a shortcut that computed ONE shared window (off
// either the plan or either single author) and reused it across both
// authors would either strand entries or delete another member's rows on
// their differently-shaped year.
const { user: userA, plan } = await setupUserWithPlan();
const { user: userB } = createUser(testDb);
await svc.getOwnPlan(userB.id);
insertMember(plan.id, userB.id, 'accepted');
await svc.updateYearSettings(userB.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 1 });
await svc.addYear(plan.id, 2026, undefined);
const insertEntry = (userId: number, date: string) =>
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, userId, date, '');
insertEntry(userA.id, '2026-02-10'); // inside A's CALENDAR 2026 window [2026-01-01, 2027-01-01) — must be deleted
insertEntry(userA.id, '2025-11-01'); // inside A's CALENDAR 2025 window — must survive
insertEntry(userB.id, '2026-02-10'); // inside B's FISCAL 2025 window [2025-04-01, 2026-04-01) — must survive
insertEntry(userB.id, '2026-05-10'); // inside B's FISCAL 2026 window [2026-04-01, 2027-04-01) — must be deleted
await svc.deleteYear(plan.id, 2026, undefined);
const remaining = testDb.prepare('SELECT user_id, date FROM vacay_entries WHERE plan_id = ? ORDER BY user_id, date').all(plan.id);
expect(remaining).toEqual([
{ user_id: userA.id, date: '2025-11-01' },
{ user_id: userB.id, date: '2026-02-10' },
]);
});
});
// ── shiftOwnerEntriesForTripWindow (VC4 restructured date-diff shape) ─────────
describe('shiftOwnerEntriesForTripWindow', () => {
it('VACAY-SVC-030b (VC4 parity): shifts an entry by the exact calendar-day offset between the old and new trip start, across several date-pair shapes incl. a leap-year boundary', async () => {
// R9's verified restructured shape (task-0-report.md): plain JS
// Math.round((Date.parse(newStart) - Date.parse(oldStart)) / 86400000)
// in place of the legacy CAST(julianday(?) - julianday(?) AS INTEGER) —
// verified there against the SQL on 5 date pairs incl. a leap-year
// boundary and a negative offset, all matched exactly. This proves the
// SAME arithmetic end-to-end through the converted service method.
const { user, plan } = await setupUserWithPlan();
const cases: [oldStart: string, oldEnd: string, newStart: string, entryDate: string, expectedShifted: string][] = [
['2026-03-01', '2026-03-10', '2026-03-06', '2026-03-05', '2026-03-10'], // +5 days
['2024-02-25', '2024-03-05', '2024-02-28', '2024-03-01', '2024-03-04'], // leap-year boundary, +3 days
['2026-06-10', '2026-06-20', '2026-06-03', '2026-06-15', '2026-06-08'], // -7 days
];
for (const [oldStart, oldEnd, newStart, entryDate, expectedShifted] of cases) {
testDb.prepare('DELETE FROM vacay_entries WHERE plan_id = ?').run(plan.id);
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, entryDate, '');
await svc.shiftOwnerEntriesForTripWindow(user.id, oldStart, oldEnd, newStart);
const row = testDb.prepare('SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ?').get(plan.id, user.id) as { date: string };
expect(row.date).toBe(expectedShifted);
}
});
it('VACAY-SVC-030c: a zero offset is a no-op (no write at all)', async () => {
const { user, plan } = await setupUserWithPlan();
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, '2026-05-05', '');
await svc.shiftOwnerEntriesForTripWindow(user.id, '2026-05-01', '2026-05-10', '2026-05-01');
const row = testDb.prepare('SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ?').get(plan.id, user.id) as { date: string };
expect(row.date).toBe('2026-05-05');
});
});
// ── getEntries / toggleEntry ──────────────────────────────────────────────────
@@ -513,6 +597,46 @@ describe('getEntries', () => {
expect(result.entries).toEqual([]);
expect(result.companyHolidays).toEqual([]);
});
it('VACAY-SVC-031a (VC111 parity): full-key toEqual against the legacy statement run raw on the same seeded rows', async () => {
// A fused plan across all three leave-year-window shapes (#737), every
// fraction/kind combination, and a member left on the default color to
// exercise the COALESCE(c.color, '#6366f1') fallback branch too — rule
// 19's "fully seeded" bar for this read model.
const { user: owner, plan } = await setupUserWithPlan();
allowWeekends(plan.id);
const { user: userB } = createUser(testDb);
const { user: userC } = createUser(testDb);
await svc.getOwnPlan(userB.id);
await svc.getOwnPlan(userC.id);
insertMember(plan.id, userB.id, 'accepted');
insertMember(plan.id, userC.id, 'accepted');
await svc.updateYearSettings(userB.id, { year_type: 'fiscal', year_start_month: 4, year_start_day: 1 });
await svc.updateYearSettings(userC.id, { year_type: 'anniversary', hire_date: '2020-06-15' });
await svc.setUserColor(owner.id, plan.id, '#111111', undefined);
await svc.setUserColor(userB.id, plan.id, '#222222', undefined);
// userC keeps whatever color getOwnPlan seeded by default — not
// re-set here, so its row exercises the LEFT JOIN's COALESCE fallback
// the same way a row with no vacay_user_colors match would.
await svc.toggleEntry(owner.id, plan.id, '2026-02-10', 1, 'vacation');
await svc.toggleEntry(userB.id, plan.id, '2026-05-05', 0.5, 'vacation');
await svc.toggleEntry(userC.id, plan.id, '2026-08-20', 1, 'comp');
const result = await svc.getEntries(plan.id, '2026', owner.id);
const legacy = testDb.prepare(`
SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color
FROM vacay_entries e
JOIN users u ON e.user_id = u.id
LEFT JOIN vacay_user_colors c ON c.user_id = e.user_id AND c.plan_id = e.plan_id
WHERE e.plan_id = ? AND e.date >= ? AND e.date < ?
`).all(plan.id, '2026-01-01', '2027-01-01') as { id: number }[];
expect(legacy).toHaveLength(3);
const byId = <T extends { id: number }>(rows: T[]) => [...rows].sort((a, b) => a.id - b.id);
expect(byId(result.entries as { id: number }[])).toEqual(byId(legacy));
});
});
describe('toggleEntry', () => {
@@ -1520,15 +1644,62 @@ describe('getSharedCalendars', () => {
// ── Quirk fixes (transactions, fetch hygiene, cache TTL, addYear errors) ──────
describe('quirk fixes', () => {
/** A DatabaseService whose run() throws when the SQL matches, for atomicity checks. */
/** A fresh, fully-functioning VacayService over the same testDb — repository-backed now (Plan 3f Task 5), so a fresh instance no longer needs a `DatabaseService` wrapper, only its own repo set (the holiday-provider cache is instance state, so `applyHolidayCalendars`/`getCountries`/etc.'s TTL tests need a service the earlier tests in this file never touched). */
async function freshVacayService(): Promise<VacayService> {
return (await buildVacayServiceWithRepos()).service;
}
/**
* Same repo set `freshVacayService` builds, but returns the repos
* themselves too, so a caller can `vi.spyOn` one of them before
* constructing — the repository-backed replacement for the legacy
* `failingService`'s `DatabaseService.run` SQL-text-match spy (this file's
* pre-conversion mechanism could match a raw SQL substring; a repository
* has no raw SQL text left to match, so this spies on the REPOSITORY
* METHOD that now issues the statement instead).
*/
async function buildVacayServiceWithRepos() {
const repos = {
plans: await createTestVacayPlansRepo(testDb),
members: await createTestVacayPlanMembersRepo(testDb),
years: await createTestVacayYearsRepo(testDb),
userYears: await createTestVacayUserYearsRepo(testDb),
userColors: await createTestVacayUserColorsRepo(testDb),
entries: await createTestVacayEntriesRepo(testDb),
companyHolidays: await createTestVacayCompanyHolidaysRepo(testDb),
holidayCalendars: await createTestVacayHolidayCalendarsRepo(testDb),
shares: await createTestVacaySharesRepo(testDb),
userSettings: await createTestVacayUserSettingsRepo(testDb),
schoolHolidayRegions: await createTestSchoolHolidayRegionsRepo(testDb),
};
const service = new VacayService(
repos.plans, repos.members, repos.years, repos.userYears, repos.userColors, repos.entries,
repos.companyHolidays, repos.holidayCalendars, repos.shares, repos.userSettings, repos.schoolHolidayRegions,
new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb),
);
return { repos, service };
}
/**
* A VacayService whose one named repository method throws once, for
* atomicity checks — the `uow.transactional` rollback proof. `match` keeps
* the three call sites below unchanged (they still name the legacy
* statement they mean to fail); this maps it onto the repository method
* that now issues it. `mockImplementationOnce` self-restores after the one
* throw, so it never leaks into a later test even though `createTestVacay*
* Repo` memoises one repository instance per entity per `testDb` handle
* (the same instance `svc`, built once in `beforeAll`, also uses).
*/
async function failingService(match: string) {
const failingDb = new DatabaseService(testDb);
const realRun = failingDb.run.bind(failingDb);
vi.spyOn(failingDb, 'run').mockImplementation((sql: string, ...params: unknown[]) => {
if (sql.includes(match)) throw new Error('boom');
return realRun(sql, ...params);
});
return new VacayService(failingDb, new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb));
const { repos, service } = await buildVacayServiceWithRepos();
if (match === 'INSERT OR IGNORE INTO vacay_user_years') {
vi.spyOn(repos.userYears, 'insertIgnore').mockImplementationOnce(() => { throw new Error('boom'); });
} else if (match === 'DELETE FROM vacay_user_years') {
vi.spyOn(repos.userYears, 'deleteForYear').mockImplementationOnce(() => { throw new Error('boom'); });
} else {
throw new Error(`failingService: no repository mapping for match "${match}"`);
}
return service;
}
it('VACAY-SVC-068: acceptInvite is atomic — a failure mid-flow rolls the status flip back', async () => {
@@ -1560,7 +1731,7 @@ describe('quirk fixes', () => {
it('VACAY-SVC-070: getCountries surfaces an upstream non-2xx as the fetch error and caches nothing', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 502, json: async () => ({}) });
vi.stubGlobal('fetch', fetchMock);
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb));
const fresh = await freshVacayService();
expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' });
// Nothing cached: a retry hits the network again.
@@ -1572,7 +1743,7 @@ describe('quirk fixes', () => {
it('VACAY-SVC-070a: getHolidays refuses a year or country that is not a plain code', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb));
const fresh = await freshVacayService();
for (const [year, country] of [['../../..', 'DE'], ['2026', 'DE/../../x'], ['20xx', 'DE'], ['2026', 'DEU']]) {
expect(await fresh.getHolidays(year, country)).toEqual({ error: 'Failed to fetch holidays' });
@@ -1583,7 +1754,7 @@ describe('quirk fixes', () => {
it('VACAY-SVC-070b: getSchoolHolidayRegions refuses a country that is not an alpha-2 code', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb));
const fresh = await freshVacayService();
expect(await fresh.getSchoolHolidayRegions('DE&countryIsoCode=FR')).toEqual({
error: 'Failed to fetch school holiday regions',
@@ -1598,7 +1769,7 @@ describe('quirk fixes', () => {
json: async () => [{ date: '2026-01-01' }],
});
vi.stubGlobal('fetch', fetchMock);
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb));
const fresh = await freshVacayService();
expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' });
expect(await fresh.getHolidays('2026', 'DE')).toEqual({ error: 'Failed to fetch holidays' });
@@ -1624,7 +1795,7 @@ describe('quirk fixes', () => {
};
});
vi.stubGlobal('fetch', fetchMock);
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb));
const fresh = await freshVacayService();
expect(await fresh.getHolidays('2026', 'DE')).toEqual({ error: 'Failed to fetch holidays' });
expect(await fresh.getCountries()).toEqual({ error: 'Failed to fetch countries' });
@@ -1636,7 +1807,7 @@ describe('quirk fixes', () => {
testDb.prepare("INSERT INTO vacay_holiday_calendars (plan_id, region) VALUES (?, 'DE')").run(plan.id);
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [] });
vi.stubGlobal('fetch', fetchMock);
const fresh = new VacayService(new DatabaseService(testDb), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(testDb));
const fresh = await freshVacayService();
await fresh.applyHolidayCalendars(plan.id);
const afterFirst = fetchMock.mock.calls.length;