mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
refactor(vacay): vacation plans, entries, shares and holiday calendars through repositories
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user