diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d7a54e81..713a2165f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -180,9 +180,26 @@ jobs: # catches it. run: cd server && npm run typecheck:tests + - name: Typecheck entity generator + # tsconfig.scripts.json covers generate-entities.ts and migrate-encryption.ts; + # gen-plugin-facts.ts uses import.meta and does not compile under this + # config (needs a newer --module target), so the gate is named for what + # it actually covers rather than the whole directory. + run: cd server && npm run typecheck:scripts + + - name: Entity files match the generator + run: cd server && npm run check:entities + - name: Lint + # Type-aware rules build the src + tests program, which outgrows + # Node's default ~4 GB heap on the runner. + env: + NODE_OPTIONS: --max-old-space-size=8192 run: cd server && npm run lint:check + - name: DB call-graph gate (no sync DB-touching methods, no raw transactions) + run: cd server && npm run db:call-graph -- --sync --tx + client-quality: name: Client Types & Lint runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 49dfc82f7..d88aa9aea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ Nest owns everything: every domain is a DI module under `server/src/nest/')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler). -- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB. +- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests** — the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` test builders are gone. Seeding is MikroORM's too (`db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection (bound in `db/orm-driver.ts`) rather than opening its own. Queries go through repositories (`server/src/db/repositories/`) and `UnitOfWork` (`server/src/nest/database/unit-of-work.ts`) for transactions — there is no `DatabaseService`. `better-sqlite3` itself is ESLint-restricted to `src/db/` plus a short, named allow-list (`server/eslint.config.mjs`), so a domain reaching for the driver directly fails lint. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB. - **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub. - **MCP**: tools are declared in `.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`. - **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`. @@ -90,7 +90,7 @@ These principles come out of a full-repo audit and shape all new code: - **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger. - **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper. -- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip. +- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip — the server entities are the precedent: `npm run gen:entities`/`check:entities --workspace=server` (`server/scripts/generate-entities.ts`) regenerate them from the live migrated schema instead of hand-editing, and a parity test (PARITY-009) pins entity-vs-DB drift (delete rules, index names) at zero skips. - **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state. - **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents. - **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds. diff --git a/client/package.json b/client/package.json index c236755db..47202b784 100644 --- a/client/package.json +++ b/client/package.json @@ -35,74 +35,74 @@ "dependencies": { "@fontsource/bebas-neue": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0", - "@fontsource/geist-sans": "^5.2.5", + "@fontsource/geist-sans": "^5.3.0", "@fontsource/inter": "^5.3.0", "@fontsource/lora": "^5.3.0", "@fontsource/museomoderno": "^5.3.0", "@fontsource/playfair-display": "^5.3.0", - "@fontsource/poppins": "^5.2.7", + "@fontsource/poppins": "^5.3.0", "@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@maplibre/maplibre-gl-leaflet": "0.1.4", - "@simplewebauthn/browser": "^13.1.2", + "@simplewebauthn/browser": "^13.3.0", "@trek/shared": "*", - "axios": "^1.6.7", - "dexie": "^4.4.2", + "axios": "^1.20.0", + "dexie": "^4.4.6", "drag-drop-touch": "^1.3.1", - "heic-to": "^1.4.2", + "heic-to": "^1.5.2", "iso-3166-2": "^1.0.0", "leaflet": "^1.9.4", - "lucide-react": "^0.344.0", - "mapbox-gl": "^3.22.0", + "lucide-react": "^0.577.0", + "mapbox-gl": "^3.31.0", "maplibre-gl": "5.24.0", - "marked": "^18.0.0", + "marked": "^18.0.13", "plyr": "^3.8.4", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-dropzone": "^14.4.1", "react-leaflet": "^5.0.0", "react-leaflet-cluster": "^4.1.3", "react-markdown": "^10.1.0", - "react-router": "^7.18.2", + "react-router": "^7.18.4", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", "tz-lookup": "^6.1.25", - "zod": "^4.3.6", - "zustand": "^5.0.14" + "zod": "^4.6.5", + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.60.0", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "^6.10.0", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/leaflet": "^1.9.8", - "@types/node": "^25.9.3", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@types/leaflet": "^1.9.22", + "@types/node": "^25.9.8", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", - "autoprefixer": "^10.4.18", - "eslint": "^10.2.1", - "eslint-config-flat-gitignore": "^2.3.0", + "autoprefixer": "^10.6.1", + "eslint": "^10.11.0", + "eslint-config-flat-gitignore": "^2.4.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react-refresh": "^0.5.7", "fake-indexeddb": "^6.2.5", - "jsdom": "^29.0.1", - "msw": "^2.13.0", - "postcss": "^8.4.35", - "prettier": "^3.8.3", + "jsdom": "^29.1.1", + "msw": "^2.15.0", + "postcss": "^8.5.28", + "prettier": "^3.9.8", "prettier-plugin-organize-imports": "^4.3.0", - "prettier-plugin-tailwindcss": "^0.8.0", - "rollup-plugin-visualizer": "^7.0.1", + "prettier-plugin-tailwindcss": "^0.8.1", + "rollup-plugin-visualizer": "^7.1.1", "sharp": "^0.35.4", - "tailwindcss": "^3.4.1", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.2", - "vite": "8.1.0", + "tailwindcss": "^3.4.19", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", + "vite": "8.3.0", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.11" } diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx index d77fbabcc..a932a2c3c 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx @@ -290,8 +290,8 @@ describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => { const { baseElement } = render() expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument() - expect(baseElement.querySelector('svg.lucide-alert-triangle')).toHaveAttribute('stroke', 'var(--warning)') - expect(baseElement.querySelector('svg.lucide-check-circle2')).toBeNull() + expect(baseElement.querySelector('svg.lucide-triangle-alert')).toHaveAttribute('stroke', 'var(--warning)') + expect(baseElement.querySelector('svg.lucide-circle-check')).toBeNull() expect(baseElement.querySelector('.animate-spin')).toBeNull() }) @@ -300,8 +300,8 @@ describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => { const { baseElement } = render() // The theme's own success colour, so it follows light and dark. - expect(baseElement.querySelector('svg.lucide-check-circle2')).toHaveAttribute('stroke', 'var(--success)') - expect(baseElement.querySelector('svg.lucide-alert-triangle')).toBeNull() + expect(baseElement.querySelector('svg.lucide-circle-check')).toHaveAttribute('stroke', 'var(--success)') + expect(baseElement.querySelector('svg.lucide-triangle-alert')).toBeNull() }) }) diff --git a/client/src/components/Packing/PackingListPanel.test.tsx b/client/src/components/Packing/PackingListPanel.test.tsx index 9918b05a0..b6922ea22 100644 --- a/client/src/components/Packing/PackingListPanel.test.tsx +++ b/client/src/components/Packing/PackingListPanel.test.tsx @@ -335,7 +335,7 @@ describe('PackingListPanel', () => { const { container } = render(); // Open the MoreHorizontal context menu - const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); + const moreBtn = container.querySelector('svg.lucide-ellipsis')?.closest('button'); expect(moreBtn).toBeTruthy(); await user.click(moreBtn!); @@ -447,7 +447,7 @@ describe('PackingListPanel', () => { const { container } = render(); // Open the MoreHorizontal context menu - const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); + const moreBtn = container.querySelector('svg.lucide-ellipsis')?.closest('button'); expect(moreBtn).toBeTruthy(); await user.click(moreBtn!); @@ -529,7 +529,7 @@ describe('PackingListPanel', () => { const { container } = render(); // Open the category context menu - const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); + const moreBtn = container.querySelector('svg.lucide-ellipsis')?.closest('button'); expect(moreBtn).toBeTruthy(); await user.click(moreBtn!); @@ -1046,7 +1046,7 @@ describe('PackingListPanel', () => { const { container } = render(); // Open context menu and click Delete List - const moreBtn = container.querySelector('svg.lucide-more-horizontal')?.closest('button'); + const moreBtn = container.querySelector('svg.lucide-ellipsis')?.closest('button'); await user.click(moreBtn!); await user.click(await screen.findByText('Delete List')); diff --git a/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx b/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx index f8e848b9d..3bffc1810 100644 --- a/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx +++ b/client/src/components/Packing/PackingListPanelCategoryGroup.test.tsx @@ -48,7 +48,7 @@ function setup(overrides: Partial = {}) { /** The category header owns the first MoreHorizontal; item rows render their own. */ function openCategoryMenu(container: HTMLElement) { - const btn = container.querySelectorAll('svg.lucide-more-horizontal')[0].closest('button')! + const btn = container.querySelectorAll('svg.lucide-ellipsis')[0].closest('button')! fireEvent.click(btn) return btn } @@ -303,7 +303,7 @@ describe('KategorieGruppe — bulk actions', () => { it('FE-W5CAT-015a: the menu trigger highlights on hover', () => { const { container } = setup() - const trigger = container.querySelectorAll('svg.lucide-more-horizontal')[0].closest('button')! + const trigger = container.querySelectorAll('svg.lucide-ellipsis')[0].closest('button')! fireEvent.mouseEnter(trigger) expect(trigger.style.color).toBe('var(--text-secondary)') diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx index ecaa3fdc1..5dc9f7d54 100644 --- a/client/src/components/Planner/DayPlanSidebar.test.tsx +++ b/client/src/components/Planner/DayPlanSidebar.test.tsx @@ -2634,7 +2634,7 @@ describe('DayPlanSidebar', () => { // ferry from IJmuiden to the Port of Tyne in the evening. The slot written here is the // one every other reader takes from then on, the road trip included. const crossing = (newcastleAt: string | null, withAmsterdam = true) => { - const day = buildDay({ id: 10, date: '2026-10-06', title: 'Day 2' }) + const day = buildDay({ id: 10, date: '2025-10-06', title: 'Day 2' }) const amsterdam = buildPlace({ id: 1, name: 'Amsterdam', lat: 52.3731, lng: 4.8926 }) const newcastle = buildPlace({ id: 2, name: 'Newcastle', lat: 54.9783, lng: -1.6178, place_time: newcastleAt }) const stops = [ @@ -2643,7 +2643,7 @@ describe('DayPlanSidebar', () => { ].filter(a => withAmsterdam || a.id !== 11) const ferry = buildReservation({ id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 10, - reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00', + reservation_time: '2025-10-06T17:30', reservation_end_time: '2025-10-06T23:00', endpoints: [ { role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879, timezone: null, local_date: null, local_time: null }, { role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522, timezone: null, local_date: null, local_time: null }, @@ -2684,12 +2684,12 @@ describe('DayPlanSidebar', () => { // Worked out over Amsterdam alone, the clock closed the day at 1.5: behind the hotel // for the road trip, which then drove to Newcastle overland before the crossing. const { reservationsApi } = await import('../../api/client') - const day = buildDay({ id: 10, date: '2026-10-06', title: 'Day 2' }) + const day = buildDay({ id: 10, date: '2025-10-06', title: 'Day 2' }) const amsterdam = buildPlace({ id: 1, name: 'Amsterdam', lat: 52.3731, lng: 4.8926 }) const hotel = buildPlace({ id: 3, name: 'Hotel Newcastle', lat: 54.975, lng: -1.61 }) const ferry = buildReservation({ id: 69, type: 'ferry', title: 'IJmuiden to Newcastle', day_id: 10, - reservation_time: '2026-10-06T17:30', reservation_end_time: '2026-10-06T23:00', + reservation_time: '2025-10-06T17:30', reservation_end_time: '2025-10-06T23:00', endpoints: [ { role: 'from', sequence: 0, name: 'IJmuiden', code: null, lat: 52.4581, lng: 4.5879, timezone: null, local_date: null, local_time: null }, { role: 'to', sequence: 1, name: 'Port of Tyne', code: null, lat: 54.9925, lng: -1.4522, timezone: null, local_date: null, local_time: null }, @@ -4134,9 +4134,9 @@ describe('DayPlanSidebar', () => { // Day 2 checks out of a Munich hotel and into a Hamburg one, and nothing else is // planned on it. The booked night's own stop never reaches the list. const movingDays = [ - buildDay({ id: 10, date: '2026-11-03', title: 'Day 1' }), - buildDay({ id: 11, date: '2026-11-04', title: 'Day 2' }), - buildDay({ id: 12, date: '2026-11-05', title: 'Day 3' }), + buildDay({ id: 10, date: '2025-11-03', title: 'Day 1' }), + buildDay({ id: 11, date: '2025-11-04', title: 'Day 2' }), + buildDay({ id: 12, date: '2025-11-05', title: 'Day 3' }), ] const stays: Accommodation[] = [ { id: 1, trip_id: 1, start_day_id: 10, end_day_id: 11, place_lat: 48.137, place_lng: 11.575, place_name: 'Hotel A' }, @@ -4146,7 +4146,7 @@ describe('DayPlanSidebar', () => { ({ role, sequence: role === 'from' ? 0 : 1, name, code: null, lat, lng, timezone: null, local_date: null, local_time: null }) const flight = (located: boolean) => buildReservation({ id: 77, type: 'flight', title: 'LH 2078', day_id: 11, end_day_id: 11, - reservation_time: '2026-11-04T15:15:00', reservation_end_time: '2026-11-04T17:20:00', + reservation_time: '2025-11-04T15:15:00', reservation_end_time: '2025-11-04T17:20:00', endpoints: located ? [airport('from', 'MUC', 48.353, 11.786), airport('to', 'HAM', 53.63, 9.988)] : [], }) @@ -4184,7 +4184,7 @@ describe('DayPlanSidebar', () => { const user = userEvent.setup() const { generateGoogleMapsUrl } = await import('../Map/RouteCalculator') const openSpy = vi.spyOn(window, 'open').mockReturnValue(null) - const day = buildDay({ id: 10, date: '2026-11-03', title: 'Day 1' }) + const day = buildDay({ id: 10, date: '2025-11-03', title: 'Day 1' }) const assignments = { '10': [ buildAssignment({ id: 1, day_id: 10, order_index: 0, place: buildPlace({ id: 1, name: 'Elbphilharmonie', lat: 53.541, lng: 9.984 }) }), diff --git a/client/src/utils/iconMarkup.parity.test.ts b/client/src/utils/iconMarkup.parity.test.ts index bce518492..1f116a179 100644 --- a/client/src/utils/iconMarkup.parity.test.ts +++ b/client/src/utils/iconMarkup.parity.test.ts @@ -14,9 +14,12 @@ import { renderIconMarkup } from './iconMarkup' type IconComponent = Parameters[0] +// `Icon` is lucide's generic base component, not an icon: it renders whatever +// `iconNode` it is handed and throws without one. const ICONS = Object.entries(lucide).filter( ([name, value]) => /^[A-Z]/.test(name) && + name !== 'Icon' && typeof value === 'object' && value !== null && (value as { $$typeof?: symbol }).$$typeof === Symbol.for('react.forward_ref') diff --git a/client/tests/environment/jsdom-native-abort.ts b/client/tests/environment/jsdom-native-abort.ts index 1a16aaa8b..4dd697118 100644 --- a/client/tests/environment/jsdom-native-abort.ts +++ b/client/tests/environment/jsdom-native-abort.ts @@ -20,13 +20,13 @@ * jsdom setup runs, so vitest copies jsdom's Storage across. */ -import { builtinEnvironments } from 'vitest/environments'; +import { builtinEnvironments } from 'vitest/runtime'; const jsdomEnv = builtinEnvironments.jsdom; export default { name: 'jsdom-native-abort', - transformMode: 'web' as const, + viteEnvironment: 'client' as const, async setup(global: typeof globalThis, options: Record) { // Capture native AbortController/AbortSignal BEFORE jsdom patches them diff --git a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx index 383ef9e4d..5a657cbad 100644 --- a/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx +++ b/client/tests/unit/mobile/trip/MRoadtripRows.test.tsx @@ -160,7 +160,7 @@ describe('RtStopRow', () => { const { container } = render() expect(screen.getByText('+25 min')).toBeInTheDocument() - expect(container.querySelector('.lucide-alert-triangle')).not.toBeNull() + expect(container.querySelector('.lucide-triangle-alert')).not.toBeNull() }) it('FE-MOB-RTROW-011: writes a range warning as the distance driven since the last fill', () => { @@ -247,7 +247,7 @@ describe('RtStopRow', () => { const { container } = render() expect(screen.getByText('+20 min')).toBeInTheDocument() - expect(container.querySelector('.lucide-alert-triangle')).not.toBeNull() + expect(container.querySelector('.lucide-triangle-alert')).not.toBeNull() // It used to fall through to the night's word. expect(screen.queryByText('roadtrip.warn.overnight')).toBeNull() }) @@ -380,7 +380,7 @@ describe('RtRideRow', () => { const mark = screen.getByRole('img', { name: 'roadtrip.ride.lateHintFlight:LH 2020,13:20,12:20,Hamburg Airport,21:59' }) expect(mark).toHaveTextContent('roadtrip.ride.missed') expect(within(mark).getByText('roadtrip.ride.reachedAt:21:59')).toBeInTheDocument() - expect(mark.querySelector('.lucide-alert-triangle')).not.toBeNull() + expect(mark.querySelector('.lucide-triangle-alert')).not.toBeNull() expect(screen.getByRole('button').className).toContain('border-[color:var(--m-st-pending)]') }) diff --git a/client/vitest.config.ts b/client/vitest.config.ts index 531097d32..05dca0458 100644 --- a/client/vitest.config.ts +++ b/client/vitest.config.ts @@ -21,8 +21,12 @@ export default defineConfig({ testTimeout: 15000, hookTimeout: 15000, pool: 'forks', - silent: false, - reporters: ['verbose'], + // Console output is kept for failing tests and dropped for passing ones: a + // green run used to print ~30k lines of MSW and act() warnings. `default` + // prints one line per file and the full detail only for failures; on CI the + // github-actions reporter adds inline annotations on the PR. + silent: 'passed-only', + reporters: process.env.CI ? ['default', 'github-actions'] : ['default'], coverage: { provider: 'v8', reporter: ['lcov', 'text'], diff --git a/package-lock.json b/package-lock.json index 573569006..1d1f671eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,235 +30,104 @@ "dependencies": { "@fontsource/bebas-neue": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0", - "@fontsource/geist-sans": "^5.2.5", + "@fontsource/geist-sans": "^5.3.0", "@fontsource/inter": "^5.3.0", "@fontsource/lora": "^5.3.0", "@fontsource/museomoderno": "^5.3.0", "@fontsource/playfair-display": "^5.3.0", - "@fontsource/poppins": "^5.2.7", + "@fontsource/poppins": "^5.3.0", "@mapbox/mapbox-gl-rtl-text": "^0.4.0", "@maplibre/maplibre-gl-leaflet": "0.1.4", - "@simplewebauthn/browser": "^13.1.2", + "@simplewebauthn/browser": "^13.3.0", "@trek/shared": "*", - "axios": "^1.6.7", - "dexie": "^4.4.2", + "axios": "^1.20.0", + "dexie": "^4.4.6", "drag-drop-touch": "^1.3.1", - "heic-to": "^1.4.2", + "heic-to": "^1.5.2", "iso-3166-2": "^1.0.0", "leaflet": "^1.9.4", - "lucide-react": "^0.344.0", - "mapbox-gl": "^3.22.0", + "lucide-react": "^0.577.0", + "mapbox-gl": "^3.31.0", "maplibre-gl": "5.24.0", - "marked": "^18.0.0", + "marked": "^18.0.13", "plyr": "^3.8.4", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-dropzone": "^14.4.1", "react-leaflet": "^5.0.0", "react-leaflet-cluster": "^4.1.3", "react-markdown": "^10.1.0", - "react-router": "^7.18.2", + "react-router": "^7.18.4", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", "tz-lookup": "^6.1.25", - "zod": "^4.3.6", - "zustand": "^5.0.14" + "zod": "^4.6.5", + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.60.0", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.1", + "@playwright/test": "^1.63.0", + "@testing-library/jest-dom": "^6.10.0", + "@testing-library/react": "^16.3.3", + "@testing-library/user-event": "^14.6.7", "@trivago/prettier-plugin-sort-imports": "^6.0.2", - "@types/leaflet": "^1.9.8", - "@types/node": "^25.9.3", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@types/leaflet": "^1.9.22", + "@types/node": "^25.9.8", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", "@vitest/coverage-v8": "^4.1.11", - "autoprefixer": "^10.4.18", - "eslint": "^10.2.1", - "eslint-config-flat-gitignore": "^2.3.0", + "autoprefixer": "^10.6.1", + "eslint": "^10.11.0", + "eslint-config-flat-gitignore": "^2.4.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react-refresh": "^0.5.7", "fake-indexeddb": "^6.2.5", - "jsdom": "^29.0.1", - "msw": "^2.13.0", - "postcss": "^8.4.35", - "prettier": "^3.8.3", + "jsdom": "^29.1.1", + "msw": "^2.15.0", + "postcss": "^8.5.28", + "prettier": "^3.9.8", "prettier-plugin-organize-imports": "^4.3.0", - "prettier-plugin-tailwindcss": "^0.8.0", - "rollup-plugin-visualizer": "^7.0.1", + "prettier-plugin-tailwindcss": "^0.8.1", + "rollup-plugin-visualizer": "^7.1.1", "sharp": "^0.35.4", - "tailwindcss": "^3.4.1", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.2", - "vite": "8.1.0", + "tailwindcss": "^3.4.19", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", + "vite": "8.3.0", "vite-plugin-pwa": "^1.3.0", "vitest": "^4.1.11" } }, - "client/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "client/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "client/node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", - "license": "MIT", - "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - } - } - }, - "client/node_modules/set-cookie-parser": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", - "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", - "license": "MIT" - }, - "client/node_modules/vite": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", - "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "client/node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "~1.1.2", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" }, "peerDependenciesMeta": { - "@types/node": { + "@rolldown/plugin-babel": { "optional": true }, - "@vitejs/devtools": { + "babel-plugin-react-compiler": { "optional": true }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "client/node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { + "oxc-transform-react": { "optional": true } } @@ -304,6 +173,7 @@ "version": "5.1.11", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -320,6 +190,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", @@ -336,6 +207,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -345,6 +217,7 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, "license": "MIT" }, "node_modules/@aws-lite/client": { @@ -2369,10 +2242,71 @@ "specificity": "bin/cli.js" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", "funding": [ { "type": "github", @@ -2389,9 +2323,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", "funding": [ { "type": "github", @@ -2412,9 +2346,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz", - "integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", "funding": [ { "type": "github", @@ -2427,8 +2361,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.1" + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" }, "engines": { "node": ">=20.19.0" @@ -2461,9 +2395,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", - "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", "funding": [ { "type": "github", @@ -3071,9 +3005,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3097,9 +3031,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", "dev": true, "license": "MIT", "dependencies": { @@ -3109,7 +3043,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" }, @@ -3138,9 +3072,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3244,9 +3178,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", - "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -3293,9 +3227,9 @@ } }, "node_modules/@fontsource/geist-sans": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.2.5.tgz", - "integrity": "sha512-anllOHyJbElRs9fV15TeDRqAeb1IKm4bSknPl6ZMoyPTx1BBy7logudcUwpNjmQLkzn4Q0JGQLRCUKJYoyST6A==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.3.0.tgz", + "integrity": "sha512-AmG7cE1AvE4NHROPaOl1sN+a02cB5xy6pA/ZV76HXqWTCsJTV2SCBc9DTFHfJqnVOODm7HhXIYtUIb75NP5QXQ==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -3338,9 +3272,9 @@ } }, "node_modules/@fontsource/poppins": { - "version": "5.2.7", - "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.2.7.tgz", - "integrity": "sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.3.0.tgz", + "integrity": "sha512-cms1nM7U6SN8epIV1WMVOV8VsA645aSm3wHfzIQnkk188U341ThbJGSl2Sv54V29gnDbT2arMPfLzvVIIVCceQ==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" @@ -4688,6 +4622,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@levischuck/tiny-cbor": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", @@ -4839,6 +4780,268 @@ "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, + "node_modules/@mikro-orm/cli": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/cli/-/cli-7.2.1.tgz", + "integrity": "sha512-YNt8HKsb90awZqcionp1oZgL2ZLRyGl76x0lYdcfVkYkYY0zrNQs3ZZ76+6AS/FXSX5oEp6q94LCIiYwnr04jA==", + "license": "MIT", + "dependencies": { + "@mikro-orm/core": "7.2.1", + "mikro-orm": "7.2.1", + "yargs": "17.7.2" + }, + "bin": { + "mikro-orm": "cli.js" + }, + "engines": { + "node": ">= 22.17.0" + } + }, + "node_modules/@mikro-orm/cli/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@mikro-orm/cli/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@mikro-orm/cli/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mikro-orm/cli/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/@mikro-orm/cli/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mikro-orm/cli/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@mikro-orm/cli/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@mikro-orm/cli/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@mikro-orm/cli/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@mikro-orm/core": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/core/-/core-7.2.1.tgz", + "integrity": "sha512-+gtJoScbin/C3wPItSZkj5vlMTq/LphBLIxHyi5brCbeA6VDcYuoMopiqoFUMQxdhXXrZlKNIHXGrhb8r3biVg==", + "license": "MIT", + "engines": { + "node": ">= 22.17.0" + }, + "funding": { + "url": "https://github.com/sponsors/b4nan" + }, + "peerDependencies": { + "dataloader": "2.2.3" + }, + "peerDependenciesMeta": { + "dataloader": { + "optional": true + } + } + }, + "node_modules/@mikro-orm/entity-generator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/entity-generator/-/entity-generator-7.2.1.tgz", + "integrity": "sha512-SMzqZ9VsIAbbO4x4IEWgMmMoujKcUxJQMIFKbOPard0D+eNpy4wiTKnP44nLHLPJ8CtDqUPh7W9eC9K9JW3C9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mikro-orm/sql": "7.2.1" + }, + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, + "node_modules/@mikro-orm/migrations": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/migrations/-/migrations-7.2.1.tgz", + "integrity": "sha512-sxGcB7na0AkcREsD5U3xP+Psb/nhQFkx7/eY1Nsqq9MBs67O5RoCL97bn3lmgq6GwKZFbktMuuOGozq42+GaNg==", + "license": "MIT", + "dependencies": { + "@mikro-orm/sql": "7.2.1" + }, + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, + "node_modules/@mikro-orm/nestjs": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@mikro-orm/nestjs/-/nestjs-7.1.0.tgz", + "integrity": "sha512-S7UXOR4jYtl+lSl23GxQNoWaI3naf5WGUaWMjqZ1o+xcp+fha8une9Yu3qEizZNA+iulE4qA+yaB6sKnA67fbw==", + "license": "MIT", + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "^7.0.0 || ^7.0.0-dev.0", + "@nestjs/common": "^11.0.5 || ^12.0.0", + "@nestjs/core": "^11.0.5 || ^12.0.0", + "reflect-metadata": "^0.1.0 || ^0.2.0" + } + }, + "node_modules/@mikro-orm/postgresql": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/postgresql/-/postgresql-7.2.1.tgz", + "integrity": "sha512-PQ1qe4Bj/hQSHYJjR02l06FfEDwniAvncduwZSCLSKuaCUddHc5AN2Utj3yo4xRIVRY8pOa7DFitEQPBozTMJA==", + "license": "MIT", + "dependencies": { + "@mikro-orm/sql": "7.2.1", + "kysely": "0.29.6", + "pg": "8.23.0", + "pg-cursor": "2.22.0", + "postgres-array": "3.0.4", + "postgres-date": "2.1.0", + "postgres-interval": "4.1.0" + }, + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, + "node_modules/@mikro-orm/seeder": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/seeder/-/seeder-7.2.1.tgz", + "integrity": "sha512-aZtpFXNW8MnP4/e351SMLuVuarHK3g713puMjRcHmLN38j+VLVL/uSoDAk+/KTP2neuNOe+5I8ioC3fTJI/qDQ==", + "license": "MIT", + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, + "node_modules/@mikro-orm/sql": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/sql/-/sql-7.2.1.tgz", + "integrity": "sha512-3M9dQeUDLlvAT5VJr/KBGAk9C7xez+psVCnvNkAO4hRhwx2Wm7N1HkuyP4Bwj0MjkHVB6LYvhTw/V6pYsVM8lA==", + "license": "MIT", + "dependencies": { + "kysely": "0.29.6" + }, + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, + "node_modules/@mikro-orm/sqlite": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@mikro-orm/sqlite/-/sqlite-7.2.1.tgz", + "integrity": "sha512-+iAPGORAqLl+TcBF+JSLOfRq+PwTQG1NLts+AvN9D8C2czlbDpyjyeaAFSR+5wJlMEZ4wv2rdzt4jSS5T7x3XQ==", + "license": "MIT", + "dependencies": { + "@mikro-orm/sql": "7.2.1", + "better-sqlite3": "13.0.3", + "kysely": "0.29.6" + }, + "engines": { + "node": ">= 22.17.0" + }, + "peerDependencies": { + "@mikro-orm/core": "7.2.1" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -5400,9 +5603,9 @@ } }, "node_modules/@nestjs/common": { - "version": "11.1.27", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.27.tgz", - "integrity": "sha512-kEGSzqM2lWr4whh4Ubflw+oPZSEzxvRMu9WL+LveZploJWTjec5bBlCiRVlVzTPg2kIwBiLwWSvCCW7Wnin1gg==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.5.tgz", + "integrity": "sha512-x3LYEZnbGZMIeu9m60ws2+ZWzFAok9zGwjXZjcmdAeCLw6BYEjp9lreimuEpDIsLXK9bi6iYcJsYQGat9wq/rQ==", "license": "MIT", "dependencies": { "file-type": "21.3.4", @@ -5458,9 +5661,9 @@ } }, "node_modules/@nestjs/core": { - "version": "11.1.27", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.27.tgz", - "integrity": "sha512-K6DX7hcqmZdeXkv7tsPakKBRCgqL19a4mtbX4FluY0hWtFdtPKp6lbe+lb8gWPfvLdbOWr/CPScn7BSjBX+Ecg==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.2.5.tgz", + "integrity": "sha512-ZgF8aitL7h8VPyVQ+vlUDrSVzbR7AojCv9Je9JqJVfHw3TtD3OOQ82RsCKrxfx09xpoveRr7UBHPA/d24bu2lA==", "license": "MIT", "dependencies": { "fast-safe-stringify": "2.1.1", @@ -5517,14 +5720,14 @@ } }, "node_modules/@nestjs/platform-express": { - "version": "11.1.27", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.27.tgz", - "integrity": "sha512-0ZFhz6H6EdGh4xQVbUNwjoAwBuz73P7FvUAl67h9CTdMqQlJDaQYJApBv8pKfVZ1fGjMCbl0m9DcC6pXaZPWSQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.5.tgz", + "integrity": "sha512-R3LSHqgPQo7ZTdYqA39lvdHS2RBnVJUePKIpL+cr1oB3HtYj9qPdhdmS307eLDc1VeTPFXAnSuVfcG9lMDEFSw==", "license": "MIT", "dependencies": { "cors": "2.8.6", "express": "5.2.1", - "multer": "2.1.1", + "multer": "2.2.0", "path-to-regexp": "8.4.2", "tslib": "2.8.1" }, @@ -5830,13 +6033,13 @@ } }, "node_modules/@nestjs/platform-ws": { - "version": "11.1.29", - "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-11.1.29.tgz", - "integrity": "sha512-AYs3VZ5PxcDLiyGfvxsAS8DP9d7q+jxCKoWfMdl9RQLz69WAzSX2x+FgWNupTHt19zf8rmFXt2hvGd2tBUROAQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/platform-ws/-/platform-ws-11.2.5.tgz", + "integrity": "sha512-RttHEJN03qF7PppKfBtg66125QUjt9LhdWSpsQY8BnRbXLYVTR1g3FyCL27Q6UAd5nWS8vOkghRcRiDLjFX+fg==", "license": "MIT", "dependencies": { "tslib": "2.8.1", - "ws": "8.21.1" + "ws": "8.21.2" }, "funding": { "type": "opencollective", @@ -5848,6 +6051,27 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/platform-ws/node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@nestjs/schedule": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", @@ -5917,9 +6141,9 @@ } }, "node_modules/@nestjs/testing": { - "version": "11.1.27", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.27.tgz", - "integrity": "sha512-I35po13UHZZeGenLWJ3QYwh77RsLau5RcFKWBZ4waVHeARpwjtC7v7n7lGh98swLQdGmZgTnbvKaZ0B5dsUIKA==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.5.tgz", + "integrity": "sha512-6PEjtGlqwpXDUDsiuSmE7EoRGHs27YAmHctgLMXfeb2oyWQsg5a/8+vQTnY/oF4c6wSRPM9x5OPtQ/JCuVNg9g==", "dev": true, "license": "MIT", "dependencies": { @@ -5945,9 +6169,9 @@ } }, "node_modules/@nestjs/websockets": { - "version": "11.1.29", - "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.29.tgz", - "integrity": "sha512-Mg0sNK2k+PpamcJpDy9ZP24ZvM08xB6AZs061Kb6dhTesGxr6zrxlbZ7+40r5xwuxD/PN8LM2tSRKf5ojbFNDQ==", + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.2.5.tgz", + "integrity": "sha512-KSTpTWKFSrBYxxXyclDg3ir9EzPWUU8iuKK4VFaVno2L+kwZcZzsKmnXrcMnmx8Sl2glgqXQg26Esu8hbo6a0Q==", "license": "MIT", "dependencies": { "iterare": "1.2.1", @@ -5981,9 +6205,9 @@ } }, "node_modules/@nodable/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", "funding": [ { "type": "github", @@ -6307,19 +6531,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", - "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.0" + "playwright": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@quansync/fs": { @@ -6346,6 +6570,23 @@ "react-dom": "^19.0.0" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz", @@ -7112,9 +7353,9 @@ "license": "MIT" }, "node_modules/@simplewebauthn/server": { - "version": "13.3.2", - "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.2.tgz", - "integrity": "sha512-KEDhfcGP1PAKRVSDjA3npTQFqS2b/srm+ipoNBNHdkzrHAlaRQUTE+a5f4ywsx6thxAw1NU2rYcLEY1949RGbQ==", + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.3.tgz", + "integrity": "sha512-LelX/lcy5cjc15A86i/aNxHhB5eU7dd20QsbP0VLAf9e38+SLlsnqCCyecx3xqfGofhmX05h1J9fKRYWxw+luA==", "license": "MIT", "dependencies": { "@hexagon/base64": "^1.1.27", @@ -7225,15 +7466,15 @@ "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.41.tgz", - "integrity": "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.16.2.tgz", + "integrity": "sha512-95I4kiSMeveI/Mhi+tE4fiWcWLUMfzfKrk0jtr8LRMqHgOgq+xHS+zExkDqoO4b5OeeuXHMWVdD5MeP3X6sULw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.26" + "@swc/types": "^0.1.28" }, "engines": { "node": ">=10" @@ -7243,18 +7484,18 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.15.41", - "@swc/core-darwin-x64": "1.15.41", - "@swc/core-linux-arm-gnueabihf": "1.15.41", - "@swc/core-linux-arm64-gnu": "1.15.41", - "@swc/core-linux-arm64-musl": "1.15.41", - "@swc/core-linux-ppc64-gnu": "1.15.41", - "@swc/core-linux-s390x-gnu": "1.15.41", - "@swc/core-linux-x64-gnu": "1.15.41", - "@swc/core-linux-x64-musl": "1.15.41", - "@swc/core-win32-arm64-msvc": "1.15.41", - "@swc/core-win32-ia32-msvc": "1.15.41", - "@swc/core-win32-x64-msvc": "1.15.41" + "@swc/core-darwin-arm64": "1.16.2", + "@swc/core-darwin-x64": "1.16.2", + "@swc/core-linux-arm-gnueabihf": "1.16.2", + "@swc/core-linux-arm64-gnu": "1.16.2", + "@swc/core-linux-arm64-musl": "1.16.2", + "@swc/core-linux-ppc64-gnu": "1.16.2", + "@swc/core-linux-s390x-gnu": "1.16.2", + "@swc/core-linux-x64-gnu": "1.16.2", + "@swc/core-linux-x64-musl": "1.16.2", + "@swc/core-win32-arm64-msvc": "1.16.2", + "@swc/core-win32-ia32-msvc": "1.16.2", + "@swc/core-win32-x64-msvc": "1.16.2" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -7266,9 +7507,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.41.tgz", - "integrity": "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.2.tgz", + "integrity": "sha512-i/j0HNbnn79qnTVPicvay92Nark8fW8NQqn1e2mGERjUXNpBV0+SwQxlRpk2zBhn6laJ8PDI6Kn1nHZhnz3LCA==", "cpu": [ "arm64" ], @@ -7283,9 +7524,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.41.tgz", - "integrity": "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.16.2.tgz", + "integrity": "sha512-HrwqHyEyHVXO3qTk8EkNK7/b6sOZSEoNh+pot6RdE5x0LbNqfo8LtJUvi3UTXr+5ja/o5HbJdW80eCXo+NjbiA==", "cpu": [ "x64" ], @@ -7300,9 +7541,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.41.tgz", - "integrity": "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.2.tgz", + "integrity": "sha512-MdXi83Z/gGp1LIrg+h7HKxiul/z/Bty/ZJSvYAFqDl9zteC1XLSAZdScquKtXPp50rdyXqritTDCqQBhwVfZKA==", "cpu": [ "arm" ], @@ -7317,13 +7558,16 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.41.tgz", - "integrity": "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.2.tgz", + "integrity": "sha512-/jcTmK6Ktz3owM3YtiKvjofV6p3VpHnYzTIrOGwDIOsDigRAAVuZ8east33wYO/7UTdKYFlyHNnJNT0WJqOA3Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7334,13 +7578,16 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.41.tgz", - "integrity": "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.2.tgz", + "integrity": "sha512-4gFarKaFnlJTSlJYKmMhV4u+3YE4uYfiydpBoYjmgQhCf9lAieOq+WilZaK9vVSHeqLuQpTEiGULZqAdsRX5Dw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7351,13 +7598,16 @@ } }, "node_modules/@swc/core-linux-ppc64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.41.tgz", - "integrity": "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.2.tgz", + "integrity": "sha512-syqSLGd6KlZ1PciNzs6bIUlhOuFztZufebOHaERjc4N4SqNZxyqYd4I+jj/EfOYnpe0kNjccn9HJLN1p5dz3+w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7368,13 +7618,16 @@ } }, "node_modules/@swc/core-linux-s390x-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.41.tgz", - "integrity": "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.2.tgz", + "integrity": "sha512-ZBBLK+ewGyXLzWeMS7wbKtWBdnif6etn7xvPY/iOfbdsjX/+bgkp1pQt2lWF2wlu2hXYZuhJ/tHZE/QR8/apzg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7385,13 +7638,16 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.41.tgz", - "integrity": "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.2.tgz", + "integrity": "sha512-LyHJgxCA4Tje0ysBMbEb0tt/ie8kgUKoFE3JAKFhpevmTmhYEoC0H9s47WuDsqiFckF1ITUguZIXJG6K5e0dvg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7402,13 +7658,16 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.41.tgz", - "integrity": "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.2.tgz", + "integrity": "sha512-PghXJlVM1cgtLfNUR1vxFo1z+PDRAe8cWAJlZZ7spmeiN7BospGXg/MHUg7oNSgwSX7Zo//YKv9P5yD9apsFJQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -7419,9 +7678,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.41.tgz", - "integrity": "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.2.tgz", + "integrity": "sha512-StTOSefYBxemvNYYUI3UmO1a8y+hSPjjfHogC2TEHL+Z1PlEBim/XtLas5rS04jAzT9RrNmbtX911SZ42H9jSQ==", "cpu": [ "arm64" ], @@ -7436,9 +7695,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.41.tgz", - "integrity": "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.2.tgz", + "integrity": "sha512-fycER209DYIzsibpTMC+chND05OfOjgztWL9U8OE6/uUlsOUZH3eh98isBLEnOymYUhlJLEt5++W1+KL/FOh5Q==", "cpu": [ "ia32" ], @@ -7453,9 +7712,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.15.41", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.41.tgz", - "integrity": "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.2.tgz", + "integrity": "sha512-cSd1z6ivSrJPVr+moVwOHWjeKy6TpO4/Shwcv5KCrKYXCccxwh4pRy1C3fDioNx2PF1jPZWHKZjtXt+Be9VbaQ==", "cpu": [ "x64" ], @@ -7477,9 +7736,9 @@ "license": "Apache-2.0" }, "node_modules/@swc/types": { - "version": "0.1.27", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.27.tgz", - "integrity": "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==", + "version": "0.1.28", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.28.tgz", + "integrity": "sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -7508,9 +7767,10 @@ } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.10.0.tgz", + "integrity": "sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==", + "deprecated": "Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0.", "dev": true, "license": "MIT", "dependencies": { @@ -7522,9 +7782,12 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11" } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -7535,9 +7798,9 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dev": true, "license": "MIT", "dependencies": { @@ -7563,9 +7826,9 @@ } }, "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", "dev": true, "license": "MIT", "engines": { @@ -7870,13 +8133,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jsesc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", - "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -7896,9 +8152,9 @@ } }, "node_modules/@types/leaflet": { - "version": "1.9.21", - "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", - "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", "license": "MIT", "dependencies": { "@types/geojson": "*" @@ -7940,9 +8196,9 @@ "license": "MIT" }, "node_modules/@types/multer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", - "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", "dev": true, "license": "MIT", "dependencies": { @@ -7950,9 +8206,9 @@ } }, "node_modules/@types/node": { - "version": "25.9.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", - "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", + "version": "25.9.8", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.8.tgz", + "integrity": "sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw==", "dev": true, "license": "MIT", "dependencies": { @@ -7960,9 +8216,9 @@ } }, "node_modules/@types/nodemailer": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", - "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.2.tgz", + "integrity": "sha512-c7M5ox8p0nEOfbJ2E9Qcmt8/QCu/VzsiAhzBiZbvky2PhKZDHpKB3sQHBM5MEZNkCfBOaS6S9//AGPRTNB7IaA==", "dev": true, "license": "MIT", "dependencies": { @@ -7994,22 +8250,22 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.2.0" + "@types/react": "^19.3.0" } }, "node_modules/@types/readdir-glob": { @@ -8030,9 +8286,9 @@ "license": "MIT" }, "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", "dev": true, "license": "MIT" }, @@ -8151,17 +8407,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", - "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/type-utils": "8.61.1", - "@typescript-eslint/utils": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -8174,15 +8430,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.1", + "@typescript-eslint/parser": "^8.70.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.9", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.9.tgz", + "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", "dev": true, "license": "MIT", "engines": { @@ -8190,16 +8446,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", - "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3" }, "engines": { @@ -8215,14 +8471,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", - "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.1", - "@typescript-eslint/types": "^8.61.1", + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", "debug": "^4.4.3" }, "engines": { @@ -8237,14 +8493,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", - "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1" + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8255,9 +8511,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", - "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", "dev": true, "license": "MIT", "engines": { @@ -8272,15 +8528,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", - "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -8297,9 +8553,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", - "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", "dev": true, "license": "MIT", "engines": { @@ -8311,16 +8567,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", - "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.1", - "@typescript-eslint/tsconfig-utils": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/visitor-keys": "8.61.1", + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -8349,9 +8605,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8362,13 +8618,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -8378,16 +8634,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", - "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.1", - "@typescript-eslint/types": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1" + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8402,13 +8658,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", - "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/types": "8.70.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8425,32 +8681,6 @@ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "license": "ISC" }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, "node_modules/@vitest/coverage-istanbul": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/coverage-istanbul/-/coverage-istanbul-4.1.11.tgz", @@ -8620,6 +8850,385 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@yuku-codegen/binding-android-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-android-arm64/-/binding-android-arm64-0.10.2.tgz", + "integrity": "sha512-Oc2KInVkPfUjEB4PeV6X0NvIoyYTzDe/WmqFrTwBqKs6YKnz4A7XwWQvJTb7M0rLz9a6WgpUUiiS5QKaCi4O/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@yuku-codegen/binding-darwin-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-arm64/-/binding-darwin-arm64-0.10.2.tgz", + "integrity": "sha512-3H7eNPIHJndUJXZ4QUGBPq1zC6RGcUZfm7bymJes+/N7wTVWUn1bxZ9JcozYHpkWQNm9f23G8YWbaHD9aYH72A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-codegen/binding-darwin-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-darwin-x64/-/binding-darwin-x64-0.10.2.tgz", + "integrity": "sha512-AJOUpR2s3LF9AWiiub/Uyc/UoXNTWcq8hK3cVI6fUSXtwh34dG21J6hRuNlO6JVJFTo4o3ScVvZOrh/YFfUEAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-codegen/binding-freebsd-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-freebsd-x64/-/binding-freebsd-x64-0.10.2.tgz", + "integrity": "sha512-ms7DcZu87u5CiK/wMwvpKAvAmtaqKjCQIXNweMLypG6qbuiaOMQf1jpbB5+j46xBcgCovo8TQMcv0bCQLKIL9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.10.2.tgz", + "integrity": "sha512-xJDpYAsKV5+eaiBhTYl05fvT6sst4PsCeuIFMRu9b0/WCSrNQPfCDtAQ5/MS6xNpsdujdZEPR+gmfWk3ZG5i3A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm-musl/-/binding-linux-arm-musl-0.10.2.tgz", + "integrity": "sha512-qfTkgd7AEx61l4K9VtXWCGTxc/fmXJ4ZheDz3i+/AAJUtg4sa0bPrc0VBxPggB/rayR8Z3+JfrBItuyanBJJ9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm64-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.10.2.tgz", + "integrity": "sha512-4e6Mifm/4UdjtU3D8mATIrvgP+xEiH8xtQOjd0zpd72XKWT7ug3sdyhq2utkkZFP6BvYp7SgZrI+aR4VeIOHdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-arm64-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.10.2.tgz", + "integrity": "sha512-RdsJrUfDYFVV3JOmWFUWwSu31fa1APn12xDeIKxgl/YWxrabwtxsDBNjF2561c7tmcbiewdCUvngqRs8AyGVLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-x64-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.10.2.tgz", + "integrity": "sha512-mVzWimEPPreaPyXIUvxsHoJzvO7ckZ5j0LgQFHz04zQIRwt9A9T2hA7K5/jYjJKkMxWG/U2VjhGNwVhtyU+uqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-linux-x64-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-linux-x64-musl/-/binding-linux-x64-musl-0.10.2.tgz", + "integrity": "sha512-Y77fyISurmr2mvO1yeq3u+x/WJbACgPR4w+lzEVx30ViCxZzIGrZPRN1yEdZ9xUNPNsIO5thBHIdRNG+UGUG+Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-codegen/binding-win32-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-arm64/-/binding-win32-arm64-0.10.2.tgz", + "integrity": "sha512-1+tGLyG0u5YYy0lev4ck7/0sd8xJ9SZde0dLut7qLDPZV5WVTV8x6OxNXUne/PGuHZyO1vK+txPQzzHOyXHGSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-codegen/binding-win32-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-codegen/binding-win32-x64/-/binding-win32-x64-0.10.2.tgz", + "integrity": "sha512-8a2SExRohRbwC0MY4VpOF0/RSckrJ2ASZqAor5/RYSJJaeadR93n10gZzcKT6pY9ukiSOIZCRKVRD2tH73T0qA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-parser/binding-android-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-android-arm64/-/binding-android-arm64-0.10.2.tgz", + "integrity": "sha512-2VPBU9fRGRAQ2xPAvghnec3oou5Nrxm2Bezhf+13UMspAxQSkF/32r+ySKXSwIPA5/RivumDJDxAxc301Sgicw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@yuku-parser/binding-darwin-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.10.2.tgz", + "integrity": "sha512-LD+PMZE51tCYTOss5HBkm3/AE39MvcMDBfWJx7A4yDcjfNbAQDnHZKtzSOuqrswx+TQHY0ws5xn6+fWOwtmfBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-parser/binding-darwin-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-darwin-x64/-/binding-darwin-x64-0.10.2.tgz", + "integrity": "sha512-mmZ8cND+AoIIMRERyMinlg5ApHxP23B9jH2B5wT7T+dliPa9rubLxneB/SUjFwyUjGaFnB5G7t4YvpfbO5zbkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@yuku-parser/binding-freebsd-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.10.2.tgz", + "integrity": "sha512-gVIjaaIddbRfAhHlC8N809wQWml7mxfSVnzaLtzGXObTeFTPAg/YVnQXt1UOhPM1ah5eG/8Y0RUlNpB4GVe7eQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-gnu/-/binding-linux-arm-gnu-0.10.2.tgz", + "integrity": "sha512-q/XPPQQAPdlw05aPj30ygBhekmQryGOwxVraBgApjKK8yY1kNQgqq6XCYLF1WHSee+lhxclrTO7W0/bt7dR1GA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm-musl/-/binding-linux-arm-musl-0.10.2.tgz", + "integrity": "sha512-A+Cb0I1hFF4wilTQpWs79k1aNnj4B2tkrHx3zsuUNF9BtVY2zPZ4yeQEM/zjXrS/qJlNMZVK9NrWJjwdpnTrfg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm64-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.10.2.tgz", + "integrity": "sha512-aGNSzqIqqphFwAdIFwVvKIyXD1Iy3CxrEJVIZAT87Ecyi4vCmmDD2v2l9h+Gd3/wSy3JZlOI792LJbKAjyy9rQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-arm64-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.10.2.tgz", + "integrity": "sha512-Ddl1sF0rtuCXyHGSOV6dcmdx+ETv1iD+IVFqQuOjzkJZWclxJxdBWX6ZJMRtTiGqGUTbqLEKiTXxpR/Bvqk+2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-x64-gnu": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.10.2.tgz", + "integrity": "sha512-/nlcpR6IF5U+0m5L9wvAIXeQa2DT+BXuvqKDsTcOTlcc0B4dHNyqE5hTXsS4hAyimpy2ZK8A1zMNF5hTrjg2cg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-linux-x64-musl": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.10.2.tgz", + "integrity": "sha512-GX80dxTQD/M/OryyuxJcFzFENzry3cDFPvCFTyWogNWQH3fSHfMrNfpwpl1YzMos1eV9NygK5GSDG7VArQlb4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@yuku-parser/binding-win32-arm64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-arm64/-/binding-win32-arm64-0.10.2.tgz", + "integrity": "sha512-agePQBV4VHewiGU0ACSjscZ/hJd283f9JfAF19Gf1rI5+wy5tTgx4GS36i0b3xim15AQ4RCpDRosEvhLZ4zAOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-parser/binding-win32-x64": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-parser/binding-win32-x64/-/binding-win32-x64-0.10.2.tgz", + "integrity": "sha512-s8//CMpgL5+y1lvDCdyh1rwGI5+ytDJywFJRe1vnhI7n0j+caxshNucurikYLLVeQ2SYFex6aPrzgQxkabBQRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@yuku-toolchain/types": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@yuku-toolchain/types/-/types-0.10.2.tgz", + "integrity": "sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q==", + "dev": true, + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -8727,16 +9336,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansis": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", - "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - } - }, "node_modules/any-base": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/any-base/-/any-base-1.1.0.tgz", @@ -8916,74 +9515,6 @@ "node": ">=12" } }, - "node_modules/ast-kit": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-3.0.0.tgz", - "integrity": "sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^8.0.0", - "estree-walker": "^3.0.3", - "pathe": "^2.0.3" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - } - }, - "node_modules/ast-kit/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/ast-kit/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz", - "integrity": "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/ast-kit/node_modules/@babel/parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0.tgz", - "integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/ast-kit/node_modules/@babel/types": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz", - "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, "node_modules/ast-v8-to-istanbul": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", @@ -9045,9 +9576,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", + "integrity": "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==", "dev": true, "funding": [ { @@ -9065,8 +9596,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -9113,13 +9644,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", + "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -9299,9 +9830,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.22", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz", - "integrity": "sha512-pWc4w51fBFd7mav43/zKRC+RI6f4yfzQoVlfvE8dECePyfkn1bzLp01Fj0QACcyCZyFhiEMyD2qScfKRWgWibA==", + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9317,10 +9848,23 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -9339,16 +9883,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/birpc": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-4.0.0.tgz", - "integrity": "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -9362,9 +9896,9 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "version": "1.20.8", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -9375,7 +9909,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -9423,9 +9957,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -9445,9 +9979,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", "dev": true, "funding": [ { @@ -9465,11 +9999,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -9497,6 +10031,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, "license": "MIT" }, "node_modules/bundle-name": { @@ -9545,6 +10080,30 @@ "node": ">=20.19.0" } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -9877,14 +10436,15 @@ } }, "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", + "destroy": "1.2.0", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", @@ -9892,6 +10452,10 @@ }, "engines": { "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/compression/node_modules/debug": { @@ -9925,21 +10489,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, "node_modules/concurrently": { "version": "10.0.4", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.4.tgz", @@ -10462,9 +11011,9 @@ } }, "node_modules/dexie": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.4.tgz", - "integrity": "sha512-jIwsYI8Os2hgnqc6O49YwFDKGc5v5QjGx0wPVp543ip1F53VFAKMLthV2pQosQcVTv3eAskTWYspOx195PM0FQ==", + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.6.tgz", + "integrity": "sha512-hJP/BO6mjB+tX6hToIO1kmxYLNmun90wYbfcoAoLpKEyDYal/k33dg0TAfoUe2TDsbbLoVIzljJ3BN2UbR5EOg==", "license": "Apache-2.0" }, "node_modules/dezalgo": { @@ -10507,9 +11056,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -10666,9 +11215,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.420", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", - "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", + "version": "1.5.433", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.433.tgz", + "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", "dev": true, "license": "ISC" }, @@ -10915,7 +11464,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -10941,9 +11489,9 @@ } }, "node_modules/eslint": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", - "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -10953,9 +11501,9 @@ "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.2", + "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -10970,14 +11518,14 @@ "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", + "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -11000,13 +11548,13 @@ } }, "node_modules/eslint-config-flat-gitignore": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/eslint-config-flat-gitignore/-/eslint-config-flat-gitignore-2.3.0.tgz", - "integrity": "sha512-bg4ZLGgoARg1naWfsINUUb/52Ksw/K22K+T16D38Y8v+/sGwwIYrGvH/JBjOin+RQtxxC9tzNNiy4shnGtGyyQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/eslint-config-flat-gitignore/-/eslint-config-flat-gitignore-2.4.0.tgz", + "integrity": "sha512-JhYC+V7qEDiCDsbJcVP8fxNc62fk4+i91YLP/YvmVHlkaQ8Xo99olYN8MO5COdxl7onGlsrfZVONe5NYsZP1UA==", "dev": true, "license": "MIT", "dependencies": { - "@eslint/compat": "^2.0.3" + "@eslint/compat": "^2.1.0" }, "funding": { "url": "https://github.com/sponsors/antfu" @@ -11083,9 +11631,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", - "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -11164,6 +11712,28 @@ "node": "20 || >=22" } }, + "node_modules/eslint/node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/eslint/node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, "node_modules/eslint/node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -11345,9 +11915,9 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -11369,9 +11939,9 @@ "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", + "path-to-regexp": "~0.1.13", "proxy-addr": "~2.0.7", - "qs": "~6.15.1", + "qs": "~6.16.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", @@ -11574,9 +12144,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.2.tgz", - "integrity": "sha512-DYPkXnVSJHAGAkSBeVYhEo/seIpz2SLr9OQcX7m6lXaX3gvoB+DCKzFdZIEhZGI3I1DUhObBAUOT/v2xfoXz/w==", + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.1.tgz", + "integrity": "sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==", "funding": [ { "type": "github", @@ -11585,17 +12155,32 @@ ], "license": "MIT", "dependencies": { - "@nodable/entities": "^2.2.0", + "@nodable/entities": "^3.0.0", "fast-xml-builder": "^1.2.0", - "is-unsafe": "^1.0.1", - "path-expression-matcher": "^1.5.0", - "strnum": "^2.4.0", - "xml-naming": "^0.1.0" + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, + "node_modules/fast-xml-parser/node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -11876,9 +12461,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -12056,22 +12641,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-tsconfig": { - "version": "5.0.0-beta.5", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.5.tgz", - "integrity": "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "engines": { - "node": ">=20.20.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/gifwrap": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/gifwrap/-/gifwrap-0.10.1.tgz", @@ -12270,6 +12839,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -12355,9 +12937,9 @@ "license": "LGPL-3.0" }, "node_modules/helmet": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", - "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -12399,6 +12981,13 @@ "dev": true, "license": "MIT" }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -12621,9 +13210,9 @@ } }, "node_modules/ip-address": { - "version": "10.7.2", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", - "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" @@ -13186,9 +13775,9 @@ } }, "node_modules/is-unsafe": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", - "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==", "funding": [ { "type": "github", @@ -13278,16 +13867,119 @@ "integrity": "sha512-xLAazfKZzwlsg/Zz/GQGQk3jJez5/2ORrjD3TjSuqz/arMht/xTK49c0GOE3afO/gEd9tHtBVVlfBla01unUng==" }, "node_modules/isomorphic-dompurify": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.17.0.tgz", - "integrity": "sha512-++PY22SYWZv/kTWOr5WQ8rNznkIKj6I/SDUVZnXXM67EhRO7ScVLDdZYNxNgEsPAOA5sfpGBcqnnbLx8WJHO1g==", + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.23.0.tgz", + "integrity": "sha512-RuYFgdNApoTlR3QxVUyWdS/1ATpLEWEVg3H2tMImbfr6HkYPnuW1sdEtW4PRGlFhp04VbQQjcjXnucHYpXT44A==", + "deprecated": "Raised the minimum Node.js version (breaking) without a major bump. Use 4.x for the same code with correct semver, or pin 3.19.0 for Node < 22.22.2.", "license": "MIT", "dependencies": { - "dompurify": "^3.4.10", - "jsdom": "^29.1.1" + "dompurify": "^3.4.12", + "jsdom": "^30.0.0" }, "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@asamuzakjp/css-color": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz", + "integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==", + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.2.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.1.tgz", + "integrity": "sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/jsdom": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz", + "integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.1.2", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/isomorphic-dompurify/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/isomorphic-dompurify/node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/isomorphic-dompurify/node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" } }, "node_modules/istanbul-lib-coverage": { @@ -13488,6 +14180,7 @@ "version": "29.1.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^5.1.11", @@ -13528,6 +14221,7 @@ "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -13671,6 +14365,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/kysely": { + "version": "0.29.6", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.6.tgz", + "integrity": "sha512-hHaB8C/rfzDDtr/t8YZwxAuPJTT0zHyaPoVzcXwDYhYNAgH/4sIfVhi/XLLIY+bL/FqaIJnjATDbi8ObSELmxg==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -13759,9 +14462,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -13775,23 +14478,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -13810,9 +14513,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -13831,9 +14534,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -13852,9 +14555,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -13873,9 +14576,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -13894,13 +14597,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13915,13 +14621,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13936,13 +14645,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13957,13 +14669,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13978,9 +14693,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -13999,9 +14714,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -14159,13 +14874,6 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true, - "license": "MIT" - }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -14199,12 +14907,12 @@ } }, "node_modules/lucide-react": { - "version": "0.344.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz", - "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/luxon": { @@ -14266,15 +14974,14 @@ } }, "node_modules/mapbox-gl": { - "version": "3.25.0", - "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.25.0.tgz", - "integrity": "sha512-I+9oSkJVFu51xIAAQcjKophFe6zVAGWROHsszeRhX9E1OXEizgPH+8BkF7GaxmmLd9FbADdEfvULF8NxEFcB5w==", + "version": "3.31.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.31.0.tgz", + "integrity": "sha512-7i25NyCPW5jnqsqJq3irgBBEoVTJgfZqTJJS446Q3ZuxyAb5Om75UIcxYIo38oz9eDswnBXr0riz+M/C8V7g3Q==", "license": "SEE LICENSE IN LICENSE.txt", "workspaces": [ "src/style-spec", "plugins/mapbox-gl-pmtiles-provider", - "test/build/vite", - "test/build/webpack", + "test/bundlers/*", "test/build/typings" ] }, @@ -14323,9 +15030,9 @@ } }, "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "version": "18.0.13", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.13.tgz", + "integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -15259,6 +15966,15 @@ "node": ">=8.6" } }, + "node_modules/mikro-orm": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/mikro-orm/-/mikro-orm-7.2.1.tgz", + "integrity": "sha512-IUUYgO5T+S67sw1Xp5r3vL0JeYnkampUIRJaHG6AzTcjujRbyRUOEc6NVAIsojKdXq9WbWRU0hI6ivUv9qSDLA==", + "license": "MIT", + "engines": { + "node": ">= 22.17.0" + } + }, "node_modules/mime": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", @@ -15344,9 +16060,9 @@ "license": "MIT" }, "node_modules/msw": { - "version": "2.14.6", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.14.6.tgz", - "integrity": "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg==", + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.15.0.tgz", + "integrity": "sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -15533,14 +16249,13 @@ } }, "node_modules/multer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", - "integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz", + "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.6.0", - "concat-stream": "^2.0.0", "type-is": "^1.6.18" }, "engines": { @@ -15580,9 +16295,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.13", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", - "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -15650,9 +16365,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.54", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", - "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", "dev": true, "license": "MIT", "engines": { @@ -15660,9 +16375,9 @@ } }, "node_modules/nodemailer": { - "version": "10.0.13", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.13.tgz", - "integrity": "sha512-SzG86OlvcW/NNhUFC6uROMwRTL4n7MswfQqC/T8mhkmnY1YVa23zUEMYi4ijSeXSl9GLz9ZeTJDUatEDuY5FeQ==", + "version": "10.0.14", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.14.tgz", + "integrity": "sha512-eJoLFKg55fesSlzUekmfOv/SBXh4KUYT8oiQc2RLP0tfG0rbNMJBK7wgHJwXTJwqyKlSJMz9UzDZ4v+aHEvo8g==", "license": "MIT-0", "engines": { "node": ">=20.0.0" @@ -15830,9 +16545,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -16116,9 +16831,9 @@ } }, "node_modules/path-expression-matcher": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", - "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", "funding": [ { "type": "github", @@ -16234,6 +16949,134 @@ "@napi-rs/canvas": "^0.1.80" } }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-cursor": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.22.0.tgz", + "integrity": "sha512-knzXLKqarTjOvb3qDSW0JiGsazmxwEKXrqHfWRte7XUsOYccQRafn3BLnQobWwInkzFJSyOej8y8cQRh2z3kGw==", + "license": "MIT", + "peerDependencies": { + "pg": "^8" + } + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pg-types/node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -16305,50 +17148,32 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/plyr": { @@ -16384,9 +17209,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -16404,7 +17229,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -16546,6 +17371,42 @@ "dev": true, "license": "MIT" }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-interval": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-4.1.0.tgz", + "integrity": "sha512-nJLACcOHi2iyRuMJeW6CuIA0Dc0i1YEpRr26XDyHAfxPjYUJOFxwZjmOc5oFFctTTh/OlRAMlnlXEJvr170fJg==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/potpack": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", @@ -16576,9 +17437,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz", + "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", "dev": true, "license": "MIT", "bin": { @@ -16622,9 +17483,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.0.tgz", - "integrity": "sha512-V8ITGH87yuBDF6JpEZTOVlUz/saAwqb8f3HRgUj8Lh+tGCcrmorhsLpYqzygwFwK0PE2Ib6Mv3M7T/uE2tZV1g==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.8.1.tgz", + "integrity": "sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==", "dev": true, "license": "MIT", "engines": { @@ -16849,6 +17710,26 @@ "node": ">=16.0.0" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -17050,12 +17931,13 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -17155,30 +18037,30 @@ } }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "scheduler": "^0.27.0" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.3.0" } }, "node_modules/react-dom/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, "node_modules/react-dropzone": { @@ -17263,6 +18145,47 @@ "react": ">=18" } }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/react-router/node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -17665,118 +18588,6 @@ "@rolldown/binding-win32-x64-msvc": "1.1.2" } }, - "node_modules/rolldown-plugin-dts": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.26.0.tgz", - "integrity": "sha512-e+kEPtUiDES0htk5iqkSeF4EzAV7R+vugGB44iPDuw1Kw9E+WyL1VG7PaV0IIjGHLiacztMBcMTyrr8ON9CT1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/generator": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.0", - "@babel/parser": "^8.0.0", - "ast-kit": "^3.0.0", - "birpc": "^4.0.0", - "dts-resolver": "^3.0.0", - "get-tsconfig": "5.0.0-beta.5", - "obug": "^2.1.3" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - }, - "peerDependencies": { - "@ts-macro/tsc": "^0.3.6", - "@typescript/native-preview": ">=7.0.0-dev.20260325.1", - "rolldown": "^1.0.0", - "typescript": "^5.0.0 || ^6.0.0", - "vue-tsc": "~3.2.0 || ~3.3.0" - }, - "peerDependenciesMeta": { - "@ts-macro/tsc": { - "optional": true - }, - "@typescript/native-preview": { - "optional": true - }, - "typescript": { - "optional": true - }, - "vue-tsc": { - "optional": true - } - } - }, - "node_modules/rolldown-plugin-dts/node_modules/@babel/generator": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", - "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "@types/jsesc": "^2.5.0", - "jsesc": "^3.0.2" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/rolldown-plugin-dts/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz", - "integrity": "sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/rolldown-plugin-dts/node_modules/@babel/parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0.tgz", - "integrity": "sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/rolldown-plugin-dts/node_modules/@babel/types": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0.tgz", - "integrity": "sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, "node_modules/rollup": { "version": "4.62.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", @@ -17823,16 +18634,16 @@ } }, "node_modules/rollup-plugin-visualizer": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.0.1.tgz", - "integrity": "sha512-UJUT4+1Ho4OcWmPYU3sYXgUqI8B8Ayfe06MX7y0qCJ1K8aGoKtR/NDd/2nZqM7ADkrzny+I99Ul7GgyoiVNAgg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-7.1.1.tgz", + "integrity": "sha512-ThaGiHTU8XW02OkK80TrTHATraJmM9OAduU4otal+7gyXLpYEtmGBLfx5kW+EHvvLwn03YGW2NnwKUIqsYlJAA==", "dev": true, "license": "MIT", "dependencies": { "open": "^11.0.0", - "picomatch": "^4.0.2", - "source-map": "^0.7.4", - "yargs": "^18.0.0" + "picomatch": "^4.0.5", + "source-map": "^0.8.0", + "yargs": "^18.1.0" }, "bin": { "rollup-plugin-visualizer": "dist/bin/cli.js" @@ -17866,14 +18677,39 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/rollup-plugin-visualizer/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "node_modules/rollup-plugin-visualizer/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, "engines": { - "node": ">= 12" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" } }, "node_modules/router": { @@ -18405,17 +19241,13 @@ } }, "node_modules/source-map": { - "version": "0.8.0-beta.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", - "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", - "deprecated": "The work that was done in this beta branch won't be included in future versions", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "whatwg-url": "^7.0.0" - }, "engines": { - "node": ">= 8" + "node": ">= 12" } }, "node_modules/source-map-js": { @@ -18448,35 +19280,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map/node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/source-map/node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/source-map/node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -18487,6 +19290,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -18743,9 +19555,9 @@ } }, "node_modules/strnum": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", - "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", + "integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==", "funding": [ { "type": "github", @@ -18912,6 +19724,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, "license": "MIT" }, "node_modules/synckit": { @@ -19124,9 +19937,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", "dev": true, "license": "MIT", "engines": { @@ -19286,9 +20099,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -19373,92 +20186,6 @@ "node": ">=6" } }, - "node_modules/tsdown": { - "version": "0.22.3", - "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.22.3.tgz", - "integrity": "sha512-louqbfA8Qf//B9jTTL0FPtXTNpjCWv1VPkbcmQMph2pTpzs+LnB1tbe4tDDRVpo2BjF5SgUXaTZe45SxB8pWHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansis": "^4.3.1", - "cac": "^7.0.0", - "defu": "^6.1.7", - "empathic": "^2.0.1", - "hookable": "^6.1.1", - "import-without-cache": "^0.4.0", - "obug": "^2.1.3", - "picomatch": "^4.0.4", - "rolldown": "~1.1.1", - "rolldown-plugin-dts": "^0.26.0", - "semver": "^7.8.4", - "tinyexec": "^1.2.4", - "tinyglobby": "^0.2.17", - "tree-kill": "^1.2.2", - "unconfig-core": "^7.5.0" - }, - "bin": { - "tsdown": "dist/run.mjs" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - }, - "funding": { - "url": "https://github.com/sponsors/sxzz" - }, - "peerDependencies": { - "@arethetypeswrong/core": "^0.18.1", - "@tsdown/css": "0.22.3", - "@tsdown/exe": "0.22.3", - "@vitejs/devtools": "*", - "publint": "^0.3.8", - "tsx": "*", - "typescript": "^5.0.0 || ^6.0.0", - "unplugin-unused": "^0.5.0", - "unrun": "*" - }, - "peerDependenciesMeta": { - "@arethetypeswrong/core": { - "optional": true - }, - "@tsdown/css": { - "optional": true - }, - "@tsdown/exe": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "publint": { - "optional": true - }, - "tsx": { - "optional": true - }, - "typescript": { - "optional": true - }, - "unplugin-unused": { - "optional": true - }, - "unrun": { - "optional": true - } - } - }, - "node_modules/tsdown/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -19466,9 +20193,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -19621,12 +20348,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -19641,16 +20362,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.61.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", - "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.1", - "@typescript-eslint/parser": "8.61.1", - "@typescript-eslint/typescript-estree": "8.61.1", - "@typescript-eslint/utils": "8.61.1" + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -19913,40 +20634,83 @@ } }, "node_modules/unplugin": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", - "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.4.0.tgz", + "integrity": "sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "acorn": "^8.15.0", - "picomatch": "^4.0.3", + "picomatch": "^4.0.7", "webpack-virtual-modules": "^0.6.2" }, "engines": { - "node": ">=18.12.0" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rsbuild/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rsbuild/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } } }, "node_modules/unplugin-swc": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.5.9.tgz", - "integrity": "sha512-RKwK3yf0M+MN17xZfF14bdKqfx0zMXYdtOdxLiE6jHAoidupKq3jGdJYANyIM1X/VmABhh1WpdO+/f4+Ol89+g==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/unplugin-swc/-/unplugin-swc-1.6.0.tgz", + "integrity": "sha512-aOdzxAwJvz1GeQ8vwSNA2N8AreHYBrpwxqnwzxmB7LCdeZiXOBDj0rgjZod+lMxFsJGm8k6lF/1iKF/9wacLbw==", "dev": true, "license": "MIT", "dependencies": { - "@rollup/pluginutils": "^5.3.0", + "@rollup/pluginutils": "^5.4.0", "load-tsconfig": "^0.2.5", - "unplugin": "^2.3.11" + "unplugin": "^3.3.0" }, "peerDependencies": { "@swc/core": "^1.2.108" } }, "node_modules/unplugin/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -19994,14 +20758,14 @@ } }, "node_modules/unzipper": { - "version": "0.12.3", - "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz", - "integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==", + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", "license": "MIT", "dependencies": { "bluebird": "~3.7.2", "duplexer2": "~0.1.4", - "fs-extra": "^11.2.0", + "fs-extra": "11.3.1", "graceful-fs": "^4.2.2", "node-int64": "^0.4.0" } @@ -20018,9 +20782,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", - "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -20089,9 +20853,9 @@ } }, "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -20110,6 +20874,19 @@ "node": ">= 0.8" } }, + "node_modules/verkit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.1.tgz", + "integrity": "sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -20139,16 +20916,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "bin": { @@ -20165,7 +20942,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -20247,54 +21024,20 @@ } } }, - "node_modules/vite/node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/vite/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/vite/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/vite/node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", "cpu": [ "arm64" ], @@ -20309,9 +21052,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", "cpu": [ "arm64" ], @@ -20326,9 +21069,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", "cpu": [ "x64" ], @@ -20343,9 +21086,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", "cpu": [ "x64" ], @@ -20360,9 +21103,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", "cpu": [ "arm" ], @@ -20377,13 +21120,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -20394,13 +21140,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -20411,13 +21160,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -20428,13 +21180,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -20445,13 +21200,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -20462,13 +21220,16 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -20479,9 +21240,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", "cpu": [ "arm64" ], @@ -20495,29 +21256,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", "cpu": [ "arm64" ], @@ -20532,9 +21274,9 @@ } }, "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", "cpu": [ "x64" ], @@ -20549,9 +21291,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -20562,13 +21304,13 @@ } }, "node_modules/vite/node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -20578,21 +21320,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" } }, "node_modules/vitest": { @@ -21211,9 +21953,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -21306,11 +22048,19 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -21364,6 +22114,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yuku-ast": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/yuku-ast/-/yuku-ast-0.10.2.tgz", + "integrity": "sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.10.2" + } + }, + "node_modules/yuku-codegen": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/yuku-codegen/-/yuku-codegen-0.10.2.tgz", + "integrity": "sha512-hentl2dtrF6cPjiAirtkfxfFZBA6Hdm61UqRJ7cA0qrlDNMk9PZGOjw5w1mx3E0hu/6pHcJF4dJW+D0SXHPZOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.10.2" + }, + "optionalDependencies": { + "@yuku-codegen/binding-android-arm64": "0.10.2", + "@yuku-codegen/binding-darwin-arm64": "0.10.2", + "@yuku-codegen/binding-darwin-x64": "0.10.2", + "@yuku-codegen/binding-freebsd-x64": "0.10.2", + "@yuku-codegen/binding-linux-arm-gnu": "0.10.2", + "@yuku-codegen/binding-linux-arm-musl": "0.10.2", + "@yuku-codegen/binding-linux-arm64-gnu": "0.10.2", + "@yuku-codegen/binding-linux-arm64-musl": "0.10.2", + "@yuku-codegen/binding-linux-x64-gnu": "0.10.2", + "@yuku-codegen/binding-linux-x64-musl": "0.10.2", + "@yuku-codegen/binding-win32-arm64": "0.10.2", + "@yuku-codegen/binding-win32-x64": "0.10.2" + } + }, + "node_modules/yuku-parser": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/yuku-parser/-/yuku-parser-0.10.2.tgz", + "integrity": "sha512-CgaU0/PPjCAIEZ3WQroosOxTY3eeKldAN3h+vk8pMNz4+jl1CZzBR4pW+K/rRPF112VooCL5FdjJoiMNjDrL2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.10.2", + "yuku-ast": "^0.10.2" + }, + "optionalDependencies": { + "@yuku-parser/binding-android-arm64": "0.10.2", + "@yuku-parser/binding-darwin-arm64": "0.10.2", + "@yuku-parser/binding-darwin-x64": "0.10.2", + "@yuku-parser/binding-freebsd-x64": "0.10.2", + "@yuku-parser/binding-linux-arm-gnu": "0.10.2", + "@yuku-parser/binding-linux-arm-musl": "0.10.2", + "@yuku-parser/binding-linux-arm64-gnu": "0.10.2", + "@yuku-parser/binding-linux-arm64-musl": "0.10.2", + "@yuku-parser/binding-linux-x64-gnu": "0.10.2", + "@yuku-parser/binding-linux-x64-musl": "0.10.2", + "@yuku-parser/binding-win32-arm64": "0.10.2", + "@yuku-parser/binding-win32-x64": "0.10.2" + } + }, "node_modules/zip-stream": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-5.0.2.tgz", @@ -21379,9 +22188,9 @@ } }, "node_modules/zod": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", - "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -21409,6 +22218,35 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -21425,32 +22263,42 @@ "dependencies": { "@aws-lite/client": "^0.23.7", "@aws-lite/s3": "^0.2.7", + "@mikro-orm/cli": "^7.2.1", + "@mikro-orm/core": "^7.2.1", + "@mikro-orm/migrations": "^7.2.1", + "@mikro-orm/nestjs": "^7.1.0", + "@mikro-orm/postgresql": "^7.2.1", + "@mikro-orm/seeder": "^7.2.1", + "@mikro-orm/sql": "^7.2.1", + "@mikro-orm/sqlite": "^7.2.1", "@modelcontextprotocol/sdk": "^1.30.0", - "@nestjs/common": "^11.1.24", + "@nestjs/common": "^11.2.5", "@nestjs/config": "^4.0.4", - "@nestjs/core": "^11.1.24", - "@nestjs/platform-express": "^11.1.24", - "@nestjs/platform-ws": "^11.1.29", + "@nestjs/core": "^11.2.5", + "@nestjs/platform-express": "^11.2.5", + "@nestjs/platform-ws": "^11.2.5", "@nestjs/schedule": "^6.1.3", - "@nestjs/swagger": "^11.4.5", - "@nestjs/websockets": "^11.1.29", - "@simplewebauthn/server": "^13.3.2", + "@nestjs/swagger": "^11.4.7", + "@nestjs/websockets": "^11.2.5", + "@simplewebauthn/server": "^13.3.3", "@trek/shared": "*", - "archiver": "^6.0.1", + "archiver": "^6.0.2", "bcryptjs": "^2.4.3", "better-sqlite3": "^13.0.3", - "compression": "^1.8.0", + "compression": "^1.8.2", "cookie-parser": "^1.4.7", - "cors": "^2.8.5", + "cors": "^2.8.6", "cron": "^4.4.0", - "dotenv": "^16.4.1", + "dotenv": "^16.6.1", "exifr": "7.1.3", - "express": "^4.18.3", - "fast-xml-parser": "^5.5.10", - "helmet": "^8.1.0", + "express": "^4.22.3", + "fast-xml-parser": "^5.11.1", + "helmet": "^8.3.0", "jimp": "^1.6.1", "json5": "^2.2.3", - "jsonwebtoken": "^9.0.2", + "jsonwebtoken": "^9.0.3", + "kysely": "^0.29.6", + "multer": "^2.4.0", "nestjs-zod": "^5.5.0", "nodemailer": "^10.0.13", "otplib": "^12.0.1", @@ -21458,52 +22306,53 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "semver": "^7.7.4", + "semver": "^7.8.5", "tsconfig-paths": "^4.2.0", - "tsx": "^4.21.0", - "typescript": "^6.0.2", + "tsx": "^4.23.15", + "typescript": "^6.0.3", "tz-lookup": "^6.1.25", "undici": "^7.30.0", - "unzipper": "^0.12.3", - "uuid": "^14.0.0", - "ws": "^8.21.0", - "zod": "^4.3.6" + "unzipper": "^0.12.5", + "uuid": "^14.0.2", + "ws": "^8.21.3", + "zod": "^4.6.5" }, "devDependencies": { "@aws-lite/s3-types": "^0.2.8", "@eslint/js": "^10.0.1", - "@nestjs/testing": "^11.1.24", - "@swc/core": "^1.15.40", + "@mikro-orm/entity-generator": "^7.2.1", + "@nestjs/testing": "^11.2.5", + "@swc/core": "^1.16.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", - "@types/compression": "^1.8.0", + "@types/compression": "^1.8.1", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", - "@types/multer": "^2.1.0", - "@types/node": "^25.5.0", - "@types/nodemailer": "^8.0.1", - "@types/qrcode": "^1.5.5", - "@types/semver": "^7.7.1", + "@types/multer": "^2.2.0", + "@types/node": "^25.9.8", + "@types/nodemailer": "^8.0.2", + "@types/qrcode": "^1.5.6", + "@types/semver": "^7.8.0", "@types/supertest": "^6.0.3", "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", "@vitest/coverage-istanbul": "^4.1.11", "@vitest/coverage-v8": "^4.1.11", - "eslint": "^9.18.0", - "eslint-config-flat-gitignore": "^2.3.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "nodemon": "^3.1.0", - "prettier": "^3.8.3", + "eslint": "^9.39.5", + "eslint-config-flat-gitignore": "^2.4.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "nodemon": "^3.1.14", + "prettier": "^3.9.8", "prettier-plugin-organize-imports": "^4.3.0", "supertest": "^7.2.2", - "typescript-eslint": "^8.58.2", - "unplugin-swc": "^1.5.9", + "typescript-eslint": "^8.70.0", + "unplugin-swc": "^1.6.0", "vitest": "^4.1.11" } }, @@ -21605,19 +22454,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "server/node_modules/better-sqlite3": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", - "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.0.0" - }, - "engines": { - "node": ">=22" - } - }, "server/node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -21647,9 +22483,10 @@ } }, "server/node_modules/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { @@ -21658,8 +22495,8 @@ "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -21737,9 +22574,9 @@ } }, "server/node_modules/eslint/node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "dev": true, "license": "MIT", "engines": { @@ -21804,39 +22641,484 @@ "name": "@trek/shared", "version": "4.3.3", "dependencies": { - "isomorphic-dompurify": "^3.15.0", - "zod": "^4.3.6" + "isomorphic-dompurify": "^3.23.0", + "zod": "^4.6.5" }, "devDependencies": { "@eslint/js": "^10.0.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@vitest/coverage-v8": "^4.1.11", - "eslint": "^10.3.0", - "eslint-config-flat-gitignore": "^2.3.0", + "eslint": "^10.11.0", + "eslint-config-flat-gitignore": "^2.4.0", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "prettier": "3.8.3", + "eslint-plugin-prettier": "^5.5.6", + "prettier": "3.9.8", "prettier-plugin-organize-imports": "^4.3.0", - "tsdown": "^0.22.2", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.2", + "tsdown": "^0.23.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", "vitest": "^4.1.11" } }, - "shared/node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "shared/node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "shared/node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "shared/node_modules/get-tsconfig": { + "version": "5.0.0-beta.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.6.tgz", + "integrity": "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" }, "engines": { - "node": ">=14" + "node": ">=20.20.0" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "shared/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "shared/node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "shared/node_modules/tsdown": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/tsdown/-/tsdown-0.23.0.tgz", + "integrity": "sha512-BaT+ep1xnj5hdyICLd5r5SYYE+TXnI1ATePLykv9vcKpHpFTWUWRH+x1AF/E2qcu3YB6+vI8IdyxIIIffEqw5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^7.0.0", + "defu": "^6.1.7", + "empathic": "^2.0.1", + "hookable": "^6.1.1", + "import-without-cache": "^0.4.0", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "rolldown": "~1.2.7", + "rolldown-plugin-dts": "^0.28.5", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "tree-kill": "^1.2.2", + "unconfig-core": "^7.5.0", + "verkit": "^0.4.0" + }, + "bin": { + "tsdown": "dist/run.mjs" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@arethetypeswrong/core": "^0.18.1", + "@tsdown/css": "0.23.0", + "@tsdown/exe": "0.23.0", + "@vitejs/devtools": "*", + "publint": "^0.3.8", + "tsx": "*", + "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", + "unplugin-unused": ">=0.5.0", + "unrun": "*" + }, + "peerDependenciesMeta": { + "@arethetypeswrong/core": { + "optional": true + }, + "@tsdown/css": { + "optional": true + }, + "@tsdown/exe": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "publint": { + "optional": true + }, + "tsx": { + "optional": true + }, + "typescript": { + "optional": true + }, + "unplugin-unused": { + "optional": true + }, + "unrun": { + "optional": true + } + } + }, + "shared/node_modules/tsdown/node_modules/rolldown-plugin-dts": { + "version": "0.28.6", + "resolved": "https://registry.npmjs.org/rolldown-plugin-dts/-/rolldown-plugin-dts-0.28.6.tgz", + "integrity": "sha512-qKrFtBfRfR2hP233m7Ic9zf3wv6MSYdZghWKMdEO6dRYZGlNfINJAa1P5ZVvyHh3mrcd7IJQvIY8L9vnm+v5rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dts-resolver": "^3.0.0", + "get-tsconfig": "5.0.0-beta.6", + "obug": "^3.0.0", + "yuku-ast": "^0.10.1", + "yuku-codegen": "^0.10.1", + "yuku-parser": "^0.10.1" + }, + "engines": { + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@typescript/native-preview": "*", + "@volar/typescript": "~2.4.0", + "@vue/language-core": "~3.2.0 || ~3.3.0", + "rolldown": "^1.2.0", + "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", + "vue-tsc": "~3.2.0 || ~3.3.0" + }, + "peerDependenciesMeta": { + "@typescript/native-preview": { + "optional": true + }, + "@volar/typescript": { + "optional": true + }, + "@vue/language-core": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "shared/node_modules/tsdown/node_modules/rolldown-plugin-dts/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" } } } diff --git a/package.json b/package.json index 4eebb0a01..f9db02cf2 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ }, "comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.", "overrides": { - "react": "19.2.6", - "react-dom": "19.2.6", + "react": "19.3.0", + "react-dom": "19.3.0", "multer": "^2.3.0" }, "comment:optionalDependencies": "The lockfile is generated on glibc, so the musl builds the Alpine image needs never land in it on their own. Keep the @img/* pins on the same version as client's sharp — a mismatch nests the binary away from libvips and the image build dies in generate-icons.", diff --git a/plugin-sdk/package-lock.json b/plugin-sdk/package-lock.json index bd29bd7d3..077381960 100644 --- a/plugin-sdk/package-lock.json +++ b/plugin-sdk/package-lock.json @@ -9,8 +9,8 @@ "version": "1.8.0", "license": "MIT", "dependencies": { - "@clack/prompts": "^1.7.0", - "semver": "^7.7.4", + "@clack/prompts": "^1.8.1", + "semver": "^7.8.5", "update-notifier": "^7.3.1" }, "bin": { @@ -19,12 +19,12 @@ "trek-plugin-sdk": "dist/cli/trek-plugin.js" }, "devDependencies": { - "@types/node": "^22.20.0", - "@types/semver": "^7.7.1", + "@types/node": "^22.20.4", + "@types/semver": "^7.8.0", "@types/update-notifier": "^6.0.8", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.11", "typescript": "^5.9.3", - "vitest": "^4.1.9" + "vitest": "^4.1.11" }, "engines": { "node": ">=18" @@ -91,9 +91,9 @@ } }, "node_modules/@clack/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", - "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", "license": "MIT", "dependencies": { "fast-wrap-ansi": "^0.2.0", @@ -104,12 +104,12 @@ } }, "node_modules/@clack/prompts": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", - "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", "license": "MIT", "dependencies": { - "@clack/core": "1.4.3", + "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" @@ -517,9 +517,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", - "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", "dev": true, "license": "MIT", "dependencies": { @@ -527,9 +527,9 @@ } }, "node_modules/@types/semver": { - "version": "7.7.1", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", - "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", "dev": true, "license": "MIT" }, diff --git a/plugin-sdk/package.json b/plugin-sdk/package.json index defd5b17a..a7a8e66e3 100644 --- a/plugin-sdk/package.json +++ b/plugin-sdk/package.json @@ -63,8 +63,8 @@ "dist" ], "dependencies": { - "@clack/prompts": "^1.7.0", - "semver": "^7.7.4", + "@clack/prompts": "^1.8.1", + "semver": "^7.8.5", "update-notifier": "^7.3.1" }, "scripts": { @@ -75,12 +75,12 @@ "prepublishOnly": "npm run build && npm test" }, "devDependencies": { - "@types/node": "^22.20.0", - "@types/semver": "^7.7.1", + "@types/node": "^22.20.4", + "@types/semver": "^7.8.0", "@types/update-notifier": "^6.0.8", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.11", "typescript": "^5.9.3", - "vitest": "^4.1.9" + "vitest": "^4.1.11" }, "keywords": [ "trek", diff --git a/plugin-sdk/src/cli/ui.ts b/plugin-sdk/src/cli/ui.ts index 885145d3b..32139bb5f 100644 --- a/plugin-sdk/src/cli/ui.ts +++ b/plugin-sdk/src/cli/ui.ts @@ -50,12 +50,12 @@ export const spinner = (): SpinnerResult => clackSpinner({ output: OUT }); // ── cancellation ───────────────────────────────────────────────────────────── /** Clack returns a symbol when the user hits Ctrl+C — turn that into a clean exit. */ -export function orCancel(value: T | symbol): T { +export function orCancel(value: T): Exclude { if (isCancel(value)) { clackCancel('Cancelled.', { output: OUT }); process.exit(0); } - return value as T; + return value as Exclude; } // ── prompts (cancel-checked, stderr-rendered) ──────────────────────────────── diff --git a/server/CLAUDE.md b/server/CLAUDE.md index e582a1705..96148752b 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -16,6 +16,7 @@ npm run test:coverage # istanbul coverage; per-domain ratchet over src/nest/ npm run gen:plugin-facts # regenerate the plugin-protocol tables into plugin-sdk/ + shared/ npm run check:plugin-facts # CI gate — fails if the generated copies drifted node scripts/coverage-thresholds.mjs # after test:coverage — prints the ratchet block for vitest.config.ts +npm run db:call-graph -- --sync --tx [--domains nest/days,…] # sync DB-touching methods + transaction sites (exit 1 if any) ``` Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `npx vitest run -t "returns 401 without cookie"`. @@ -32,14 +33,14 @@ Nest owns everything. Every domain is a DI module under `src/nest//` (`c - **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator** — it bypasses that gate. - **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together. - **Storage**: `src/nest/storage/` is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly. -- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on; `database.ts` runs `schema.ts` → `migrations.ts` → `seeds.ts`. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness). +- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on. `database.ts` only opens the connection and applies pragmas — it does not build a schema. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests**: the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` builders and the parity test that guarded them are gone. Seeding is the MikroORM seeders in `db/seeders/`, applied by `db/orm.ts` from `buildApp()` before anything reads. An install the retired positional runner migrated (a `schema_version` row, no `mikro_orm_migrations` rows) is baselined by `db/legacy-baseline.ts`: the migrations whose `Legacy migration step N` docstring is ≤ its version are recorded as executed (never replayed) in the same transaction as the migrator run, while the baseline migration itself still runs first, as the legacy runner's `createTables()` did on every boot; anything it cannot map refuses the boot. `db/orm-driver.ts` binds the ORM to that same connection instead of opening a second one — with `:memory:` under test, a second connection is a second, empty database. Every read and write goes through a repository in `db/repositories/` (each extends the shared `TrekRepository` base in `db/repositories/_shared/`, which validates the request context before delegating to MikroORM) and, for multi-statement writes, `UnitOfWork.transactional(...)` (`nest/database/unit-of-work.ts`, provided by the global `OrmModule`) — there is no `DatabaseService`/`DatabaseModule` to inject any more. Entrypoints outside an HTTP request — an MCP tool call, a plugin RPC dispatch, a WebSocket message, a cron job, boot-time seeding, a backup restore — open one explicitly with `withRequestContext()` (`nest/database/request-context.ts`), since only the pathless `mikroOrmRequestContext` middleware `buildApp()` mounts (with `@mikro-orm/nestjs`'s own `registerRequestContext` turned off in `app.module.ts`, UPLOADS-P16) forks an `EntityManager` per HTTP request. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker, opened from a snapshot `tests/global-setup.ts` migrates once per run; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness). ## Rules for new code - **Thin controller → injectable provider.** Never resurrect a plain function-module service; don't grow god services — split a domain into several modules when it outgrows one class (trips and auth already are). -- **Inject, don't reach for module globals.** No new imports of the global `db` proxy from `nest/` layers; no new code depending on the `ws` singleton or event-sink globals. Modules must be importable without side effects. -- **Transactions are not optional.** Multi-statement writes go in `db.transaction()`; never hand-roll `BEGIN`/`COMMIT`. Bind values with `?`; identifiers only via a literal allow-list. -- **Migrations are append-only.** Identity is positional (array index == `schema_version`) — never reorder, insert mid-list, or delete; no env interpolation in SQL. +- **Inject, don't reach for module globals.** No imports of the global `db` proxy from `nest/` layers at all any more — the last production importer converted onto a repository, and the export now survives only so ~190 test files' `vi.mock('…/db/database', …)` factories keep typechecking; no new code depending on the `ws` singleton or event-sink globals. There is no `DatabaseService` to inject either — take the repository(ies) you need plus `UnitOfWork` for transactions. Modules must be importable without side effects. +- **Transactions are not optional.** Multi-statement writes go in `await this.uow.transactional(async () => …)` (`UnitOfWork` from `nest/database`, provided by the global `OrmModule`); never hand-roll `BEGIN`/`COMMIT` and never call `db.transaction()` (the Phase 1 async sweep removes it). Every DB-touching method is `async`; `@typescript-eslint/no-floating-promises` and `no-misused-promises` are errors, so an unawaited call or a `Promise` in a condition fails lint. Bind values with `?`; identifiers only via a literal allow-list. +- **Migrations are append-only.** A migration is identified by its class name in `mikro_orm_migrations`, and files sort by the timestamp in that name — never rename or delete one that has shipped, and never edit its body; add a new one. No env interpolation in SQL. (The old positional `schema_version` array and the hand-kept `db/migrations.ts` test builder are both gone; the numbered migrations are the only schema history, for tests too.) - **Every write endpoint validates** through the Zod pipe with a schema from `@trek/shared` — no `Record` + ad-hoc checks. Also verify every referenced id exists and belongs to the same trip. - **Every outbound `fetch`** gets a timeout, a response-size cap and boundary validation — no `as`-casting provider responses, no silent `catch → null`. User-influenced URLs go through the SSRF guard. Never fall back to another user's API key. - **Fail closed.** Unrecognized flag values mean OFF; sandbox/permission opt-outs refuse in production; never pick a less-safe path based on the *absence* of a file. GET handlers must not write. diff --git a/server/eslint-rules/fixtures/file.ts b/server/eslint-rules/fixtures/file.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/server/eslint-rules/fixtures/file.ts @@ -0,0 +1 @@ +export {}; diff --git a/server/eslint-rules/fixtures/strict.ts b/server/eslint-rules/fixtures/strict.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/server/eslint-rules/fixtures/strict.ts @@ -0,0 +1 @@ +export {}; diff --git a/server/eslint-rules/fixtures/tsconfig.json b/server/eslint-rules/fixtures/tsconfig.json new file mode 100644 index 000000000..023f92807 --- /dev/null +++ b/server/eslint-rules/fixtures/tsconfig.json @@ -0,0 +1,16 @@ +{ + // Fixture project for the RuleTester in tests/unit/eslint-rules/. It exists so + // typescript-eslint can build a real program for the virtual `file.ts` the + // tester feeds it. Kept outside src/ and tests/ on purpose: files here must not + // reach `tsc --noEmit`, `tsc -p tsconfig.tests.json` or the type-aware lint + // blocks, since the invalid cases are deliberately broken code. + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": false, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["file.ts"] +} diff --git a/server/eslint-rules/fixtures/tsconfig.strict.json b/server/eslint-rules/fixtures/tsconfig.strict.json new file mode 100644 index 000000000..48ce17e3c --- /dev/null +++ b/server/eslint-rules/fixtures/tsconfig.strict.json @@ -0,0 +1,15 @@ +{ + // Second fixture project, identical but with strictNullChecks ON. The server + // compiles with `strict: false`, where `Promise | null` collapses to + // `Promise` and the rule's nullable exemptions are unreachable — this one + // exists so those branches (`p ?? x`, `p === null`) are actually exercised. + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["strict.ts"] +} diff --git a/server/eslint-rules/no-promise-as-value.mjs b/server/eslint-rules/no-promise-as-value.mjs new file mode 100644 index 000000000..818928e72 --- /dev/null +++ b/server/eslint-rules/no-promise-as-value.mjs @@ -0,0 +1,344 @@ +/** + * trek/no-promise-as-value — the blind spot the compiler cannot see. + * + * Phase 1 of the ORM migration turns hundreds of DB-touching methods `async`. + * TypeScript catches a missed `await` only when the promise is used in a way it + * can type-check: `promise.foo` (TS2339), a floating statement + * (no-floating-promises), a condition (no-misused-promises). It catches NOTHING + * when the promise is used as a plain value, because this project compiles with + * `strict: false` / `noImplicitAny: false`: + * + * settings['metric'] // element access on a promise -> `any` + * if (svc.get(id) === 'x') // always false, no error + * `${svc.count(id)}` // "[object Promise]" + * Object.keys(svc.all()) // [] + * JSON.stringify(svc.row(id)) // "{}" + * for (const r of svc.rows()) // not iterable, at runtime only + * + * This rule reports those shapes. It is DELIBERATELY CONSERVATIVE and has no + * fixer: it fires only when the resolved type is the global `Promise`/ + * `PromiseLike` itself (never a merely-thenable library type such as MikroORM's + * QueryBuilder or supertest's Test, both of which are chained on purpose), only + * when every non-nullish member of a union is one, and never inside + * `Promise.all`/`allSettled`/`race`/`any`, on `.then`/`.catch`/`.finally`, on + * `expect(p).resolves`/`.rejects`, or in an existence/identity check + * (`p === undefined`, `p === null`, `p ?? q`, `a === b` between two promises) — + * see isExistenceOrIdentityCheck below. A finding is a bug: add the `await`. + * + * It also reports a bare `expect(p)` (never `expect.soft`/other wrappers) + * whose argument is a Promise, unless the call is immediately the object of a + * `.resolves`/`.rejects` access, of `.toBeInstanceOf(Promise)` (asking + * whether the value itself is a Promise IS the assertion, not a missing + * await), or of `.toBe(otherPromise)` where the argument is itself + * Promise-typed — the same in-flight-map identity idiom + * isExistenceOrIdentityCheck exempts for `===`, just spelled with a matcher + * (trek-photo-cache's stampede guard: `expect(svc.getInFlight(k)).toBe(fetch)` + * — awaiting would compare the resolved buffer to the unawaited Promise `fetch` + * and always fail). `expect(p).toBe(x)` is otherwise a vacuous assertion (it + * always passes: a Promise object is never `=== x`), same bug class as the + * rest of this rule but with its own message since the fix is either + * `expect(await p)` or `await expect(p).resolves…`, not a bare `await`. + */ +import ts from 'typescript'; + +const NULLISH_FLAGS = ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void; +const PASSTHROUGH_PROPS = new Set(['then', 'catch', 'finally', 'resolves', 'rejects']); +const COMBINATORS = new Set(['all', 'allSettled', 'race', 'any']); +const EQUALITY_OPERATORS = new Set(['==', '!=', '===', '!==']); +// Callbacks whose result is consumed as a value, where a promise is always a bug +// (every promise is truthy, so the predicate degenerates). `.map`/`.reduce` are +// excluded on purpose: returning promises there feeds Promise.all legitimately. +const PREDICATE_METHODS = new Set([ + 'filter', + 'find', + 'findLast', + 'findIndex', + 'findLastIndex', + 'some', + 'every', + 'sort', +]); +const OBJECT_CONSUMERS = new Set(['keys', 'values', 'entries', 'assign', 'fromEntries']); + +/** `String(p)` / `Number(p)` / `Object.keys(p)` / `JSON.stringify(p)` / `Array.isArray(p)`. */ +function isValueConsumerCall(callee) { + if (callee.type === 'Identifier') return callee.name === 'String' || callee.name === 'Number'; + if (callee.type !== 'MemberExpression' || callee.computed) return false; + const { object, property } = callee; + if (object.type !== 'Identifier' || property.type !== 'Identifier') return false; + if (object.name === 'Object') return OBJECT_CONSUMERS.has(property.name); + if (object.name === 'JSON') return property.name === 'stringify'; + if (object.name === 'Array') return property.name === 'isArray'; + return false; +} + +function isCombinatorCall(node) { + const callee = node.callee; + return ( + callee.type === 'MemberExpression' && + !callee.computed && + callee.object.type === 'Identifier' && + callee.object.name === 'Promise' && + callee.property.type === 'Identifier' && + COMBINATORS.has(callee.property.name) + ); +} + +/** Inside `Promise.all([...])` a promise IS the value; stop at the function boundary. */ +function isInsideCombinator(node) { + for (let cur = node.parent; cur; cur = cur.parent) { + if (cur.type === 'CallExpression' && isCombinatorCall(cur)) return true; + if (cur.type.includes('Function')) return false; + } + return false; +} + +function isNullLiteral(node) { + return (node.type === 'Literal' && node.value === null) || (node.type === 'Identifier' && node.name === 'undefined'); +} + +export default { + meta: { + type: 'problem', + docs: { + description: + 'Disallow using a Promise as a plain value (element access, comparison, interpolation, spread, iteration) — the missing-await shapes the compiler cannot see with strict mode off.', + }, + schema: [], + messages: { + promiseAsValue: 'Promise used as a value (missing await?): {{ text }}', + expectPromise: 'Promise passed to expect() without .resolves/.rejects (missing await?)', + }, + }, + + create(context) { + const services = context.sourceCode.parserServices; + if (!services?.program || !services.esTreeNodeToTSNodeMap) return {}; + const checker = services.program.getTypeChecker(); + const reported = new WeakSet(); + + const isNullish = (type) => (type.flags & NULLISH_FLAGS) !== 0; + + function isPromiseType(type, tsNode) { + if (!type) return false; + if (type.isUnion()) { + const parts = type.types.filter((t) => !isNullish(t)); + return parts.length > 0 && parts.every((t) => isPromiseType(t, tsNode)); + } + const symbol = type.getSymbol() ?? type.aliasSymbol; + const name = symbol?.getName(); + if (name !== 'Promise' && name !== 'PromiseLike') return false; + const then = type.getProperty('then'); + if (!then) return false; + return checker.getTypeOfSymbolAtLocation(then, tsNode).getCallSignatures().length > 0; + } + + function typeOf(node) { + if (!node || node.type === 'AwaitExpression' || node.type === 'PrivateIdentifier') return null; + const tsNode = services.esTreeNodeToTSNodeMap.get(node); + return tsNode ? { type: checker.getTypeAtLocation(tsNode), tsNode } : null; + } + + function isPromise(node) { + const resolved = typeOf(node); + return !!resolved && isPromiseType(resolved.type, resolved.tsNode); + } + + function report(node) { + if (!node || reported.has(node) || isInsideCombinator(node)) return; + reported.add(node); + const raw = context.sourceCode.getText(node).replace(/\s+/g, ' '); + const text = raw.length > 60 ? `${raw.slice(0, 57)}...` : raw; + context.report({ node, messageId: 'promiseAsValue', data: { text } }); + } + + function check(node) { + if (isPromise(node)) report(node); + } + + function reportExpect(node) { + if (!node || reported.has(node)) return; + reported.add(node); + context.report({ node, messageId: 'expectPromise' }); + } + + /** Bare `expect(...)` only — `expect.soft(...)` and other wrappers are untouched. */ + function isBareExpectCall(node) { + return node.callee.type === 'Identifier' && node.callee.name === 'expect'; + } + + /** `expect(p).resolves` / `expect(p).rejects`, however it is chained further from there. */ + function isResolvesOrRejectsAccess(node) { + const parent = node.parent; + return ( + !!parent && + parent.type === 'MemberExpression' && + parent.object === node && + !parent.computed && + parent.property.type === 'Identifier' && + (parent.property.name === 'resolves' || parent.property.name === 'rejects') + ); + } + + /** + * `expect(p).toBeInstanceOf(Promise)` asks whether the VALUE ITSELF is a + * Promise object — that is the assertion, not a missing await, and + * awaiting it would change what the test checks. Exempt only that literal + * shape: the argument must resolve to the global `Promise` constructor, so + * `expect(f()).toBeInstanceOf(SomeOtherClass)` is still a reported bug. + */ + function isInstanceOfPromiseCheck(node) { + const memberParent = node.parent; + if ( + !memberParent || + memberParent.type !== 'MemberExpression' || + memberParent.object !== node || + memberParent.computed || + memberParent.property.type !== 'Identifier' || + memberParent.property.name !== 'toBeInstanceOf' + ) { + return false; + } + const callParent = memberParent.parent; + if (!callParent || callParent.type !== 'CallExpression' || callParent.callee !== memberParent) return false; + const arg = callParent.arguments[0]; + const resolved = arg && typeOf(arg); + if (!resolved) return false; + const symbol = resolved.type.getSymbol(); + return symbol?.getName() === 'PromiseConstructor'; + } + + /** + * `expect(p).toBe(otherPromise)` — an identity check between two Promise + * references (the in-flight-map stampede guard), not a resolved-value + * comparison. Mirrors isExistenceOrIdentityCheck's "both sides are + * promises" branch for `===`. + */ + function isPromiseIdentityToBe(node) { + const memberParent = node.parent; + if ( + !memberParent || + memberParent.type !== 'MemberExpression' || + memberParent.object !== node || + memberParent.computed || + memberParent.property.type !== 'Identifier' || + memberParent.property.name !== 'toBe' + ) { + return false; + } + const callParent = memberParent.parent; + if (!callParent || callParent.type !== 'CallExpression' || callParent.callee !== memberParent) return false; + const arg = callParent.arguments[0]; + return !!arg && isPromise(arg); + } + + /** + * `p === undefined` / `p === null` is how this codebase asks whether an + * in-flight promise is already parked in a map — a truthiness test there is + * what no-misused-promises rejects, so the explicit comparison IS the + * correct idiom (weather, collab, maps, exchange-rates, idempotency, the + * plugin drain, atlas). `chains.get(userId) === next` likewise asks whether + * the promise it stored is still the current one. Neither can be told apart + * from a missing await by type alone — with strictNullChecks off `Map.get()` + * is not even typed as nullable — so equality against nullish, and equality + * between two promises, are both exempt. `p === 'x'` still reports. + */ + function isExistenceOrIdentityCheck(node) { + if (isNullLiteral(node.left) || isNullLiteral(node.right)) return true; + return isPromise(node.left) && isPromise(node.right); + } + + return { + MemberExpression(node) { + const key = + node.property.type === 'Identifier' && !node.computed + ? node.property.name + : node.property.type === 'Literal' + ? node.property.value + : undefined; + if (typeof key === 'string' && PASSTHROUGH_PROPS.has(key)) return; + check(node.object); + }, + + BinaryExpression(node) { + // `p instanceof Promise` is the legitimate way to ask. + if (node.operator === 'instanceof') return; + if (EQUALITY_OPERATORS.has(node.operator) && isExistenceOrIdentityCheck(node)) return; + check(node.left); + check(node.right); + }, + + LogicalExpression(node) { + // `p ?? x` is the same existence check as `p === undefined`, and with + // strictNullChecks off the type cannot say whether it is a real one. + if (node.operator === '??') return; + // Only the left operand of `&&`/`||`: it is the one evaluated as a test, + // where a promise is unconditionally truthy. The right operand IS the + // expression's value and gets caught at whatever consumes it. + check(node.left); + }, + + TemplateLiteral(node) { + node.expressions.forEach(check); + }, + + SpreadElement(node) { + check(node.argument); + }, + + UnaryExpression(node) { + if (node.operator === 'typeof') check(node.argument); + }, + + ForOfStatement(node) { + if (!node.await) check(node.right); + }, + + ForInStatement(node) { + check(node.right); + }, + + CallExpression(node) { + if ( + isBareExpectCall(node) && + node.arguments.length > 0 && + node.arguments[0].type !== 'SpreadElement' && + !isResolvesOrRejectsAccess(node) && + !isInstanceOfPromiseCheck(node) && + !isPromiseIdentityToBe(node) && + isPromise(node.arguments[0]) + ) { + reportExpect(node.arguments[0]); + return; + } + if (isValueConsumerCall(node.callee)) { + node.arguments.forEach((arg) => { + if (arg.type !== 'SpreadElement') check(arg); + }); + return; + } + const callee = node.callee; + if ( + callee.type !== 'MemberExpression' || + callee.computed || + callee.property.type !== 'Identifier' || + !PREDICATE_METHODS.has(callee.property.name) + ) { + return; + } + for (const arg of node.arguments) { + if (arg.type !== 'ArrowFunctionExpression' && arg.type !== 'FunctionExpression') continue; + const resolved = typeOf(arg); + if (!resolved) continue; + const signatures = resolved.type.getCallSignatures(); + if ( + signatures.length > 0 && + signatures.every((sig) => isPromiseType(checker.getReturnTypeOfSignature(sig), resolved.tsNode)) + ) { + report(arg.body.type === 'BlockStatement' ? arg : arg.body); + } + } + }, + }; + }, +}; diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index 4684c3ad6..0c7edecc3 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -4,6 +4,11 @@ import gitignore from 'eslint-config-flat-gitignore'; import eslintConfigPrettier from 'eslint-config-prettier'; import tseslint from 'typescript-eslint'; +import noPromiseAsValue from './eslint-rules/no-promise-as-value.mjs'; + +// Local rules live in eslint-rules/ and are exposed under the `trek/` prefix. +const trek = { rules: { 'no-promise-as-value': noPromiseAsValue } }; + export default tseslint.config( gitignore({ strict: false }), { @@ -25,9 +30,55 @@ export default tseslint.config( js.configs.recommended, ...tseslint.configs.recommended, eslintConfigPrettier, + { + files: ['src/**/*.ts'], + languageOptions: { + parserOptions: { + // Type-aware linting for src/: the project service asks TypeScript which + // config owns a file, the way an editor does, so there is no `project` + // array to keep in sync. Every file under src/ is in tsconfig.json's + // `include`, so the service resolves them all. + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, + { + // tests/ needs the classic `project` instead. The project service looks for + // the tsconfig.json nearest the file and refuses a file that config does not + // include ("was not found by the project service"); tsconfig.json includes + // only `src`, and its `allowDefaultProject` escape hatch rejects any glob + // containing `**`, so it cannot cover a tree this size. `tsconfig.tests.json` + // (the one `npm run typecheck:tests` uses) includes src + tests, which is + // exactly the program these files need. + files: ['tests/**/*.ts'], + languageOptions: { + parserOptions: { + project: ['./tsconfig.tests.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, + }, { files: ['src/**/*.ts', 'tests/**/*.ts'], + plugins: { trek }, rules: { + // trek/no-promise-as-value closes the hole the two rules below leave open: + // with `strict: false`, a promise used as a plain VALUE (element access, + // comparison, interpolation, spread, iteration, Object.keys/JSON.stringify) + // type-checks clean and silently produces `any`, `false` or `[object + // Promise]`. See eslint-rules/no-promise-as-value.mjs. + 'trek/no-promise-as-value': 'error', + // --- The promise safety net for the Phase 1 async sweep (ORM migration) --- + // Turning a DB-touching method `async` makes every unawaited call site a + // silently reordered write, and every `if (svc.exists(id))` permanently + // true — neither of which the type checker reports. These two rules are + // the only thing that catches them, so they are errors, not warnings. + '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], + '@typescript-eslint/no-misused-promises': [ + 'error', + { checksConditionals: true, checksVoidReturn: false, checksSpreads: true }, + ], // --- Severities tuned to keep CI green on a codebase that was never linted --- // (each rule below has pre-existing violations; surfaced as warnings, not blockers) '@typescript-eslint/no-explicit-any': 'warn', @@ -106,6 +157,124 @@ export default tseslint.config( ], }, }, + { + // D4: the driver is an implementation detail of src/db. Nothing else in + // src/ names `better-sqlite3` — a domain module that imports it is coupled + // to the engine the ORM exists to abstract, and (for the value import) + // reaches a connection the DI graph never handed it. + // + // `no-restricted-imports` is repeated rather than added to the src/services + // block above because ESLint flat config REPLACES a rule's options with the + // last matching config object's — a block that set only the driver path + // would silently drop the services wall for every file it matches. + files: ['src/**/*.ts'], + ignores: [ + // The connection and the ORM's bound driver: this is where the engine lives. + 'src/db/database.ts', + // openDatabase(): every better-sqlite3 handle the server opens (#2518). + 'src/db/connection.ts', + 'src/db/orm-driver.ts', + 'src/db/durability.ts', + // The plugin sandbox opens its own per-plugin database files, deliberately + // outside the app's ORM and connection. + 'src/nest/plugins/host/plugin-data.service.ts', + // Frozen forever (ruling 7): invoked from inside numbered, already-shipped + // MikroORM migrations — rewriting either onto the ORM would change what a + // re-run of an old migration against an old schema snapshot produces, + // which is never done to a landed migration. Not Plan 4's or any future + // plan's to touch. + 'src/db/reseat-booked-nights.ts', + 'src/db/document-provider-seed.ts', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'better-sqlite3', + message: + 'The SQLite driver is an implementation detail of src/db. Go through the ORM (src/db/entities + src/db/repositories) or UnitOfWork; the handle itself is owned by src/db/database.ts.', + }, + ], + patterns: [ + { + group: ['**/services/*', '**/services/**/*'], + message: + 'src/services/ is deleted. New backend code goes to src/nest// (service + controller + module, registered in app.module.ts). See src/nest/README.md.', + }, + ], + }, + ], + }, + }, + { + // D4/D10: a repository is dialect-neutral. It talks to the ORM through + // @mikro-orm/core and @mikro-orm/sql — never to a concrete driver package, + // never to better-sqlite3 (the driver is wired once, in src/db/orm-driver.ts) + // — and it never spells raw SQL inline: every database function goes through + // src/db/dialect/sql-functions.ts, which dispatches on the live platform. + // + // Both rules restate the blocks above for the same flat-config reason: the + // last matching config object's options REPLACE, not extend, the earlier + // ones, so dropping either restatement would open a hole under + // src/db/repositories/ only. + files: ['src/db/repositories/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@mikro-orm/sqlite', + message: + 'Repositories are dialect-neutral: import from @mikro-orm/sql / @mikro-orm/core. The driver lives in src/db/orm-driver.ts.', + }, + { + name: '@mikro-orm/postgresql', + message: + 'Repositories are dialect-neutral: import from @mikro-orm/sql / @mikro-orm/core. The driver lives in src/db/orm-driver.ts.', + }, + { + name: 'better-sqlite3', + message: + 'Repositories are dialect-neutral: import from @mikro-orm/sql / @mikro-orm/core. The driver lives in src/db/orm-driver.ts.', + }, + ], + patterns: [ + { + group: ['**/services/*', '**/services/**/*'], + message: + 'src/services/ is deleted. New backend code goes to src/nest// (service + controller + module, registered in app.module.ts). See src/nest/README.md.', + }, + ], + }, + ], + 'no-restricted-syntax': [ + 'error', + { + selector: "MemberExpression[object.name='process'][property.name='env']", + message: + 'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.', + }, + { + selector: "MemberExpression[object.name='process'][property.value='env']", + message: + 'Read configuration via src/app-config (readEnv()/derive/tokens), not process.env. Exemptions: eslint.config.mjs + src/app-config/README.md.', + }, + { + selector: "CallExpression[callee.name='raw']", + message: + 'Dialect SQL goes through src/db/dialect/sql-functions.ts, which dispatches on the live MikroORM platform. A repository must not spell raw SQL.', + }, + { + selector: "TaggedTemplateExpression[tag.name='sql']", + message: + 'Dialect SQL goes through src/db/dialect/sql-functions.ts, which dispatches on the live MikroORM platform. A repository must not spell raw SQL.', + }, + ], + }, + }, { // The plugin RPC decorator kit is written to stay extractable into its own // package, so its dependencies are pinned to Nest plus the two host modules it diff --git a/server/package.json b/server/package.json index 5555cf055..d867de8b4 100644 --- a/server/package.json +++ b/server/package.json @@ -10,8 +10,12 @@ "reset-admin": "node reset-admin.js", "typecheck": "tsc --noEmit", "typecheck:tests": "tsc -p tsconfig.tests.json --noEmit", + "typecheck:scripts": "tsc -p tsconfig.scripts.json --noEmit", + "db:call-graph": "node scripts/db-call-graph.mjs", "gen:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts", "check:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts --check", + "gen:entities": "node --import tsx scripts/generate-entities.ts", + "check:entities": "node --import tsx scripts/generate-entities.ts --check", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", @@ -24,35 +28,51 @@ "test:e2e": "vitest run tests/e2e", "test:coverage": "vitest run --coverage" }, + "mikro-orm": { + "configPaths": [ + "./src/mikro-orm.config.ts", + "./dist/mikro-orm.config.js" + ] + }, "dependencies": { "@aws-lite/client": "^0.23.7", "@aws-lite/s3": "^0.2.7", + "@mikro-orm/cli": "^7.2.1", + "@mikro-orm/core": "^7.2.1", + "@mikro-orm/migrations": "^7.2.1", + "@mikro-orm/nestjs": "^7.1.0", + "@mikro-orm/postgresql": "^7.2.1", + "@mikro-orm/seeder": "^7.2.1", + "@mikro-orm/sql": "^7.2.1", + "@mikro-orm/sqlite": "^7.2.1", "@modelcontextprotocol/sdk": "^1.30.0", - "@nestjs/common": "^11.1.24", + "@nestjs/common": "^11.2.5", "@nestjs/config": "^4.0.4", - "@nestjs/core": "^11.1.24", - "@nestjs/platform-express": "^11.1.24", - "@nestjs/platform-ws": "^11.1.29", + "@nestjs/core": "^11.2.5", + "@nestjs/platform-express": "^11.2.5", + "@nestjs/platform-ws": "^11.2.5", "@nestjs/schedule": "^6.1.3", - "@nestjs/swagger": "^11.4.5", - "@nestjs/websockets": "^11.1.29", - "@simplewebauthn/server": "^13.3.2", + "@nestjs/swagger": "^11.4.7", + "@nestjs/websockets": "^11.2.5", + "@simplewebauthn/server": "^13.3.3", "@trek/shared": "*", - "archiver": "^6.0.1", + "archiver": "^6.0.2", "bcryptjs": "^2.4.3", "better-sqlite3": "^13.0.3", - "compression": "^1.8.0", + "compression": "^1.8.2", "cookie-parser": "^1.4.7", - "cors": "^2.8.5", + "cors": "^2.8.6", "cron": "^4.4.0", - "dotenv": "^16.4.1", + "dotenv": "^16.6.1", "exifr": "7.1.3", - "express": "^4.18.3", - "fast-xml-parser": "^5.5.10", - "helmet": "^8.1.0", + "express": "^4.22.3", + "fast-xml-parser": "^5.11.1", + "helmet": "^8.3.0", "jimp": "^1.6.1", "json5": "^2.2.3", - "jsonwebtoken": "^9.0.2", + "jsonwebtoken": "^9.0.3", + "kysely": "^0.29.6", + "multer": "^2.4.0", "nestjs-zod": "^5.5.0", "nodemailer": "^10.0.13", "otplib": "^12.0.1", @@ -60,62 +80,63 @@ "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "semver": "^7.7.4", + "semver": "^7.8.5", "tsconfig-paths": "^4.2.0", - "tsx": "^4.21.0", - "typescript": "^6.0.2", + "tsx": "^4.23.15", + "typescript": "^6.0.3", "tz-lookup": "^6.1.25", "undici": "^7.30.0", - "unzipper": "^0.12.3", - "uuid": "^14.0.0", - "ws": "^8.21.0", - "zod": "^4.3.6" + "unzipper": "^0.12.5", + "uuid": "^14.0.2", + "ws": "^8.21.3", + "zod": "^4.6.5" }, "overrides": { "hono": "^4.12.16", "@hono/node-server": "^1.19.13", "picomatch": "^4.0.4", "ip-address": "^10.1.1", - "multer": "^2.3.0", - "ws": "^8.21.0", + "multer": "^2.4.0", + "ws": "^8.21.3", "qs": "^6.15.2", "file-type": "^21.3.4" }, "devDependencies": { "@aws-lite/s3-types": "^0.2.8", "@eslint/js": "^10.0.1", - "@nestjs/testing": "^11.1.24", - "@swc/core": "^1.15.40", + "@mikro-orm/entity-generator": "^7.2.1", + "@nestjs/testing": "^11.2.5", + "@swc/core": "^1.16.2", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/archiver": "^7.0.0", "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", - "@types/compression": "^1.8.0", + "@types/compression": "^1.8.1", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", "@types/jsonwebtoken": "^9.0.10", - "@types/multer": "^2.1.0", - "@types/node": "^25.5.0", - "@types/nodemailer": "^8.0.1", - "@types/qrcode": "^1.5.5", - "@types/semver": "^7.7.1", + "@types/multer": "^2.2.0", + "@types/node": "^25.9.8", + "@types/nodemailer": "^8.0.2", + "@types/qrcode": "^1.5.6", + "@types/semver": "^7.8.0", "@types/supertest": "^6.0.3", "@types/unzipper": "^0.10.11", "@types/uuid": "^10.0.0", "@types/ws": "^8.18.1", "@vitest/coverage-istanbul": "^4.1.11", "@vitest/coverage-v8": "^4.1.11", - "eslint": "^9.18.0", - "eslint-config-flat-gitignore": "^2.3.0", - "eslint-config-prettier": "^10.0.1", - "eslint-plugin-prettier": "^5.2.2", - "nodemon": "^3.1.0", - "prettier": "^3.8.3", + "eslint": "^9.39.5", + "eslint-config-flat-gitignore": "^2.4.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "nodemon": "^3.1.14", + "prettier": "^3.9.8", "prettier-plugin-organize-imports": "^4.3.0", "supertest": "^7.2.2", - "typescript-eslint": "^8.58.2", - "unplugin-swc": "^1.5.9", + "typescript-eslint": "^8.70.0", + "unplugin-swc": "^1.6.0", "vitest": "^4.1.11" } } diff --git a/server/scripts/__fixtures__/sync-db-sample.ts b/server/scripts/__fixtures__/sync-db-sample.ts new file mode 100644 index 000000000..5ed794cbf --- /dev/null +++ b/server/scripts/__fixtures__/sync-db-sample.ts @@ -0,0 +1,23 @@ +/** + * Fixture for `db-call-graph.mjs --sync --tx --root scripts/__fixtures__`: one class that + * still touches the database synchronously and opens a raw transaction, so the two gates + * CI runs (`--sync`, `--tx`) each have a known-bad case to report. `unawaited-sample.ts` + * beside it is clean under both flags. + */ + +interface RawDb { + get(sql: string, ...args: unknown[]): unknown; + transaction(fn: () => T): T; +} + +export class SyncSample { + private db: RawDb = { get: () => undefined, transaction: (fn) => fn() }; + + readSync(id: number): unknown { + return this.db.get('SELECT 1 WHERE id = ?', id); // sync DB-touching method + } + + writeInTx(): void { + this.db.transaction(() => undefined); // raw transaction site + } +} diff --git a/server/scripts/__fixtures__/unawaited-sample.ts b/server/scripts/__fixtures__/unawaited-sample.ts new file mode 100644 index 000000000..0de9cef92 --- /dev/null +++ b/server/scripts/__fixtures__/unawaited-sample.ts @@ -0,0 +1,60 @@ +/** + * Fixture for `db-call-graph.mjs --unawaited --root scripts/__fixtures__`. Not part of the + * server build or its typecheck — it exists only so the detector has something small and + * known-good/known-bad to run against. Three GOOD call shapes should produce no findings; + * five BAD ones should each produce exactly one, with a distinct reason tag. + */ + +class Dep { + async fetchValue(): Promise { + return 1; + } +} + +export class Sample { + private dep: Dep = new Dep(); + + async loadOne(): Promise { + return 1; + } + + // ---- GOOD (3): correctly consumed, nothing to report ---- + + async good1() { + return await this.loadOne(); // awaited + } + + async good2() { + return this.loadOne(); // returned directly — propagates the promise to the caller + } + + async good3() { + await Promise.all([this.loadOne(), this.dep.fetchValue()]); // Promise.all combinator + } + + // ---- BAD (5): used without await, one per reason tag ---- + + async bad1() { + const x = this.loadOne(); // stored-then-used + return { x }; + } + + async bad2() { + console.log(this.dep.fetchValue()); // argument + } + + async bad3() { + return { value: this.loadOne() }; // property + } + + async bad4() { + if (this.loadOne()) { + return 1; + } + return 0; // condition + } + + async bad5() { + this.loadOne(); // statement — result discarded + } +} diff --git a/server/scripts/build.mjs b/server/scripts/build.mjs index 24deb830a..0c840f5b4 100644 --- a/server/scripts/build.mjs +++ b/server/scripts/build.mjs @@ -1,8 +1,39 @@ import { execSync } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +// Resolved from this file rather than cwd, so the directory that gets removed is +// always this package's own dist no matter where the script is invoked from. +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const distDir = join(packageRoot, 'dist'); + +// tsc only ever adds to dist, so a module deleted or renamed in src leaves its +// compiled twin behind. For most code that is merely stale, but the migrator +// globs dist/db/migrations at boot: an orphan there is a live migration with no +// source, and it runs. Same for dist/db/seeders. Nothing else writes into dist — +// the client bundle goes to server/public and the runtime assets the Dockerfile +// needs are copied in separately — so it is safe to drop wholesale. +rmSync(distDir, { recursive: true, force: true }); + +let tscExitedNonZero = false; try { - execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' }); + execSync('tsc -p tsconfig.build.json', { stdio: 'inherit', cwd: packageRoot }); } catch { + tscExitedNonZero = true; +} + +// `noEmitOnError` is off, so tsc exits non-zero on type errors and still writes +// dist — that case is deliberate, and gated by `npm run typecheck`. A tsc that +// never ran at all (not on PATH, crashed) is indistinguishable from here, and +// now that dist is emptied first it would leave nothing behind while still +// reporting success. Checking for the entry point tells the two apart. +if (!existsSync(join(distDir, 'index.js'))) { + console.error('[build] tsc emitted nothing — dist is empty. Run via `npm run build` so tsc is on PATH.'); + process.exit(1); +} + +if (tscExitedNonZero) { console.warn('[build] tsc reported type errors — emitting anyway (gated by `npm run typecheck`).'); } diff --git a/server/scripts/db-call-graph.mjs b/server/scripts/db-call-graph.mjs new file mode 100644 index 000000000..9642616f0 --- /dev/null +++ b/server/scripts/db-call-graph.mjs @@ -0,0 +1,1021 @@ +#!/usr/bin/env node +/** + * The Phase 1 (async sweep) worklist and gate. + * + * WHAT IT MEASURES + * --sync every *synchronous* method under `server/src/**` that touches the + * database — directly (a `get/all/run/prepare/transaction/exec/pragma` + * or a `canAccessTrip/isOwner/rosterUserIds/getPlaceWithTags` call on a + * DatabaseService / raw handle) or transitively through a chain of + * *synchronous* `this.*` calls that does. An `async` callee is a + * stopping point for this walk: it is not traversed into, and its own + * DB reach (direct or transitive) is not attributed to the caller — so + * a sync method that only calls already-async methods (e.g. an + * `onApplicationBootstrap()` hook that schedules an async cron + * callback) is not listed here. That shape is `--unawaited`'s job, not + * `--sync`'s. These are exactly the methods the sweep's R1 turns into + * `async` ones. + * --tx every real `…​.transaction(` call site (the AST sees calls, so the six + * prose mentions inside doc comments that `grep` finds are not here). + * These are the sites R2 routes through `UnitOfWork.transactional`. + * --unawaited every call expression of the form `this..(…)`, + * `this.(…)`, or `(…)` (imported from a relative path + * resolving under `src/`) whose target resolves to an `async` + * method/function, where the call's result is used without `await` + * in a way that compiles under `strict: false` but is a live bug — + * stored in a variable then read, passed as a plain argument, used + * as an object-literal property value, tested in a condition or + * comparison, or member-accessed. Each finding carries a reason tag + * (`stored-then-used` / `argument` / `property` / `condition` / + * `compared` / `member` / `statement` / `other`). It does NOT flag + * `await x()`, `return x()` (any function), `void x()`, an argument + * of `Promise.all/allSettled/race/any`, the concise body of an arrow + * passed to `.map(` (the `Promise.all(arr.map(…))` pattern — but it + * DOES flag that shape for `.filter/.find/.some/.every/.sort/.forEach`), + * or a `.then(`/`.catch(`/`.finally(` chain (which also covers the + * fire-and-forget-with-`.catch(` case). It is a lint-shaped finder + * for shapes the type-aware ESLint rules (`no-floating-promises`, + * `no-misused-promises`) cannot see under `strict: false` — see the + * recipe for why those rules alone aren't enough during Phase 1. + * + * IT DOES NOT RESOLVE: calls through a `.bind` alias, a call reached + * via a local variable holding a method/function reference, a call + * on a dependency typed as an interface/token/union (same limit as + * `--sync`), or a call to a `export default`-exported function. Those + * stay the manual-grep's job — see the recipe. + * + * It exits 1 as soon as any requested list is non-empty, so a task can gate on it: + * `node scripts/db-call-graph.mjs --sync --tx --domains nest/days,nest/budget` + * + * R0 EXCEPTIONS (never listed, by either flag): `DatabaseService` itself, + * `src/db/**`, `src/demo/**` and `src/nest/plugins/host/plugin-data.service.ts`. + * They stay synchronous for the whole of Phase 1 — see the recipe's R0. + * + * IT IS SYNTACTIC. It parses with `ts.createSourceFile` and no type checker, so + * it resolves `this..()` by the dependency's *written* type name: a + * dependency typed as an interface, a token or a union is not followed, and a + * call through a local (`const s = f(); s.get(…)`) is not attributed. It is a + * worklist and a ratchet, not a proof. Derived from the one-shot inventory pass + * that produced `docs/superpowers/plans/phase1-inventory.{md,json}`; Appendix C + * there lists the same accuracy caveats in full. + * + * LIFETIME: Plan 4 deletes this script along with `DatabaseService` — once no + * raw handle is left to call synchronously, there is nothing for it to count. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SERVER = path.resolve(HERE, '..'); +const REPO = path.resolve(SERVER, '..'); +const require_ = createRequire(path.join(REPO, 'package.json')); +const ts = require_('typescript'); + +let SRC = path.join(SERVER, 'src'); + +// ---------------------------------------------------------------- CLI +function parseArgs(argv) { + const opts = { sync: false, tx: false, unawaited: false, domains: null, json: false, root: null }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--sync') opts.sync = true; + else if (a === '--tx') opts.tx = true; + else if (a === '--unawaited') opts.unawaited = true; + else if (a === '--json') opts.json = true; + else if (a === '--domains') opts.domains = splitDomains(argv[++i]); + else if (a.startsWith('--domains=')) opts.domains = splitDomains(a.slice('--domains='.length)); + // --root is intentionally undocumented: it exists so tests/unit/scripts/db-call-graph.test.ts + // can point the whole analysis at a tiny sample tree instead of server/src. + else if (a === '--root') opts.root = argv[++i]; + else if (a.startsWith('--root=')) opts.root = a.slice('--root='.length); + else if (a === '--help' || a === '-h') opts.help = true; + else { + process.stderr.write(`db-call-graph: unknown argument ${a}\n`); + opts.help = true; + opts.bad = true; + } + } + // No selector: report both --sync and --tx, which is what a task's gate wants. + // --unawaited is never implied — it's opt-in only. + if (!opts.sync && !opts.tx && !opts.unawaited) { + opts.sync = true; + opts.tx = true; + } + return opts; +} +function splitDomains(value) { + if (!value) return null; + const list = value + .split(',') + .map((d) => d.trim()) + .filter(Boolean); + return list.length ? list : null; +} +const USAGE = `Usage: node scripts/db-call-graph.mjs [--sync] [--tx] [--unawaited] [--domains a,b,…] [--json] + + --sync list synchronous DB-touching methods (direct + transitive) + --tx list real transaction call sites + --unawaited list calls to async methods/functions whose result is used without await + --domains a,b restrict to these inventory domain names (nest/days, systemNotices, …) + --json machine-readable output + (no --sync/--tx/--unawaited) --sync and --tx together + +Exits 1 when a requested list is non-empty. R0 exceptions (DatabaseService, +src/db/**, src/demo/**, plugins/host/plugin-data.service.ts) are never listed. +`; + +const opts = parseArgs(process.argv.slice(2)); +if (opts.help) { + process.stdout.write(USAGE); + process.exit(opts.bad ? 2 : 0); +} +if (opts.root) { + SRC = path.resolve(process.cwd(), opts.root); +} + +// ---------------------------------------------------------------- file walk +function walk(dir, acc = []) { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) { + const r = path.relative(SRC, p).replace(/\\/g, '/'); + // Generated/declarative trees with no call graph of their own. + if (r === 'db/migrations' || r === 'db/seeders' || r === 'db/entities' || r === 'typings') continue; + walk(p, acc); + } else if (ent.name.endsWith('.ts') && !ent.name.endsWith('.d.ts')) { + const r = path.relative(SRC, p).replace(/\\/g, '/'); + if (r === 'db/migrations.ts') continue; // legacy test-only schema replay + acc.push(p); + } + } + return acc; +} +const files = walk(SRC).sort(); + +// ---------------------------------------------------------------- constants +const DB_METHODS = new Set(['get', 'all', 'run', 'prepare', 'transaction', 'exec', 'pragma']); +const DB_HELPERS = new Set(['canAccessTrip', 'isOwner', 'rosterUserIds', 'getPlaceWithTags']); +const DB_IDENT_NAMES = new Set(['db', 'conn', 'tx', 'database', 'dbs']); +const DB_PROP_NAMES = new Set(['db', 'database', 'dbs']); +const DB_SERVICE_TYPES = new Set(['DatabaseService']); + +/** R0: the raw seam and the out-of-scope trees. See the recipe's R0. */ +const R0_CLASSES = new Set(['DatabaseService']); +function isR0File(relFile) { + return ( + relFile.startsWith('src/db/') || + relFile.startsWith('src/demo/') || + relFile === 'src/nest/plugins/host/plugin-data.service.ts' + ); +} + +function relServer(p) { + return path.relative(SERVER, p).replace(/\\/g, '/'); +} + +function domainOf(file) { + const r = path.relative(SRC, file).replace(/\\/g, '/'); + if (r.startsWith('nest/')) { + const seg = r.split('/')[1]; + return seg.includes('.') ? 'nest(root)' : `nest/${seg}`; + } + if (r.startsWith('nest-mcp/')) return 'nest-mcp'; + if (r.startsWith('systemNotices/')) return 'systemNotices'; + if (r.startsWith('demo/')) return 'demo'; + if (r.startsWith('mcp/')) return 'mcp'; + if (r.startsWith('middleware/')) return 'middleware'; + if (r.startsWith('db/')) return 'db'; + if (r.startsWith('app-config/')) return 'app-config'; + if (r.startsWith('utils/')) return 'utils'; + return 'src(root)'; +} + +// ---------------------------------------------------------------- parse pass +/** @type {Map} className -> first record with that name */ +const classes = new Map(); +/** @type {any[]} */ +const allClasses = []; +const txSites = []; + +function lineOf(sf, node) { + return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; +} + +function typeNameOf(typeNode) { + if (!typeNode) return null; + if (ts.isTypeReferenceNode(typeNode)) { + const n = typeNode.typeName; + return ts.isIdentifier(n) ? n.text : n.getText(); + } + return typeNode.getText().replace(/\s+/g, ' ').slice(0, 60); +} + +for (const file of files) { + const text = fs.readFileSync(file, 'utf8'); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, /* setParentNodes */ true, ts.ScriptKind.TS); + + // Identifiers imported from db/database denote a raw handle / helper. + const dbDatabaseImports = new Set(); + for (const st of sf.statements) { + if (!ts.isImportDeclaration(st) || !st.importClause) continue; + const spec = st.moduleSpecifier.text; + const names = []; + if (st.importClause.name) names.push(st.importClause.name.text); + const nb = st.importClause.namedBindings; + if (nb && ts.isNamedImports(nb)) for (const e of nb.elements) names.push(e.name.text); + if (nb && ts.isNamespaceImport(nb)) names.push(nb.name.text); + for (const n of names) if (/(^|\/)db\/database$/.test(spec)) dbDatabaseImports.add(n); + } + + const fileRel = relServer(file); + const fileDomain = domainOf(file); + + // ---- gather class-likes and module-level functions + const containers = []; + for (const st of sf.statements) { + if (ts.isClassDeclaration(st) && st.name) containers.push({ kind: 'class', node: st, name: st.name.text }); + } + const moduleFns = []; + for (const st of sf.statements) { + if (ts.isFunctionDeclaration(st) && st.name && st.body) { + moduleFns.push({ + name: st.name.text, + node: st, + async: !!st.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword), + }); + } + if (ts.isVariableStatement(st)) { + for (const d of st.declarationList.declarations) { + if (d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)) && ts.isIdentifier(d.name)) { + moduleFns.push({ + name: d.name.text, + node: d.initializer, + async: !!d.initializer.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword), + }); + } + } + } + } + if (moduleFns.length) { + containers.push({ kind: 'module', node: sf, name: `${path.basename(file, '.ts')} (module)`, fns: moduleFns }); + } + + for (const c of containers) { + const deps = {}; // propName -> written type name + const dbProps = new Set(); + const methods = []; + let classRec; + + if (c.kind === 'class') { + for (const m of c.node.members) { + if (ts.isConstructorDeclaration(m)) { + for (const p of m.parameters) { + if (!ts.isIdentifier(p.name)) continue; + const tn = typeNameOf(p.type); + deps[p.name.text] = tn; + if (DB_SERVICE_TYPES.has(tn) || DB_PROP_NAMES.has(p.name.text)) dbProps.add(p.name.text); + } + } else if (ts.isPropertyDeclaration(m) && ts.isIdentifier(m.name)) { + const tn = typeNameOf(m.type); + if (tn && !deps[m.name.text]) deps[m.name.text] = tn; + if ((tn && DB_SERVICE_TYPES.has(tn)) || DB_PROP_NAMES.has(m.name.text)) dbProps.add(m.name.text); + } + } + // `private get db() { return this.dbs.connection; }` — the alias 14 services use. + for (const m of c.node.members) { + if (ts.isGetAccessorDeclaration(m) && m.name && ts.isIdentifier(m.name) && m.body) { + const stmts = m.body.statements; + const only = stmts.length === 1 && ts.isReturnStatement(stmts[0]) ? stmts[0].expression : null; + const aliasesDb = + only && + ts.isPropertyAccessExpression(only) && + ((only.expression.kind === ts.SyntaxKind.ThisKeyword && dbProps.has(only.name.text)) || + (only.name.text === 'connection' && + ts.isPropertyAccessExpression(only.expression) && + only.expression.expression.kind === ts.SyntaxKind.ThisKeyword && + dbProps.has(only.expression.name.text))); + const tn = typeNameOf(m.type); + if (aliasesDb || (tn && DB_SERVICE_TYPES.has(tn))) { + dbProps.add(m.name.text); + if (!deps[m.name.text]) deps[m.name.text] = 'DatabaseService'; + } + } + } + for (const m of c.node.members) { + const mods = ts.getModifiers(m); + if (ts.isMethodDeclaration(m) && m.name && m.body) { + methods.push({ + name: m.name.getText(sf), + kind: 'method', + body: m.body, + async: !!mods?.some((x) => x.kind === ts.SyntaxKind.AsyncKeyword), + generator: !!m.asteriskToken, // async *foo() — consumed via `for await`/`yield*`, not `await`; see --unawaited + line: lineOf(sf, m), + }); + } else if ( + ts.isPropertyDeclaration(m) && + m.initializer && + (ts.isArrowFunction(m.initializer) || ts.isFunctionExpression(m.initializer)) && + m.name + ) { + methods.push({ + name: m.name.getText(sf), + kind: 'method', + body: m.initializer.body, + async: !!m.initializer.modifiers?.some((x) => x.kind === ts.SyntaxKind.AsyncKeyword), + line: lineOf(sf, m), + }); + } else if ( + ts.isPropertyDeclaration(m) && + m.initializer && + m.name && + (ts.isCallExpression(m.initializer) || + ts.isNewExpression(m.initializer) || + ts.isObjectLiteralExpression(m.initializer) || + ts.isArrayLiteralExpression(m.initializer)) + ) { + // e.g. `private readonly supervisor = create(fn, { onStatus: () => this.db.… })` + methods.push({ name: m.name.getText(sf), kind: 'field-init', body: m.initializer, async: false, line: lineOf(sf, m) }); + } else if (ts.isConstructorDeclaration(m) && m.body) { + methods.push({ name: 'constructor', kind: 'constructor', body: m.body, async: false, line: lineOf(sf, m) }); + } else if (ts.isGetAccessorDeclaration(m) && m.body && m.name) { + methods.push({ name: m.name.getText(sf), kind: 'getter', body: m.body, async: false, line: lineOf(sf, m) }); + } + } + classRec = { name: c.name, kind: 'class', file: fileRel, domain: fileDomain, deps, dbProps: [...dbProps], methods: [] }; + } else { + for (const f of c.fns) { + methods.push({ name: f.name, kind: 'function', body: f.node.body, async: f.async, line: lineOf(sf, f.node) }); + } + classRec = { name: c.name, kind: 'module', file: fileRel, domain: fileDomain, deps: {}, dbProps: [], methods: [] }; + } + + // ---- receiver classification + function receiverKind(expr) { + if (ts.isIdentifier(expr)) { + if (dbDatabaseImports.has(expr.text)) return { text: expr.text }; + if (DB_IDENT_NAMES.has(expr.text)) return { text: expr.text }; + return null; + } + if (ts.isPropertyAccessExpression(expr)) { + if (expr.expression.kind === ts.SyntaxKind.ThisKeyword && dbProps.has(expr.name.text)) { + return { text: `this.${expr.name.text}` }; + } + if ( + expr.name.text === 'connection' && + ts.isPropertyAccessExpression(expr.expression) && + expr.expression.expression.kind === ts.SyntaxKind.ThisKeyword && + dbProps.has(expr.expression.name.text) + ) { + return { text: `this.${expr.expression.name.text}.connection` }; + } + if (expr.expression.kind === ts.SyntaxKind.ThisKeyword && expr.name.text === 'conn') { + return { text: 'this.conn' }; + } + } + return null; + } + + // ---- walk each method body + for (const m of methods) { + const dbCalls = []; + const calls = []; // {kind:'self'|'dep', dep, depType, method} + + const visit = (node) => { + if (node !== m.body && (ts.isClassDeclaration(node) || ts.isClassExpression(node))) return; + if (ts.isCallExpression(node)) { + const callee = node.expression; + if (ts.isPropertyAccessExpression(callee)) { + const name = callee.name.text; + const recv = receiverKind(callee.expression); + if (recv && (DB_METHODS.has(name) || DB_HELPERS.has(name))) { + dbCalls.push({ recv: recv.text, method: name, line: lineOf(sf, node) }); + if (name === 'transaction') { + txSites.push({ + file: fileRel, + domain: fileDomain, + class: classRec.name, + method: m.name, + line: lineOf(sf, node), + receiver: recv.text, + }); + } + } + if (callee.expression.kind === ts.SyntaxKind.ThisKeyword) { + calls.push({ kind: 'self', method: name }); + } else if ( + ts.isPropertyAccessExpression(callee.expression) && + callee.expression.expression.kind === ts.SyntaxKind.ThisKeyword + ) { + const dep = callee.expression.name.text; + if (Object.prototype.hasOwnProperty.call(deps, dep)) { + calls.push({ kind: 'dep', dep, depType: deps[dep], method: name }); + } + } + } else if (ts.isIdentifier(callee)) { + if (DB_HELPERS.has(callee.text) && dbDatabaseImports.has(callee.text)) { + dbCalls.push({ recv: '(import)', method: callee.text, line: lineOf(sf, node) }); + } + if (c.kind === 'module' && moduleFns.some((f) => f.name === callee.text)) { + calls.push({ kind: 'self', method: callee.text }); + } + } + } + ts.forEachChild(node, visit); + }; + if (m.body) visit(m.body); + + classRec.methods.push({ + name: m.name, + kind: m.kind, + line: m.line, + async: m.async, + generator: !!m.generator, + dbCalls, + dbCallCount: dbCalls.length, + calls, + }); + } + + allClasses.push(classRec); + if (!classes.has(classRec.name)) classes.set(classRec.name, classRec); + } +} + +// ------------------------------------------------- transitive DB closure +const methodKey = (cls, m) => `${cls}#${m}`; +const methodIndex = new Map(); +for (const c of allClasses) for (const m of c.methods) methodIndex.set(methodKey(c.name, m.name), { cls: c, m }); + +function calleeKeys(c, m) { + const out = []; + for (const call of m.calls) { + if (call.kind === 'self') out.push(methodKey(c.name, call.method)); + else if (call.kind === 'dep' && call.depType && classes.has(call.depType)) out.push(methodKey(call.depType, call.method)); + } + return out; +} + +for (const c of allClasses) for (const m of c.methods) m.directDb = m.dbCallCount > 0; + +for (const c of allClasses) { + for (const m of c.methods) { + const seen = new Set(); + const stack = calleeKeys(c, m).slice(); + const dbCallees = new Set(); + while (stack.length) { + const k = stack.pop(); + if (seen.has(k)) continue; + seen.add(k); + const t = methodIndex.get(k); + if (!t) continue; + // An async callee stops the walk: neither its own DB reach nor anything further + // behind it is attributed to the (sync) caller — that's --unawaited's job. See header. + if (t.m.async) continue; + if (t.m.directDb) dbCallees.add(k); + for (const nk of calleeKeys(t.cls, t.m)) if (!seen.has(nk)) stack.push(nk); + } + m.transitiveDbCallees = [...dbCallees].sort(); + m.touchesDb = m.directDb || dbCallees.size > 0; + } +} + +// ------------------------------------------------- selection +const domainFilter = opts.domains ? new Set(opts.domains) : null; +const unknownDomains = domainFilter + ? [...domainFilter].filter((d) => !allClasses.some((c) => c.domain === d) && !files.some((f) => domainOf(f) === d)) + : []; +function inScope(rec) { + if (isR0File(rec.file) || R0_CLASSES.has(rec.name)) return false; + if (domainFilter && !domainFilter.has(rec.domain)) return false; + return true; +} + +/** A `constructor` / field initializer / getter cannot simply become `async` — R1.3/R1.5/R1.6. */ +const CANNOT_BE_ASYNC = new Set(['constructor', 'field-init']); + +const syncMethods = []; +const restructureSites = []; +for (const c of allClasses) { + if (!inScope(c)) continue; + for (const m of c.methods) { + if (!m.touchesDb || m.async) continue; + const entry = { + domain: c.domain, + file: c.file, + class: c.name, + container: c.kind, + method: m.name, + methodKind: m.kind, + line: m.line, + direct: m.directDb, + dbCallSites: m.dbCallCount, + via: m.directDb ? [] : m.transitiveDbCallees, + }; + if (CANNOT_BE_ASYNC.has(m.kind)) restructureSites.push(entry); + else syncMethods.push(entry); + } +} +const txSelected = txSites.filter((t) => { + if (isR0File(t.file) || R0_CLASSES.has(t.class)) return false; + if (domainFilter && !domainFilter.has(t.domain)) return false; + return true; +}); + +const byDomainClass = (a, b) => a.domain.localeCompare(b.domain) || a.file.localeCompare(b.file) || a.line - b.line; +syncMethods.sort(byDomainClass); +restructureSites.sort(byDomainClass); +txSelected.sort(byDomainClass); + +// ------------------------------------------------- --unawaited +/** + * Everything below is purely syntactic (same posture as the rest of the file): + * it classifies a call expression's *immediate* AST context, and resolves its + * target through the `classes`/`deps` maps the main pass already built (for + * `this.` / `this..`) or a fresh per-file import map (for a plain + * `(…)` call). See the header comment for exactly what it does and + * does not see. + */ +/** Walk up through no-op-at-runtime wrappers (parens, `as`/`satisfies`, postfix `!`) and through + * a ternary branch (`cond ? call() : x` propagates `call()`'s result exactly like `call()` would + * on its own) to the expression a caller actually treats as "the call's result". */ +function effectiveNode(node) { + let n = node; + for (;;) { + const p = n.parent; + if (!p) return n; + if (ts.isParenthesizedExpression(p) || ts.isAsExpression(p) || ts.isNonNullExpression(p) || ts.isSatisfiesExpression?.(p)) { + n = p; + continue; + } + if (ts.isConditionalExpression(p) && (p.whenTrue === n || p.whenFalse === n)) { + n = p; + continue; + } + return n; + } +} +const COMPARE_OPS = new Set([ + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.LessThanToken, + ts.SyntaxKind.GreaterThanToken, + ts.SyntaxKind.LessThanEqualsToken, + ts.SyntaxKind.GreaterThanEqualsToken, +]); +const LOGICAL_OPS = new Set([ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken]); +const ARROW_ALWAYS_REPORT = new Set(['filter', 'find', 'some', 'every', 'sort', 'forEach']); + +/** The property/method name of a call `X.(arrow)` when `arrow` is one of its arguments, else null. */ +function arrowCallContext(arrow) { + const call = arrow.parent; + if (!call || !ts.isCallExpression(call) || !call.arguments.includes(arrow)) return null; + const callee = call.expression; + if (!ts.isPropertyAccessExpression(callee)) return null; + return callee.name.text; +} +/** `eff` is (via an optional spread) a direct element of an array passed to Promise.all/allSettled/race/any. */ +function isPromiseCombinatorArrayElement(eff) { + let n = eff; + let p = n.parent; + if (p && ts.isSpreadElement(p)) { + n = p; + p = n.parent; + } + if (!p || !ts.isArrayLiteralExpression(p)) return false; + const call = p.parent; + if (!call || !ts.isCallExpression(call) || !call.arguments.includes(p)) return false; + const callee = call.expression; + return ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + callee.expression.text === 'Promise' && + ['all', 'allSettled', 'race', 'any'].includes(callee.name.text) + ); +} +/** True when `node`'s result is used in a way this recipe treats as already-safe. */ +function isUnawaitedSkip(node) { + const eff = effectiveNode(node); + const parent = eff.parent; + if (!parent) return false; + if (ts.isAwaitExpression(parent) && parent.expression === eff) return true; + if (ts.isReturnStatement(parent) && parent.expression === eff) return true; + if (ts.isVoidExpression(parent) && parent.expression === eff) return true; + // A `.then(`/`.catch(`/`.finally(` chain — including the fire-and-forget-with-.catch( shape. + if ( + ts.isPropertyAccessExpression(parent) && + parent.expression === eff && + ['then', 'catch', 'finally'].includes(parent.name.text) && + parent.parent && + ts.isCallExpression(parent.parent) && + parent.parent.expression === parent + ) { + return true; + } + if (isPromiseCombinatorArrayElement(eff)) return true; + if (ts.isArrowFunction(parent) && parent.body === eff) { + const mc = arrowCallContext(parent); + return !(mc && ARROW_ALWAYS_REPORT.has(mc)); // default: implicit-return-like (the `.map(` pattern) — skip + } + return false; +} +/** `async *foo()` returns an AsyncGenerator, not a Promise — `await` doesn't consume it (and isn't + * expected to); only `for await (… of call())` or a delegating `yield* call()` does. */ +function isGeneratorConsumedSafely(node) { + const eff = effectiveNode(node); + const parent = eff.parent; + if (!parent) return false; + if (ts.isForOfStatement(parent) && parent.expression === eff && parent.awaitModifier) return true; + if (ts.isYieldExpression(parent) && parent.expression === eff && parent.asteriskToken) return true; + return false; +} +/** Reason tag for a call whose result is used without await (only called when NOT skipped). */ +function classifyUnawaitedShape(node) { + const eff = effectiveNode(node); + const parent = eff.parent; + if (!parent) return 'other'; + if (ts.isVariableDeclaration(parent) && parent.initializer === eff) return 'stored-then-used'; + if (ts.isPropertyAssignment(parent) && parent.initializer === eff) return 'property'; + if (ts.isBinaryExpression(parent)) { + const op = parent.operatorToken.kind; + if (COMPARE_OPS.has(op) && (parent.left === eff || parent.right === eff)) return 'compared'; + if (LOGICAL_OPS.has(op) && (parent.left === eff || parent.right === eff)) return 'condition'; + } + if (ts.isIfStatement(parent) && parent.expression === eff) return 'condition'; + if (ts.isConditionalExpression(parent) && parent.condition === eff) return 'condition'; + if (ts.isWhileStatement(parent) && parent.expression === eff) return 'condition'; + if (ts.isDoStatement(parent) && parent.expression === eff) return 'condition'; + if ((ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent)) && parent.expression === eff) { + return 'member'; + } + if ((ts.isCallExpression(parent) || ts.isNewExpression(parent)) && parent.arguments?.includes(eff)) return 'argument'; + if (ts.isExpressionStatement(parent)) return 'statement'; + if (ts.isArrowFunction(parent) && parent.body === eff) { + const mc = arrowCallContext(parent); + if (mc && ['filter', 'find', 'some', 'every'].includes(mc)) return 'condition'; // predicate result discarded + if (mc === 'forEach') return 'statement'; // return value discarded, same as an expression statement + } + return 'other'; +} + +function collectExportedFnAsyncness(sf) { + const out = new Map(); // name -> { async, generator } + for (const st of sf.statements) { + if (ts.isFunctionDeclaration(st) && st.name && st.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + out.set(st.name.text, { + async: !!st.modifiers.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword), + generator: !!st.asteriskToken, + }); + } else if (ts.isVariableStatement(st) && st.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) { + for (const d of st.declarationList.declarations) { + if ( + d.initializer && + (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)) && + ts.isIdentifier(d.name) + ) { + out.set(d.name.text, { + async: !!d.initializer.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword), + // Arrow functions can't be generators; a function expression can (`async function* () {}`). + generator: ts.isFunctionExpression(d.initializer) ? !!d.initializer.asteriskToken : false, + }); + } + } + } + } + return out; +} +/** Resolve a relative import specifier to a `.ts` file (or its `index.ts`) under the tree, else null. */ +function resolveModuleFile(fromFile, specifier) { + if (!specifier.startsWith('.')) return null; // node_modules / path-mapped — not "a file under src/" + const base = path.resolve(path.dirname(fromFile), specifier); + for (const cand of [`${base}.ts`, path.join(base, 'index.ts')]) { + if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; + } + return null; +} +/** Nearest enclosing function-like body of `node`, or null (e.g. a class-field initializer). */ +function findEnclosingFunctionBody(node) { + let n = node.parent; + while (n) { + if ( + ts.isFunctionDeclaration(n) || + ts.isFunctionExpression(n) || + ts.isArrowFunction(n) || + ts.isMethodDeclaration(n) || + ts.isGetAccessorDeclaration(n) || + ts.isSetAccessorDeclaration(n) || + ts.isConstructorDeclaration(n) + ) { + return n.body ?? null; + } + n = n.parent; + } + return null; +} +/** + * `const x = this.asyncMethod()` is only safe when *every* later read of `x` in its + * declaring function is itself one of the safe shapes `isUnawaitedSkip` recognizes + * (`await x`, `return x`, `Promise.all([x, …])`, …) — the in-flight/memoized-promise + * pattern (`this.inFlight.set(url, task); … return await task;`). Matches by name + * within the enclosing function body only (no real scope/shadowing analysis — see + * the header's limits); a variable this can't find a later read for is reported, + * same as the declaration-only heuristic would. + */ +function isVariableUsedSafely(declNode, varName) { + const scopeBody = findEnclosingFunctionBody(declNode); + if (!scopeBody) return false; + let sawReference = false; + let allSafe = true; + const visit = (node) => { + if (ts.isIdentifier(node) && node.text === varName && node !== declNode.name) { + const p = node.parent; + const isBindingOrPropertyName = + (ts.isVariableDeclaration(p) && p.name === node) || + (ts.isParameter(p) && p.name === node) || + (ts.isBindingElement(p) && p.name === node) || + (ts.isPropertyAccessExpression(p) && p.name === node); + if (!isBindingOrPropertyName) { + sawReference = true; + if (!isUnawaitedSkip(node)) allSafe = false; + } + } + ts.forEachChild(node, visit); + }; + visit(scopeBody); + return sawReference && allSafe; +} + +function classMemberBodies(classNode, sf) { + const out = []; + for (const m of classNode.members) { + if (ts.isMethodDeclaration(m) && m.name && m.body) { + out.push({ name: m.name.getText(sf), body: m.body }); + } else if ( + ts.isPropertyDeclaration(m) && + m.initializer && + (ts.isArrowFunction(m.initializer) || ts.isFunctionExpression(m.initializer)) && + m.name + ) { + out.push({ name: m.name.getText(sf), body: m.initializer.body }); + } else if (ts.isConstructorDeclaration(m) && m.body) { + out.push({ name: 'constructor', body: m.body }); + } else if (ts.isGetAccessorDeclaration(m) && m.body && m.name) { + out.push({ name: m.name.getText(sf), body: m.body }); + } + } + return out; +} + +let unawaitedFindings = []; +if (opts.unawaited) { + const exportedFnsByFile = new Map(); + const parsed = new Map(); // file -> {sf, text} + for (const file of files) { + const text = fs.readFileSync(file, 'utf8'); + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS); + parsed.set(file, sf); + exportedFnsByFile.set(file, collectExportedFnAsyncness(sf)); + } + + function resolveCallTarget(node, classRec, selfAsyncByName, importMap) { + const callee = node.expression; + if (ts.isPropertyAccessExpression(callee)) { + const methodName = callee.name.text; + const obj = callee.expression; + if (obj.kind === ts.SyntaxKind.ThisKeyword) { + const self = selfAsyncByName?.get(methodName); + if (self) return { desc: `this.${methodName}`, async: self.async, generator: self.generator }; + return null; + } + if (ts.isPropertyAccessExpression(obj) && obj.expression.kind === ts.SyntaxKind.ThisKeyword) { + const dep = obj.name.text; + const depType = classRec?.deps?.[dep]; + const depClass = depType ? classes.get(depType) : null; + const m = depClass?.methods.find((mm) => mm.name === methodName); + if (m) return { desc: `this.${dep}.${methodName}`, async: m.async, generator: !!m.generator }; + } + return null; + } + if (ts.isIdentifier(callee)) { + const imp = importMap.get(callee.text); + if (!imp) return null; + const asyncMap = exportedFnsByFile.get(imp.file); + const entry = asyncMap?.get(imp.imported); + if (!entry) return null; + return { desc: `import:${callee.text}`, async: entry.async, generator: entry.generator }; + } + return null; + } + + for (const file of files) { + const relFile = relServer(file); + if (isR0File(relFile)) continue; + const fileDomain = domainOf(file); + if (domainFilter && !domainFilter.has(fileDomain)) continue; + + const sf = parsed.get(file); + const importMap = new Map(); + for (const st of sf.statements) { + if (!ts.isImportDeclaration(st) || !st.importClause || !ts.isStringLiteral(st.moduleSpecifier)) continue; + const targetFile = resolveModuleFile(file, st.moduleSpecifier.text); + if (!targetFile) continue; + const clause = st.importClause; + const nb = clause.namedBindings; + if (nb && ts.isNamedImports(nb)) { + for (const el of nb.elements) { + importMap.set(el.name.text, { file: targetFile, imported: el.propertyName ? el.propertyName.text : el.name.text }); + } + } + // A default import's local name can't be matched against a named-export map — not resolved (see header). + } + + const record = (node, className, methodName) => { + const target = resolveCallTarget(node, className.classRec, className.selfAsyncByName, importMap); + if (!target || !target.async) return; + if (target.generator ? isGeneratorConsumedSafely(node) : isUnawaitedSkip(node)) return; + const reason = classifyUnawaitedShape(node); + if (reason === 'stored-then-used') { + const declNode = effectiveNode(node).parent; + if ( + ts.isVariableDeclaration(declNode) && + ts.isIdentifier(declNode.name) && + isVariableUsedSafely(declNode, declNode.name.text) + ) { + return; // every later read is already await'd / returned / combined — the in-flight-promise pattern + } + } + const text = node.getText(sf).replace(/\s+/g, ' ').trim(); + unawaitedFindings.push({ + domain: fileDomain, + file: relFile, + class: className.name, + method: methodName, + line: lineOf(sf, node), + call: text.length > 100 ? `${text.slice(0, 97)}...` : text, + target: target.desc, + reason, + }); + }; + + for (const st of sf.statements) { + if (!ts.isClassDeclaration(st) || !st.name) continue; + const className = st.name.text; + const classRec = allClasses.find((c) => c.file === relFile && c.name === className); + if (!classRec || R0_CLASSES.has(className)) continue; + const selfAsyncByName = new Map(); + for (const m of classRec.methods) { + if (!selfAsyncByName.has(m.name)) selfAsyncByName.set(m.name, { async: m.async, generator: !!m.generator }); + } + const ctx = { name: className, classRec, selfAsyncByName }; + + for (const member of classMemberBodies(st, sf)) { + const visit = (node) => { + if (node !== member.body && (ts.isClassDeclaration(node) || ts.isClassExpression(node))) return; + if (ts.isCallExpression(node)) record(node, ctx, member.name); + ts.forEachChild(node, visit); + }; + visit(member.body); + } + } + + // Module-level function bodies: only `(…)` calls apply (there is no `this`). + const moduleCtx = { name: `${path.basename(file, '.ts')} (module)`, classRec: null, selfAsyncByName: null }; + const moduleFnBodies = []; + for (const st of sf.statements) { + if (ts.isFunctionDeclaration(st) && st.name && st.body) moduleFnBodies.push({ name: st.name.text, body: st.body }); + if (ts.isVariableStatement(st)) { + for (const d of st.declarationList.declarations) { + if (d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)) && ts.isIdentifier(d.name)) { + moduleFnBodies.push({ name: d.name.text, body: d.initializer.body }); + } + } + } + } + for (const fn of moduleFnBodies) { + const visit = (node) => { + if (ts.isCallExpression(node)) record(node, moduleCtx, fn.name); + ts.forEachChild(node, visit); + }; + visit(fn.body); + } + } + + unawaitedFindings.sort( + (a, b) => a.domain.localeCompare(b.domain) || a.file.localeCompare(b.file) || a.line - b.line, + ); +} + +// ------------------------------------------------- output +const out = []; +function group(entries, key) { + const m = new Map(); + for (const e of entries) { + const k = key(e); + if (!m.has(k)) m.set(k, []); + m.get(k).push(e); + } + return m; +} + +if (opts.json) { + out.push( + JSON.stringify( + { + scope: { + domains: opts.domains ?? 'all', + r0Exceptions: ['DatabaseService', 'src/db/**', 'src/demo/**', 'src/nest/plugins/host/plugin-data.service.ts'], + }, + syncDbMethods: opts.sync ? syncMethods : undefined, + syncDbMethodCount: opts.sync ? syncMethods.length : undefined, + cannotBeAsync: opts.sync ? restructureSites : undefined, + transactionSites: opts.tx ? txSelected : undefined, + transactionSiteCount: opts.tx ? txSelected.length : undefined, + unawaitedCalls: opts.unawaited ? unawaitedFindings : undefined, + unawaitedCallCount: opts.unawaited ? unawaitedFindings.length : undefined, + unknownDomains, + }, + null, + 2, + ), + ); +} else { + if (unknownDomains.length) { + out.push(`! no such domain: ${unknownDomains.join(', ')} (names come from the inventory, e.g. nest/days, systemNotices)`); + out.push(''); + } + if (opts.sync) { + const scope = opts.domains ? opts.domains.join(', ') : 'all domains'; + if (!syncMethods.length) { + out.push(`sync DB-touching methods: none (${scope})`); + } else { + const direct = syncMethods.filter((e) => e.direct).length; + out.push( + `sync DB-touching methods: ${syncMethods.length} (${direct} direct, ${syncMethods.length - direct} transitive) in ${scope}`, + ); + for (const [k, entries] of group(syncMethods, (e) => `${e.domain}\u0000${e.file}\u0000${e.class}`)) { + const [domain, file, cls] = k.split('\u0000'); + const d = entries.filter((e) => e.direct).length; + out.push(''); + out.push(` ${domain} · ${cls} · ${file} (${d} direct, ${entries.length - d} transitive)`); + for (const e of entries) { + const detail = e.direct ? `${e.dbCallSites} db call site${e.dbCallSites === 1 ? '' : 's'}` : `via ${e.via.join(', ')}`; + out.push(` ${String(e.line).padStart(5)} ${e.method} — ${detail}`); + } + } + } + if (restructureSites.length) { + out.push(''); + out.push(` note: ${restructureSites.length} sync DB context(s) that cannot simply become async (recipe R1.5/R1.6 —`); + out.push(' restructure, or call an async helper with `void h().catch(…)`); not counted, not gating:'); + for (const e of restructureSites) out.push(` ${e.file}:${e.line} ${e.class}.${e.method} (${e.methodKind})`); + } + } + if (opts.tx) { + if (opts.sync) out.push(''); + const scope = opts.domains ? opts.domains.join(', ') : 'all domains'; + if (!txSelected.length) { + out.push(`transaction sites: none (${scope})`); + } else { + out.push(`transaction sites: ${txSelected.length} in ${scope}`); + for (const t of txSelected) { + out.push(` ${t.file}:${t.line} ${t.class}.${t.method} — ${t.receiver}.transaction(`); + } + } + } + if (opts.unawaited) { + if (opts.sync || opts.tx) out.push(''); + const scope = opts.domains ? opts.domains.join(', ') : 'all domains'; + if (!unawaitedFindings.length) { + out.push(`unawaited async call sites: none (${scope})`); + } else { + out.push(`unawaited async call sites: ${unawaitedFindings.length} in ${scope}`); + const byReason = group(unawaitedFindings, (e) => e.reason); + const reasonOrder = ['stored-then-used', 'argument', 'property', 'condition', 'compared', 'member', 'statement', 'other']; + const reasonSummary = reasonOrder + .filter((r) => byReason.has(r)) + .map((r) => `${r}=${byReason.get(r).length}`) + .join(', '); + out.push(` by reason: ${reasonSummary}`); + for (const [k, entries] of group(unawaitedFindings, (e) => `${e.domain}\u0000${e.file}\u0000${e.class}`)) { + const [domain, file, cls] = k.split('\u0000'); + out.push(''); + out.push(` ${domain} · ${cls} · ${file}`); + for (const e of entries) { + out.push(` ${String(e.line).padStart(5)} [${e.reason}] ${e.method} — ${e.call} (→ ${e.target})`); + } + } + } + } +} + +process.stdout.write(out.join('\n') + '\n'); + +const found = + (opts.sync && syncMethods.length > 0) || + (opts.tx && txSelected.length > 0) || + (opts.unawaited && unawaitedFindings.length > 0); +process.exit(found ? 1 : 0); diff --git a/server/scripts/generate-entities.ts b/server/scripts/generate-entities.ts new file mode 100644 index 000000000..f7f10c726 --- /dev/null +++ b/server/scripts/generate-entities.ts @@ -0,0 +1,2145 @@ +/** + * Re-runnable entity generator wrapper (ORM migration Plan 2, Task 2). + * + * `@mikro-orm/entity-generator` 7.2.1 already produces the Phase 0 shape + * (`Days`/`DayNotes`/`Trips`, see `docs/superpowers/specs/2026-09-21-orm-repository-migration-design.md` + * D1/D2) from the migrated schema once configured with a naming strategy whose + * `columnNameToProperty` is the identity — every scalar and FK twin comes out + * named exactly like its column. What it does NOT do on its own (verified by + * reading `node_modules/@mikro-orm/entity-generator/*.js` and by running this + * wrapper against the real schema, not by assumption): + * + * - it leaves multi-word relation names snake_case and inverse-collection + * names camelCase (the opposite of what Phase 0 does for each); + * - it never marks a relation `.hidden()`; + * - it emits `p.datetime()` / `p.unknown().columnType('REAL')` instead of + * `p.type(DbTimestampType)` / `p.double()`; + * - it leaves `updateRule('no action')` / `deleteRule('no action')` noise + * (SQLite reports "NO ACTION" for every FK that never named a rule); + * - integer autoincrement primary keys come out `.primary().nullable()` + * (SQLite's `PRAGMA table_info` carries no AUTOINCREMENT flag); + * - it never binds a `repository:`; + * - the "collision class" (an FK column with no `_id` suffix, e.g. + * `country`, `created_by`) gets a relation named after the column AND a + * same-named scalar twin — colliding — and even once renamed, the + * generator's own override mechanism for a to-one relation only ever + * emits `.name()`, which Task 0/1 proved is inert for a relation + * (`.joinColumn()` is the only thing that actually pins the FK + * column; see `.superpowers/sdd/2026-09-22-orm-phase2-entity-rewrite/task-0-report.md` + * and `task-1-report.md`, fact (c)). + * + * Each gap above is closed by a single named rule function, applied either at + * the metadata level (`onProcessedMetadata`, preferred — the MikroORM- + * documented hook for this exact purpose) or, only where the metadata cannot + * carry the fix into the rendered `defineEntity` call (traced against the + * actual renderer in `SourceFile.js`/`DefineEntitySourceFile.js`, not + * guessed), as a small text pass over the generated source. See + * `task-2-report.md` for which rule lives at which level and why. + * + * This script never writes into `src/db/entities/` on its own — `--write` is + * accepted (Task 3 uses it) but this task never passes it. It never opens + * `data/travel.db` either: it migrates a throwaway temp file (via the same + * `Migrator` extension `buildApp()` uses, configured directly rather than by + * importing `src/db/orm.ts` — that module transitively imports + * `src/db/database.ts`, which opens `resolveDbPath()` — i.e. the real + * database file — as a side effect of being imported at all). + * + * Usage: + * node --import tsx scripts/generate-entities.ts --out + * node --import tsx scripts/generate-entities.ts --write # Task 3 only + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { + ReferenceKind, + UnderscoreNamingStrategy, + type Connection, + type EntityMetadata, + type EntityProperty, + type ImportsResolver, + type Platform, +} from '@mikro-orm/core'; +import { EntityGenerator } from '@mikro-orm/entity-generator'; +import { Migrator } from '@mikro-orm/migrations'; +import { MikroORM as SqliteMikroORM } from '@mikro-orm/sqlite'; + +// --------------------------------------------------------------------------- +// Paths +// --------------------------------------------------------------------------- + +/** `server/` — every relative path below (migrations, repositories, entities) is anchored here. */ +const SERVER_ROOT = path.join(__dirname, '..'); +const ENTITIES_DIR = path.join(SERVER_ROOT, 'src/db/entities'); +const REPOSITORIES_DIR = path.join(SERVER_ROOT, 'src/db/repositories'); + +// --------------------------------------------------------------------------- +// Naming strategy — D1: scalars and FK twins are named exactly like their +// column (`day_id`, `sort_order`), never camelCased. Only `columnNameToProperty` +// is overridden; table names and everything else (`classToTableName`, +// `joinKeyColumnName`, `inverseSideName`, `manyToManyPropertyName`, …) stay the +// stock `UnderscoreNamingStrategy` behaviour, which is what lets the renamed +// relations below still compute the *same* FK column the schema already has +// (see RULE5_renameOwningRelations). +// --------------------------------------------------------------------------- +export class SnakeProps extends UnderscoreNamingStrategy { + override columnNameToProperty(column: string): string { + return column; + } +} + +// --------------------------------------------------------------------------- +// KNOWN_DIFFS — the only allowed differences between this wrapper's output +// (into a scratch dir) and the five hand-written reference entities +// (`Days`, `DayNotes`, `Trips`, `BudgetCategoryOrder`, `VacayUserSettings`). +// Anything else in the diff is a rule bug, not an entry to add here. +// --------------------------------------------------------------------------- +export const KNOWN_DIFFS = ` +1. Index duplicated onto the FK twin. The generator carries a relation's named + index (".index('idx_days_trip_id')") onto BOTH the owning relation AND its + persist(false) scalar twin; the hand-written files only ever declare it on + the relation. Harmless (SQLite would just create the same index once) — + verified against the actual generated output, not assumed. +2. Class-field TS annotation on an integer autoincrement primary key. The + hand-written files declare it "id?: number | null;" as an ergonomic hint + (you can build the entity before an id is assigned); the generator's own + renderer ties both the "?" and the "| null" suffix to metadata property + "nullable" (see SourceFile.js#getPropertyDefinition), and rule 3 below sets + nullable=false on this exact property so the metadata BUILDER CHAIN has no + stray ".nullable()" (matching the reference's *metadata*, which is what the + parity tests and the ORM itself read) — at the cost of the class field + rendering "id!: number & Opt;" instead (and the resulting "type Opt" import). + Purely a TypeScript-side annotation; it does not change runtime behaviour, + hydration, or the metadata the schema parity test compares. Confirmed by + generating the real schema, not assumed. It is also safe at the database + level despite the metadata's builder chain having no explicit + ".autoincrement()" call for this property: MikroORM's own + MetadataDiscovery#initAutoincrement (node_modules/@mikro-orm/core/metadata/MetadataDiscovery.js) + defaults "autoincrement ??= true" for any single numeric PK at discovery + time, so the ORM treats the generated and hand-written metadata + identically once loaded — confirmed by generating the real schema and + inserting through the written entity (Days.repository.test.ts's insert + passes). Task 3 should have its own parity test (a PARITY-010 or sibling) + asserting "autoincrement === true" in the *discovered* metadata for every + integer PK, so this inference is pinned rather than assumed going + forward. +3. Method-call order within a builder chain (e.g. ".deleteRule('cascade').hidden().index(...)" + vs the hand-written files' ".hidden().deleteRule('cascade').index(...)"). + The renderer assembles each property's options in its OWN fixed sequence + (kind-specific options — fieldName/deleteRule/updateRule/primary/generated — + then common flags — nullable/hidden/etc. — then index/unique), which the + hand-written files, authored before this generator produced this shape, did + not follow. Cosmetic only: every option present is the same, only the + textual order of independent chained calls differs. +4. "defaultRaw" renders as a backtick template literal ("\`CURRENT_TIMESTAMP\`") + rather than a single-quoted string ("'CURRENT_TIMESTAMP'"). SourceFile.js's + own renderer always uses a template literal for defaultRaw specifically (so + raw SQL containing "\${" is escaped rather than interpolated) — the + hand-written files predate this generator and used a plain string. + Semantically identical TypeScript. +5. A nullable column's DB-level default. Phase 0's own hand-written files are + not internally consistent about restating a NULLABLE column's default in + metadata at all: "DayNotes.sort_order" (nullable, "REAL DEFAULT 0") has + ".default(0)"; "DayNotes.icon" (nullable, "TEXT DEFAULT '📝'") has none + at all, relying only on the class-field initialiser. The wrapper's default + rule (RULE_normalizeLiteralDefaults) deliberately scopes itself to NOT + NULL columns only — see that rule's doc comment — and leaves whatever the + generator's own (occasionally "defaultRaw" instead of "default", or + entirely absent) rendering produces for a nullable column. This is exactly + Task 0's skipped PARITY-005 (column defaults) territory, not this task's. +6. Trailing blank line / whitespace at end of file, if any — a generator + formatting convention, immaterial to the parsed module. + +Fix round 1 (task-2-fix-brief.md) resolved the former #6 (the +VacayUserSettings.user_id PK-twin nullability judgment call) by taking the +generator's answer as correct and updating the hand-written file to match +(see .superpowers/sdd/2026-09-22-orm-phase2-entity-rewrite/task-2-review.md, +"#6 — generator or hand-written file?") — it is no longer a diff at all, so +it is no longer listed here. +`.trim(); + +// --------------------------------------------------------------------------- +// JSON columns — SQLite has no JSON storage class, so PRAGMA table_info can +// never tell the generator "this TEXT column is JSON-shaped"; that's always a +// judgement call an entity author made (today: exactly one column in the +// whole schema, `addons.config`, declared `p.json()`, grepped +// from src/db/entities). `JSON_COLUMNS` maps `table.column` -> the TypeScript +// type name to render (`p.json()`, never a bare `p.json()` or +// `p.json()` — Fix round 1, task-2-review.md C3: a stale +// `runtimeType` left the class field as `IType`, a new `any` in +// generated output). Every value here needs a matching declaration in +// `JSON_INTERFACES` below — `injectJsonInterfaces` throws otherwise. +// --------------------------------------------------------------------------- +export const JSON_COLUMNS: ReadonlyMap = new Map([['addons.config', 'AddonConfig']]); + +/** + * The interface declaration text `injectJsonInterfaces` inserts into a + * generated file for every `JSON_COLUMNS` type name it references. Not + * reconstructable from the schema (SQLite has no notion of a JSON column's + * shape) — this is a maintained list, the same shape as `JSON_COLUMNS` + * itself. `AddonConfig`'s shape is carried forward from the current + * hand-written `Addons.entity.ts`. + */ +const JSON_INTERFACES: Readonly> = { + AddonConfig: 'export interface AddonConfig {\n [key: string]: unknown;\n}\n', +}; + +// --------------------------------------------------------------------------- +// Boolean columns — same problem as JSON, the opposite direction: SQLite has +// no BOOLEAN storage class either (every "boolean" column is really +// INTEGER-affinity), so a plain `p.boolean()` column re-introspects as +// `p.integer()` and the class field silently degrades `boolean` -> `number` +// (Fix round 1, task-2-review.md I4). One column in the whole schema today +// (`addons.enabled`, grepped from src/db/entities — the only `p.boolean()` in +// the tree) — a maintained allow-list, same shape as `JSON_COLUMNS`. +// --------------------------------------------------------------------------- +export const BOOLEAN_COLUMNS: ReadonlySet = new Set(['addons.enabled']); + +// --------------------------------------------------------------------------- +// Rule 1 (metadata level): scalar type mapping. +// +// The naming strategy already makes every scalar/twin property name equal its +// column (no rule needed for that half of D1). What's left is the TYPE: the +// generator leaves a column it can't map cleanly as `p.unknown().columnType()`. +// On the current schema the only such family is SQLite's REAL affinity +// (MikroORM's SQLite platform never overrides `getDoubleDeclarationSQL`, so it +// stays abstract — confirmed in task-0-report.md while building the parity +// test's own normaliser). Anything else is unexpected: fail loudly, naming the +// column, rather than silently emitting a wrong or lossy type. +// --------------------------------------------------------------------------- +const UNKNOWN_COLUMN_TYPE_MAP: Readonly> = { + real: { type: 'double', runtimeType: 'number' }, +}; + +/** + * `prop.runtimeType` must move with `prop.type` here, not just the type name. + * Left stale (introspection sets it to `'unknown'` alongside `type: 'unknown'`), + * `SourceFile.js#breakdownOfIType` sees `prop.runtimeType !== rawType` (the + * freshly-retyped `'double'`'s own runtime, `'number'`) and treats that + * mismatch as genuine type ambiguity — it wraps the class field in + * `IType` and, separately, `needsExplicitColumnType()` also + * disagrees with itself and adds a redundant `.columnType('REAL')`. Both + * disappear once `runtimeType` is corrected. Confirmed by generating the real + * schema (`DayNotes.sort_order`) before and after this fix, not assumed. + */ +/** A scalar this run retyped (Rule 1's `p.unknown()` fix or Rule 2's `DbTimestampType`) — its metadata line may carry a redundant `.columnType()` (see below). */ +export interface RetypedScalarFixup { + className: string; + propName: string; +} + +export function RULE1_fixUnknownScalarTypes(metadata: EntityMetadata[]): RetypedScalarFixup[] { + const fixups: RetypedScalarFixup[] = []; + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + if (prop.type !== 'unknown') continue; + const columnType = (prop.columnTypes?.[0] ?? '').toLowerCase(); + const mapped = UNKNOWN_COLUMN_TYPE_MAP[columnType]; + if (!mapped) { + throw new Error( + `generate-entities: ${meta.tableName}.${prop.fieldNames.join(',')} has an unmapped column type ` + + `"${prop.columnTypes?.[0]}" (property "${meta.className}.${prop.name}"). Add it to ` + + 'UNKNOWN_COLUMN_TYPE_MAP once you know what it should render as — never guess.', + ); + } + prop.type = mapped.type; + prop.runtimeType = mapped.runtimeType; + fixups.push({ className: meta.className, propName: prop.name }); + } + } + return fixups; +} + +/** + * A JSON column this run marked — like `RetypedScalarFixup` (it needs the + * same `.columnType()` strip and is folded into `retypedScalars`) plus the + * TypeScript type name to render. + */ +export interface JsonColumnFixup extends RetypedScalarFixup { + typeName: string; +} + +/** + * Rule 1b (metadata level): mark known JSON columns so they render + * `p.json()` (see JSON_COLUMNS above). `prop.runtimeType` must move with + * `prop.type` here for the same reason Rule 1's doc comment gives — left at + * its introspected value (`'string'`, since SQLite stores JSON as TEXT), the + * mismatch against `JsonType`'s own always-`'any'` runtime type would still + * make `SourceFile.js#breakdownOfIType` treat the property as ambiguous and + * wrap the class field in `IType<..., any>`. `fixJsonClassFieldTypes` (a text + * pass, see below) closes what setting `runtimeType` here does not: MikroORM + * has no metadata escape from `IType` for a JSON-typed property at + * all (`JsonType#runtimeType` is a hardcoded getter, not derived from the + * property) — confirmed by generating the real schema and reading + * `JsonType.js`, not assumed. + */ +export function RULE1b_markJsonColumns(metadata: EntityMetadata[]): JsonColumnFixup[] { + const fixups: JsonColumnFixup[] = []; + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + const typeName = JSON_COLUMNS.get(`${meta.tableName}.${prop.fieldNames[0]}`); + if (!typeName) continue; + prop.type = 'json'; + prop.runtimeType = typeName; + fixups.push({ className: meta.className, propName: prop.name, typeName }); + } + } + return fixups; +} + +/** Rule 1c (metadata level): mark known boolean columns so they render `p.boolean()` (see BOOLEAN_COLUMNS above). */ +export function RULE1c_markBooleanColumns(metadata: EntityMetadata[]): RetypedScalarFixup[] { + const fixups: RetypedScalarFixup[] = []; + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + if (!BOOLEAN_COLUMNS.has(`${meta.tableName}.${prop.fieldNames[0]}`)) continue; + prop.type = 'boolean'; + // `runtimeType` must move with `type` here too, for the same reason + // Rule 1's doc comment gives: left at its introspected value + // (`'number'`, SQLite has no BOOLEAN storage class), the mismatch + // against `BooleanType`'s own runtime type ('boolean') would make + // `breakdownOfIType` treat the property as ambiguous and wrap the + // class field in `IType` — confirmed by generating + // the real schema before this fix, not assumed. + prop.runtimeType = 'boolean'; + // The introspected DB default is always the raw SQLite value (`0`/`1`, + // a number, since SQLite has no boolean storage class either) — + // coerce it to a real boolean so a `p.boolean()` column's `.default()` + // renders `.default(false)`, not the now ill-typed `.default(0)`. + if (typeof prop.default === 'number') prop.default = prop.default !== 0; + if (typeof prop.defaultRaw === 'string' && /^-?\d+$/.test(prop.defaultRaw)) { + prop.defaultRaw = prop.defaultRaw !== '0' ? 'true' : 'false'; + } + fixups.push({ className: meta.className, propName: prop.name }); + } + } + return fixups; +} + +/** + * Rule 1d (metadata level): a numeric scalar's class-field initialiser must + * never render `NaN`. + * + * `@mikro-orm/sql`'s own introspection (`DatabaseTable.js#getPropertyDeclaration` + * -> `getPropertyDefaultValue`) computes a NUMERIC column's `prop.default` as + * `+defaultValue` — a plain unary-plus coercion of the raw SQLite default + * text — whenever that text doesn't equal the literal, lowercase string + * `'null'`. SQLite reports the DDL's exact casing back through + * `PRAGMA table_info` (`'NULL'`, not `'null'`, for `DEFAULT NULL`), and + * `SchemaHelper#normalizeDefaultValue`'s own `'null'` check + * (`DatabaseTable.js` line ~869) is the same case-sensitive comparison — so + * neither the `DEFAULT NULL` case NOR a non-literal expression default + * (`DEFAULT (strftime('%s','now'))`) is ever recognised as "no usable + * numeric value", and `+'NULL'` / `+"(strftime('%s','now'))"` both evaluate + * to `NaN`. That `NaN` becomes `prop.default`, which + * `SourceFile.js#getPropertyDefinition` then treats as a genuine, usable + * default (`NaN` is neither `undefined` nor `null`) and renders `= NaN` as + * the class-field initialiser — confirmed by generating the real schema + * (`BudgetItems.persons`/`.days`, `IdempotencyKeys.created_at`, + * `BudgetItems.place_id`/`.reservation_id`, `PackingBags.user_id`) and by + * reading `node_modules/@mikro-orm/sql/schema/DatabaseTable.js`, not + * assumed. `prop.defaultRaw` itself is unaffected — it is threaded straight + * through to `.defaultRaw(...)` in the rendered schema and is correct there; + * only the class-field initialiser is wrong, and it is wrong specifically + * because the upstream library's `NaN` looks like "a real number" to the + * renderer's own `useDefault` check. + * + * Clearing `prop.default` back to `undefined` (never `null` — `null` would + * make the renderer's `hasUsableNullDefault` branch fire and print `= null`, + * which is still an initialiser this rule must not emit) makes the + * renderer's `useDefault` check naturally false, so NO initialiser is + * emitted at all: the class field falls back to nullability alone — + * `?: number | null` for a nullable column, `!: number` for a NOT NULL one + * (matching every other scalar's fallback shape in this generator). + * + * A `defaultRaw` that IS a finite numeric literal (`'0'`, `'1'`) is left + * completely alone: `Number(raw)` is correct there, `prop.default` already + * holds the right value, and nothing needs fixing. + */ +export function RULE1d_fixNaNNumericDefaults(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + if (typeof prop.defaultRaw !== 'string') continue; + if (Number.isFinite(Number(prop.defaultRaw))) continue; // a genuine numeric literal — untouched + if (typeof prop.default !== 'number' || !Number.isNaN(prop.default)) continue; // nothing broken here + prop.default = undefined; + } + } +} + +// --------------------------------------------------------------------------- +// Rule 2 (metadata level): p.datetime() -> p.type(DbTimestampType). +// +// `prop.type = 'DbTimestampType'` (a plain string that is NOT a key in +// @mikro-orm/core's `types` registry) makes the renderer emit `p.type(DbTimestampType)` +// verbatim (DefineEntitySourceFile#getPropertyBuilder: `!(options.type in types)` +// -> `p.type(${options.type})`), and it auto-adds 'DbTimestampType' to the +// file's entityImports (SourceFile#getScalarPropertyDecoratorOptions, +// isTypeStringMissingFromMap branch) — the IMPORT PATH for that identifier +// (`../types`) is resolved by the onImport hook below, also metadata/config +// level, not a text pass. `.nullable()`/`.defaultRaw(...)` come from the +// already-introspected `prop.nullable`/`prop.defaultRaw` and need no rule; +// introspection never invents an `onCreate` hook (there is nothing in a +// PRAGMA to discover one from), so "never onCreate" needs no enforcement here. +// --------------------------------------------------------------------------- +/** A scalar this run retyped to `DbTimestampType` — the class field's type still needs a text fix (see below). */ +export type TimestampFixup = RetypedScalarFixup; + +export function RULE2_datetimeToDbTimestampType(metadata: EntityMetadata[]): TimestampFixup[] { + const fixups: TimestampFixup[] = []; + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + if (prop.type !== 'datetime') continue; + prop.type = 'DbTimestampType'; + fixups.push({ className: meta.className, propName: prop.name }); + } + } + return fixups; +} + +// --------------------------------------------------------------------------- +// Rule 3 (metadata level): integer `id` PK -> p.integer().primary().autoincrement(). +// +// All 122 integer PKs in the migrations are AUTOINCREMENT (inputs.md), but +// SQLite's PRAGMA table_info carries no such flag, so the generator always +// emits `.primary().nullable()` for one. `nullable = false` is what removes +// the stray `.nullable()` from the metadata BUILDER CHAIN (SourceFile's +// getCommonDecoratorOptions emits `.nullable()` whenever `prop.nullable` is +// true, unconditionally) — the one place this creates a *textual*, not +// semantic, difference from the hand-written files is documented as +// KNOWN_DIFFS #2 above. Composite and text PKs are untouched, per the plan +// ("`.nullable()` on text PKs is parity-correct — keep"). +// --------------------------------------------------------------------------- +export function RULE3_integerPkAutoincrement(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + if (meta.getPrimaryProps().length !== 1) continue; // composite PK — untouched + const [pk] = meta.getPrimaryProps(); + if (pk.kind !== undefined && pk.kind !== ReferenceKind.SCALAR) continue; // a relation PK — untouched + const columnType = (pk.columnTypes?.[0] ?? '').toLowerCase(); + if (columnType !== 'integer') continue; // a text PK — untouched + pk.autoincrement = true; + pk.nullable = false; + } +} + +// --------------------------------------------------------------------------- +// Rule 4 (metadata level): every relation, owning and inverse, any kind, hidden(). +// --------------------------------------------------------------------------- +export function RULE4_hideAllRelations(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + for (const prop of meta.relations) { + prop.hidden = true; + } + } +} + +function isOwningToOne(prop: EntityProperty): boolean { + if (prop.kind === ReferenceKind.MANY_TO_ONE) return true; + return prop.kind === ReferenceKind.ONE_TO_ONE && !prop.mappedBy; +} + +function isInverseSide(prop: EntityProperty): boolean { + if (prop.kind === ReferenceKind.ONE_TO_MANY) return true; + if (prop.kind === ReferenceKind.ONE_TO_ONE && !!prop.mappedBy) return true; + if (prop.kind === ReferenceKind.MANY_TO_MANY && !!prop.mappedBy) return true; + return false; +} + +function toCamelCase(snake: string): string { + return snake.replace(/_([a-z0-9])/g, (_m, c: string) => c.toUpperCase()); +} + +function toSnakeCase(camel: string): string { + return camel.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); +} + +/** A collision-class relation this run renamed: needs an explicit `.joinColumn()` text fix. */ +export interface JoinColumnFixup { + className: string; + propName: string; + column: string; +} + +/** + * Renames don't just need `properties[newName] = prop` (what `addProperty` + * does) — `EntityGenerator`'s `DefineEntitySourceFile` renders the + * `properties: {...}` object by iterating `Object.entries(meta.properties)` + * directly (not the sorted `meta.props`/`propertyOrder` MikroORM otherwise + * uses), so `removeProperty` + `addProperty`'s delete-then-reinsert always + * moves a renamed property to the END of the file (Fix round 1, + * task-2-review.md M7). This rebuilds the dict's key order to match + * `originalOrder` (captured before any renames), substituting each renamed + * key for its old one — same prop objects, just re-keyed and reordered in + * place, so `propertyOrder`/`props`/`relations` (already correct from + * `addProperty`'s own `sync()`) are untouched. + */ +function restorePropertyOrder(meta: EntityMetadata, originalOrder: readonly string[], renameMap: ReadonlyMap): void { + const keyOrder = originalOrder.map((oldName) => renameMap.get(oldName) ?? oldName); + const values = keyOrder.map((key) => meta.properties[key]); + for (const key of keyOrder) delete meta.properties[key]; + keyOrder.forEach((key, i) => { + meta.properties[key] = values[i]; + }); +} + +/** Stock `UnderscoreNamingStrategy` behaviour (our `SnakeProps` override never touches it) — used by RULE5 to verify a rename's round trip (see I2 below). No state, safe to share. */ +const namingStrategy = new SnakeProps(); + +/** + * Rule 5 (metadata level for the rename; text level for the join column — see + * task-2-report.md "rule 5"): owning relation property name = camelCase of + * (column minus a trailing `_id`). A "collision class" column (no `_id` + * suffix: `created_by`, `country`, … — the FK twin would otherwise be named + * identically to the relation) gets `Ref` instead, and — because the + * renderer for a to-one relation only ever knows how to emit `.name()` + * as an override, which Task 0/1 proved does not actually pin the FK column — + * this function also records the (className, newPropName, column) triple for + * `injectJoinColumns` to turn into an explicit `.joinColumn()` in the + * generated text afterwards. + * + * A multi-column FK (`prop.fieldNames.length !== 1`) throws rather than + * silently keeping the raw snake_case name (Fix round 1, task-2-review.md + * I1 — the schema has zero of these today; the plan's D1 camelCase-relations + * rule has no shape for one, and nothing else would catch the drift, since + * `fieldNames` themselves stay correct). + * + * For a NORMAL (non-collision-class) rename, this also verifies the round + * trip: `namingStrategy.joinKeyColumnName(newName, referencedColumn)` must + * reproduce the real `column`, or the renderer's implicit FK-column inference + * (nothing pins it explicitly for this case) would silently point at the + * wrong column the moment a future migration's naming breaks the assumption + * (Fix round 1, task-2-review.md I2). A mismatch gets the exact same + * `JoinColumnFixup` treatment as a collision class — no separate code path. + * + * Every inverse `mappedBy` across the whole metadata array that pointed at + * the OLD owning name is fixed up to the new one in the same pass (mappedBy + * is a plain string on the inverse side; nothing else references an owning + * relation by name). + */ +export function RULE5_renameOwningRelations(metadata: EntityMetadata[]): JoinColumnFixup[] { + const fixups: JoinColumnFixup[] = []; + const renames = new Map>(); // meta -> oldName -> newName + + for (const meta of metadata) { + const originalOrder = Object.keys(meta.properties); + const renameMap = new Map(); + for (const prop of [...meta.relations]) { + if (!isOwningToOne(prop)) continue; + if (prop.fieldNames.length !== 1) { + throw new Error( + `generate-entities: ${meta.className}.${prop.name} is a multi-column FK ` + + `(${prop.fieldNames.join(', ')}) — RULE5 only knows how to rename a single-column owning ` + + 'relation. Add explicit handling for this relation before regenerating this entity.', + ); + } + const column = prop.fieldNames[0]; + const isCollisionClass = !column.endsWith('_id'); + const stripped = isCollisionClass ? column : column.slice(0, -'_id'.length); + const newName = isCollisionClass ? `${toCamelCase(stripped)}Ref` : toCamelCase(stripped); + if (newName === prop.name) continue; + if (meta.properties[newName]) { + throw new Error( + `generate-entities: renaming ${meta.className}.${prop.name} to "${newName}" would collide with an ` + + `existing property of that name. Column: ${column}.`, + ); + } + const oldName = prop.name; + meta.removeProperty(oldName, false); + prop.name = newName; + meta.addProperty(prop); + renameMap.set(oldName, newName); + if (isCollisionClass) { + fixups.push({ className: meta.className, propName: newName, column }); + } else { + const referencedColumn = prop.referencedColumnNames?.[0]; + const derivedColumn = namingStrategy.joinKeyColumnName(newName, referencedColumn); + if (derivedColumn !== column) { + fixups.push({ className: meta.className, propName: newName, column }); + } + } + } + if (renameMap.size > 0) { + renames.set(meta, renameMap); + restorePropertyOrder(meta, originalOrder, renameMap); + } + } + + // Fix up every inverse `mappedBy` that pointed at a renamed owning property. + for (const meta of metadata) { + for (const prop of meta.relations) { + if (!prop.mappedBy) continue; + const targetMeta = metadata.find((m) => m.className === prop.type); + if (!targetMeta) continue; + const renameMap = renames.get(targetMeta); + const renamed = renameMap?.get(prop.mappedBy); + if (renamed) prop.mappedBy = renamed; + } + } + + return fixups; +} + +/** + * Rule 6 (metadata level): inverse collections and inverse one-to-ones are + * named snake_case of whatever `bidirectionalRelations` generated (always + * camelCase, from `namingStrategy.inverseSideName`, a naming-strategy method + * our `SnakeProps` override does not touch) — matches Phase 0 + * (`day_notes_collection`). Pure case conversion: `dayAccommodationsCollection1` + * -> `day_accommodations_collection1` (the disambiguating digit the generator + * itself appends on a naming collision, e.g. two FKs from the same table, + * survives untouched — the regex only inserts an underscore before an + * uppercase letter, never before a digit). + */ +export function RULE6_renameInverseCollections(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + const originalOrder = Object.keys(meta.properties); + const renameMap = new Map(); + for (const prop of [...meta.relations]) { + if (!isInverseSide(prop)) continue; + const newName = toSnakeCase(prop.name); + if (newName === prop.name) continue; + if (meta.properties[newName]) { + throw new Error( + `generate-entities: renaming ${meta.className}.${prop.name} to "${newName}" would collide with an ` + + 'existing property of that name.', + ); + } + const oldName = prop.name; + meta.removeProperty(oldName, false); + prop.name = newName; + meta.addProperty(prop); + renameMap.set(oldName, newName); + } + // Same rendering-order fix as RULE5 (see `restorePropertyOrder`'s doc comment) — M7. + if (renameMap.size > 0) restorePropertyOrder(meta, originalOrder, renameMap); + } +} + +/** + * Rule 7 (metadata level): drop `updateRule('no action')` / `deleteRule('no action')` + * noise. SQLite's `PRAGMA foreign_key_list` reports "NO ACTION" for every FK + * that never named an ON UPDATE/ON DELETE clause — that's "no information", + * not a real rule the migration stated, so the generator's raw introspection + * carries it as noise on nearly every relation. Every other value (cascade, + * set null, restrict) is real and kept. + */ +export function RULE7_dropNoActionRules(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + for (const prop of meta.relations) { + if (prop.updateRule?.toLowerCase() === 'no action') delete prop.updateRule; + if (prop.deleteRule?.toLowerCase() === 'no action') delete prop.deleteRule; + } + } +} + +/** + * Rule 8 (metadata level): `repository: () => XRepository` + its import. + * Setting `meta.repositoryClass` is the whole rule — SourceFile's + * getEntityDeclOptions() (shared by defineEntity/entitySchema output) turns a + * truthy `repositoryClass` into both the `repository: () => X` option AND an + * auto-added import for that identifier; the onImport hook below resolves it + * to `../repositories/X.repository`. Whether the *file* on disk actually gets + * written (only when missing, never overwritten) is write-mode's job in + * `writeRepositoriesIfMissing`, not this metadata rule. + */ +export function RULE8_bindRepositories(metadata: EntityMetadata[]): void { + for (const meta of metadata) { + meta.repositoryClass = `${meta.className}Repository`; + } +} + +/** + * Rule 9 (metadata level; DB-dependent — see `collectImplicitUniqueIndexes` + * below for the half that actually queries the schema): every column-set an + * inline `UNIQUE(...)` table constraint (or a column-level `UNIQUE`) names + * gets a matching `uniques: [{ properties: [...] }]` entry. + * + * Why this is needed at all: SQLite backs every such constraint with an + * unnamed `sqlite_autoindex__` index, and `@mikro-orm/sql`'s own + * `SqliteSchemaHelper.getIndexes()` calls `isImplicitIndex(name)` — true for + * any index whose name starts with `sqlite_` — to filter exactly these out + * before `entity-generator` ever sees them (read directly out of + * `node_modules/@mikro-orm/sql/dialects/sqlite/SqliteSchemaHelper.js`, not + * assumed): "Ignore indexes with reserved names, e.g. autoindexes". A NAMED + * `CREATE UNIQUE INDEX` (`Trips.idx_trips_feed_token`, `Reservations. + * idx_reservations_external`, …) survives that filter and already renders + * correctly with no help from this rule — only the inline/unnamed spelling is + * silently dropped. `settings`'s `UNIQUE(user_id, key)` is the ruling's + * trigger case (metadata FIDELITY for the constraint `em.upsert`'s conflict + * target is documented against — see below for what that entry does and + * does not buy), but the same gap exists on ~40 other tables in the schema + * (grep `UNIQUE(` across `db/migrations/*.ts`), so this rule is general — + * every table, not just `settings` — and this task's report says which + * entities' `uniques:` this adds where none existed in the hand-written + * baseline before. + * + * `implicitUniques` is precomputed once per generator run and handed in as + * plain data (table name -> one string[] of column names per implicit unique + * index) specifically so this rule itself stays a pure, fixture-testable + * function like every other rule in this file — it never opens a connection + * itself. + * + * A column that IS a single-column FK's join column (the `persist(false)` + * twin's column, e.g. `settings.user_id`) is emitted as the RELATION + * property name (`user`), never the twin (`user_id`) — looked up from + * `meta.relations` (already renamed by RULE5 by the time this rule runs, so + * this sees the final, post-rename property name) by matching an owning + * to-one relation's single `fieldNames[0]` against the column. Every other + * column doubles as its own property name with no naming-strategy lookup + * (`SnakeProps.columnNameToProperty` is the identity, D1). + * + * This is a deliberate DIVERGENCE from the pre-existing hand-written/ + * generated composite `uniques:` blocks elsewhere in the schema + * (`Reservations`/`DocumentSyncItems`/`TrekPhotos`/`FileLinks` all reference + * the twin — `['file_id', 'budget_item_id']`, never `['file', 'budgetItem']` + * — because those are NAMED `CREATE UNIQUE INDEX`es MikroORM's own + * introspection already renders, untouched by this rule, and it always uses + * column names there). The reason for the divergence: unlike the twin + * spelling, the relation-property spelling is actually consumable by + * `em.upsert`'s own conflict-target inference — `getWhereCondition` in + * `@mikro-orm/core/utils/upsert-utils.js` matches a `uniques` entry's + * `properties` against the upsert payload's own keys, which are PROPERTY + * names (`data.user`, never `data.user_id`, since nobody passes a + * `persist(false)` twin into `em.upsert`'s data). Proven directly: + * `Settings.repository.test.ts`'s SETTINGSREPO-014 calls `em.upsert` on + * `Settings` with no `onConflictFields` at all and still gets + * `on conflict (\`user_id\`, \`key\`)`. + * + * That said, this rule's job is METADATA FIDELITY — the generated entity + * matching what the migrated schema actually constrains — not a functional + * dependency any caller has on it: `SettingsRepository.upsertForUser` keeps + * its `onConflictFields: ['user', 'key']` explicit regardless (clearer at + * the call site than relying on inference from metadata), and every other + * `uniques:` entry this rule adds has no `em.upsert` caller at all yet. A + * column an implicit index names that has no matching property AND no + * matching relation twin (naming-strategy mismatch, or a typo in the + * migration) throws, naming the table/columns/entity, rather than silently + * emitting a `uniques:` entry the ORM would reject at discovery time. + * + * A column set that is exactly the table's own primary key is skipped: a + * non-`INTEGER` (or composite) PRIMARY KEY gets the identical kind of + * unnamed `sqlite_autoindex_` entry a table-level `UNIQUE(...)` does + * (`AppSettings.key`, `Addons.id` — both `TEXT PRIMARY KEY`, confirmed by + * running this rule unfiltered against the real schema before adding this + * check), and it is already unique by construction and already `.primary()` + * on every one of its columns — a `uniques:` entry for it is pure noise, not + * a second constraint. + */ +export function RULE9_addImplicitUniqueConstraints( + metadata: EntityMetadata[], + implicitUniques: ReadonlyMap, +): void { + for (const meta of metadata) { + const indexes = implicitUniques.get(meta.tableName); + if (!indexes) continue; + const primaryKeys = new Set(meta.primaryKeys); + // A single-column owning to-one relation's join column, keyed by that + // column so a FK twin (`user_id`) resolves to the relation's own + // (post-RULE5-rename) property name (`user`) — see the doc comment above + // for why this, rather than the twin, is what gets emitted. + const relationNameByColumn = new Map(); + for (const prop of meta.relations) { + if (!isOwningToOne(prop) || prop.fieldNames.length !== 1) continue; + relationNameByColumn.set(prop.fieldNames[0], prop.name); + } + for (const columns of indexes) { + if (columns.length === primaryKeys.size && columns.every((column) => primaryKeys.has(column))) continue; + const properties = columns.map((column) => { + const relationName = relationNameByColumn.get(column); + if (relationName) return relationName; + if (!meta.properties[column]) { + throw new Error( + `generate-entities: implicit unique index on ${meta.tableName}(${columns.join(', ')}) references ` + + `column "${column}", which has no matching property on ${meta.className} (naming-strategy mismatch?).`, + ); + } + return column; + }); + meta.uniques.push({ properties }); + } + } +} + +/** A repository-type marker to inject into one entity class — Rule 10. */ +export interface RepositoryTypeMarkerFixup { + className: string; + repositoryClassName: string; +} + +/** + * Rule 10 (metadata level: which class needs a marker and what to name it; + * text level: the actual insertion, see `injectRepositoryTypeMarker` below — + * this split, and why it cannot be a pure metadata rule, mirrors Rule 5's + * `.joinColumn()` injection): every entity class gets + * `[EntityRepositoryType]?: XRepository;` as its FIRST member, so + * `em.getRepository(X)` — and `t.repo(X)` in `tests/helpers/test-orm.ts`, + * whose `repo` already returns `GetRepository>` — + * resolves to the entity's own repository type instead of the generic + * `SqlEntityRepository` fallback. `GetRepository` + * (`@mikro-orm/core`'s `typings.d.ts`, installed version 7.2.1) reads + * exactly this symbol-keyed member off the entity type + * (`Entity[typeof EntityRepositoryType]`) — confirmed against the installed + * typings, not assumed. + * + * `@mikro-orm/entity-generator`'s OWN renderer already knows how to emit this + * marker, but only for the decorator-based `@Entity()` shape + * (`SourceFile.js#generate()`, guarded by `if (this.meta.repositoryClass)`). + * Our `defineEntity` output goes through a different method entirely — + * `EntitySchemaSourceFile.generateClassDefinition()` (shared by + * `DefineEntitySourceFile`, read directly out of + * `node_modules/@mikro-orm/entity-generator/EntitySchemaSourceFile.js`) — + * which only ever consults `PrimaryKeyProp`/`EagerProps`/`Config` for the + * class head, never `repositoryClass`. No `GenerateOptions` flag changes + * that, so — exactly like Rule 5's join column — the metadata alone cannot + * carry this into the rendered class body; only a text pass can. + * + * The metadata half below does not mutate anything: `meta.repositoryClass` + * is already set for every entity by Rule 8 (which always runs first, see + * `applyRules`) — this is purely a collector, so the text pass has + * (className, repositoryClassName) pairs to work from without re-deriving + * the "Repository" naming convention a second time. + * + * The marker is a type-only declaration (`?:`, never assigned a value) — NOT + * a real entity property. It must never go through `meta.addProperty`: + * MikroORM would then treat it as a genuine, unmapped column. + * `tests/unit/db/entity-class-fields.test.ts` (CLASSFIELD-003) pins that this + * stays true — the marker never shows up in `meta.props` and is never + * counted by CLASSFIELD-002's class-field census. + */ +export function RULE10_repositoryTypeMarker(metadata: EntityMetadata[]): RepositoryTypeMarkerFixup[] { + const fixups: RepositoryTypeMarkerFixup[] = []; + for (const meta of metadata) { + if (!meta.repositoryClass) continue; // Rule 8 always sets this — defensive only, never observed empty + fixups.push({ className: meta.className, repositoryClassName: meta.repositoryClass }); + } + return fixups; +} + +/** An owning relation whose FK targets a non-PK column on its target entity — Rule 11. */ +export interface ReferencedColumnsFixup { + className: string; + propName: string; + referencedColumnNames: readonly string[]; +} + +/** + * Rule 11 (metadata level: detection only, no mutation; text level: the + * actual insertion — see `injectReferencedColumns` below. This split, and why + * it cannot be a pure metadata rule, mirrors Rule 5's `.joinColumn()` + * injection and Rule 10's repository marker. + * + * `@mikro-orm/sql`'s SQLite introspection (`SqliteSchemaHelper.js`, straight + * from `PRAGMA foreign_key_list`'s `to` column) already sets + * `prop.referencedColumnNames` correctly for every owning to-one relation — + * confirmed empirically with a throwaway `onProcessedMetadata` probe against + * the real migrated schema, BEFORE any rule in this file runs: + * `OauthTokens.client`/`OauthConsents.client` already carry + * `referencedColumnNames: ['client_id']` (the schema's real FK target, + * `Migration20200101012500_oauth_2.ts`'s `REFERENCES oauth_clients(client_id)` + * — a UNIQUE natural key, not `OauthClients`'s PK, `id`), while every other + * owning relation in the whole schema (`OauthTokens.user`, `.parentToken`, + * `SchoolHolidayRegions.countryRef` -> `school_holiday_countries(code)`, + * which IS that table's PK, …) already carries `referencedColumnNames` equal + * to its target's own PK. + * + * The generator's own RENDERER never emits this correct, already-introspected + * fact into the generated text, though — it is not something an earlier rule + * in this file drops. Read directly out of + * `node_modules/@mikro-orm/entity-generator/{EntityGenerator,SourceFile}.js`, + * not assumed: `SourceFile.js#getForeignKeyDecoratorOptions` (shared by every + * output mode, including `defineEntity`, through + * `EntitySchemaSourceFile.getPropertyOptions` -> `DefineEntitySourceFile`) + * only ever emits `.referencedColumnNames([...])` when + * `prop.ownColumns && prop.ownColumns.length !== prop.fieldNames.length` — a + * COMPOSITE-FK partial-ownership column-COUNT mismatch, never a check of + * whether the referenced column differs from the target's PK. `prop.ownColumns` + * itself is only ever set by `EntityGenerator.js#generate()` when + * `targetPrimaryColumns.length !== prop.referencedColumnNames.length` — again + * a length comparison, not a content comparison. For a single-column FK + * against a single-column PK (exactly the `client_id` case here), both + * lengths are 1 either way, so `ownColumns` is never set and the renderer's + * emission check never fires, no matter which column is actually referenced. + * This is a real gap in `@mikro-orm/entity-generator` 7.2.1's own renderer, + * not a defect our earlier text passes introduce or could avoid at the + * metadata level. + * + * Detection compares `prop.referencedColumnNames` (already correct) against + * the target entity's OWN primary-key field names + * (`targetMeta.getPrimaryProps().flatMap(pk => pk.fieldNames)`) — never a + * hard-coded table list, so a future migration adding another FK against a + * non-PK column is caught automatically the next time this generator runs, + * not silently missed. + */ +export function RULE11_referencedColumns(metadata: EntityMetadata[]): ReferencedColumnsFixup[] { + const fixups: ReferencedColumnsFixup[] = []; + for (const meta of metadata) { + for (const prop of meta.relations) { + if (!isOwningToOne(prop)) continue; + const targetMeta = metadata.find((m) => m.className === prop.type); + if (!targetMeta) continue; // unresolved relation target — nothing this rule can compare against + const targetPk = targetMeta.getPrimaryProps().flatMap((pk) => pk.fieldNames); + const referenced = prop.referencedColumnNames ?? []; + const matchesTargetPk = referenced.length === targetPk.length && referenced.every((column, i) => column === targetPk[i]); + if (matchesTargetPk) continue; + fixups.push({ className: meta.className, propName: prop.name, referencedColumnNames: [...referenced] }); + } + } + return fixups; +} + +/** A CHECK constraint this run repaired — its introspected expression had swallowed trailing DDL text. */ +export interface CheckExpressionFixup { + className: string; + checkName: string | undefined; +} + +/** + * RULE12 (metadata level): repair a CHECK expression that MikroORM's own + * SQLite introspection over-captured from a trailing table-level constraint + * (Plan 3d Task 0, R10 — `roadtrip_day_boundaries.fraction`). + * + * `SqliteSchemaHelper#getChecks` (verified directly against + * `node_modules/@mikro-orm/sql/dialects/sqlite/SqliteSchemaHelper.js`, not + * assumed) extracts a column's inline CHECK with `/ (check \((.*)\))/i` + * against that column's own slice of `PRAGMA table_xinfo`'s definition text. + * The regex's greedy `.*` runs to the LAST `)` in that slice — correct when + * the checked column is followed only by other columns, wrong when it is the + * last column before a table-level constraint with its own parens. The + * migration (`Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts`) + * declares exactly that shape: + * + * fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), + * PRIMARY KEY (trip_id, day_number) + * + * — so the introspected expression comes back as + * `"fraction BETWEEN 0 AND 1), PRIMARY KEY (trip_id, day_number"`, + * not the DDL's `"fraction BETWEEN 0 AND 1"`. This is a third-party + * introspection defect (the migration's own DDL is correct, and + * `day_number`'s sibling CHECK on the same table — not the last column + * before the table constraint — introspects cleanly), so it is repaired + * here rather than by touching the migration or hand-editing the entity. + * + * The fix walks the captured expression tracking parenthesis depth, + * starting at 1 (the string has already had its own OUTER, matching + * `CHECK (...)` parens stripped by the upstream regex): `(` increments, + * `)` decrements. The first time depth returns to 0 is where the check's + * OWN expression actually closes — the DDL's CHECK is, by definition, + * itself a balanced parenthesised expression, so that is exactly its end; + * everything captured after it is the swallowed trailing constraint text, + * discarded. An expression whose parens never rebalance to 0 before the + * string ends (no parens at all — `day_number >= 1`, + * `end_date >= start_date` — or balanced-but-never-closing nested ones) is + * left byte-identical: this only fires on the specific over-capture shape, + * never on a clean expression. + * + * The walk skips a `(`/`)` that falls inside a single-quoted SQL string + * literal (a doubled `''` is SQLite's escape for a literal quote *inside* + * the literal, so it does not end the string) — an expression like + * `status IN ('confirmed)', 'tentative')` has a literal `)` inside its first + * string value that is not a real closing paren; without this tracking the + * scanner would hit depth 0 at that spurious character and truncate a + * perfectly clean expression (losing its own genuine trailing `)` in the + * process, corrupting otherwise-valid SQL), not just leave RULE12-003's kind + * of case ambiguous the way a false paren-count fires here (Task 8a's own + * carry — "nothing affected today" per 3d's ledger, a latent-bug fix, not an + * active one — no shipped entity's CHECK expression has hit this shape yet). + */ +export function RULE12_fixGarbledCheckExpressions(metadata: EntityMetadata[]): CheckExpressionFixup[] { + const fixups: CheckExpressionFixup[] = []; + for (const meta of metadata) { + for (const check of meta.checks) { + if (typeof check.expression !== 'string') continue; + const expr = check.expression; + let depth = 1; + let cut = -1; + let inString = false; + for (let i = 0; i < expr.length; i++) { + const ch = expr[i]; + if (inString) { + if (ch === "'") { + if (expr[i + 1] === "'") { + i++; // a doubled '' is an escaped quote inside the literal, not its end + continue; + } + inString = false; + } + continue; + } + if (ch === "'") { + inString = true; + continue; + } + if (ch === '(') depth++; + else if (ch === ')') { + depth--; + if (depth === 0) { + cut = i; + break; + } + } + } + if (cut === -1) continue; // parens never rebalance to 0 — a clean expression, nothing swallowed + const fixed = expr.slice(0, cut).trimEnd(); + if (fixed === expr) continue; + check.expression = fixed; + fixups.push({ className: meta.className, checkName: check.name }); + } + } + return fixups; +} + +/** A relation whose `deleteRule` RULE13 had to pin explicitly because MikroORM's own runtime default would otherwise disagree with `PRAGMA foreign_key_list`. */ +export interface DeleteRuleFixup { + className: string; + propName: string; + from: string | undefined; + to: string; +} + +/** + * RULE13 (metadata level; DB-dependent — see `collectForeignKeyDeleteRules` + * below for the half that queries the schema): every owning to-one + * relation's EFFECTIVE `deleteRule` — explicit if the entity sets one, + * otherwise whatever `@mikro-orm/core`'s `MetadataDiscovery` infers by + * default at runtime — must equal `PRAGMA foreign_key_list`'s `on_delete` + * for its column. Plan 4 Task 7's own measurement + * (`tests/unit/db/entity-schema-parity.test.ts`, PARITY-009b) found exactly + * 10 owning relations across 120 entities where it does not. + * + * The root cause, read directly out of + * `node_modules/@mikro-orm/core/metadata/MetadataDiscovery.js` (not + * assumed): a nullable owning relation defaults to `deleteRule ??= 'set + * null'` (`initManyToOneFields`/`initOneToOneFields`); separately, when + * EVERY one of an entity's primary keys is itself a relation (composite or + * single FK-as-PK), each of those relations defaults to `deleteRule ??= + * 'cascade'` (`processEntity`'s `fkPks` block) — and the nullable check runs + * FIRST, so a relation that is both nullable and part of an all-FK PK still + * gets `'set null'`, never `'cascade'`. Neither default is announced by the + * generated source when the introspected value happens to equal it — Rule 7 + * above strips the redundant `deleteRule('no action')` noise, and + * `@mikro-orm/entity-generator`'s OWN `cleanUpReferentialIntegrityRules` + * (`EntityGenerator.js`) strips a redundant `'cascade'`/`'set null'` the + * same way for the FK-as-PK, fixed-order-pivot and nullable-relation shapes + * — but neither cleanup step re-derives the SAME default this rule computes + * here, so when the physical schema's real `on_delete` differs from what + * MikroORM would infer left implicit, the generated entity silently carries + * the wrong effective rule. Concretely, of the 10: 5 are a nullable relation + * whose column has no `ON DELETE` clause at all in its migration (SQLite ⇒ + * `NO ACTION`) — `trip_members.invited_by`, `oauth_tokens.parent_token_id`, + * `budget_settlements.created_by_user_id`, `budget_items.paid_by_user_id`, + * and `roadtrip_day_tracks.day_id` (nullable AND the table's sole PK — its + * migration DOES say `ON DELETE CASCADE`, so this one goes the other way: + * physical is `cascade`, the nullable default silently gives `set null`); + * `journey_contributors.user_id` is a composite-PK member whose migration + * has no `ON DELETE` (physical `no action`) while its FK-as-PK sibling + * relation, `journey_id`, DOES say `ON DELETE CASCADE` (so its own implicit + * `cascade` default is correct — only `user_id` disagrees); the remaining 4 + * (`reservation_travelers.reservation_id`/`.user_id`, + * `assignment_participants.assignment_id`/`.user_id`) are plain non-nullable + * relations on a table with its own `id INTEGER PRIMARY KEY AUTOINCREMENT` + * — no implicit default applies to them at all (MikroORM has none for that + * shape) — but `@mikro-orm/entity-generator`'s pivot-shaped-table cleanup + * (Case 2: a single autoincrement PK + exactly 2 many-to-one relations and + * no other columns) strips their introspected `'cascade'` on the mistaken + * assumption that MikroORM would restore it as a default, which it does not + * for a non-composite-PK entity; their migrations DO say `ON DELETE CASCADE`. + * + * Every one of the 10 is, per the task's own ruling, an entity that never + * matched its migration's physical FK to begin with — not a migration + * defect — so this rule always fixes the ENTITY side: it pins an explicit + * `deleteRule` equal to the physical value whenever leaving the property + * alone (explicit-if-set, else the same implicit default computed above) + * would produce the wrong effective rule, and otherwise leaves the property + * untouched — matching Rule 7's own "no noise where the implicit default is + * already correct" intent instead of stamping every relation explicitly. + * + * One more wrinkle, found empirically (probed `t.orm.getMetadata()` against + * the real `ALL_ENTITIES`, not assumed): a relation that is BOTH nullable + * AND its entity's sole/composite FK-as-PK member — `RoadtripDayTracks.day`, + * `DawarichConnections.user`, `PlaceRegions.place`, `VacayUserSettings.user`, + * every one of them shaped exactly like `.primary().ref().nullable()` with + * no explicit `deleteRule` — is where BOTH candidate defaults above apply at + * once, and the doc comment's "nullable runs first" precedence is only true + * WITHIN a single `processEntity(meta)` call. Across entities, `Days`'/ + * `Users`'/`Places`' own inverse `.mappedBy(...)` side of the same relation + * also runs `initManyToOneFields`, and depending on `ALL_ENTITIES` + * discovery order that can re-touch the owning prop before ITS OWN + * `processEntity` runs — measured outcome: `RoadtripDayTracks.day` really + * does resolve to `'set null'` at runtime (matching this task's 10), while + * the other three resolve to `'cascade'` (NOT in the 10) despite identical + * source. That order dependency is exactly the kind of fact a future entity + * addition could silently flip, so this rule does not trust "already + * correct" for this one shape — `isAmbiguousNullablePrimaryFk` below always + * pins an explicit `deleteRule` on it, matching physical, even on the three + * that happen to already be correct today. + */ +export function RULE13_pinDeleteRuleDrift( + metadata: EntityMetadata[], + fkDeleteRules: ReadonlyMap>, +): DeleteRuleFixup[] { + const fixups: DeleteRuleFixup[] = []; + for (const meta of metadata) { + const byColumn = fkDeleteRules.get(meta.tableName); + if (!byColumn) continue; + const pks = meta.getPrimaryProps().filter((pk): pk is EntityProperty => pk !== undefined); + const allPksAreFk = pks.length > 0 && pks.every((pk) => pk.kind !== undefined && pk.kind !== ReferenceKind.SCALAR); + for (const prop of meta.relations) { + if (!isOwningToOne(prop) || prop.fieldNames.length !== 1) continue; + const physical = byColumn.get(prop.fieldNames[0]); + if (physical === undefined) continue; // no db FK on this column at all — PARITY-009 proper's job, not this rule's + // The two candidate implicit defaults collide on this one shape — see + // the doc comment's "one more wrinkle" — so never trust "already + // correct" here; always pin it explicitly. + const isAmbiguousNullablePrimaryFk = !!prop.nullable && !!prop.primary && allPksAreFk; + if (!isAmbiguousNullablePrimaryFk) { + // The same precedence MetadataDiscovery applies at runtime within a single + // entity's own processEntity() call: nullable wins over FK-as-PK. + const implicitDefault = prop.nullable ? 'set null' : prop.primary && allPksAreFk ? 'cascade' : 'no action'; + const effectiveIfLeftAsIs = (prop.deleteRule ?? implicitDefault).toLowerCase(); + if (effectiveIfLeftAsIs === physical) continue; // already correct, explicit or by the implicit default alike + } else if ((prop.deleteRule ?? '').toLowerCase() === physical) { + continue; // already pinned explicitly to the right value — nothing to change + } + fixups.push({ className: meta.className, propName: prop.name, from: prop.deleteRule, to: physical }); + prop.deleteRule = physical; + } + } + return fixups; +} + +/** A scalar property whose literal default the renderer's own heuristic drops or mis-renders — see below. */ +export interface DefaultFixup { + className: string; + propName: string; + /** Valid TS source for the literal, e.g. `0`, `'calendar'`. */ + literal: string; +} + +/** A raw SQL default that is a plain literal (number or single-quoted string) rather than an expression like `CURRENT_TIMESTAMP`. */ +// Numbers, quoted strings and the booleans RULE1c coerces a NOT NULL boolean default into. +const PLAIN_LITERAL_DEFAULT_RE = /^-?\d+(\.\d+)?$|^'.*'$|^(?:true|false)$/; + +/** + * Rule (metadata level for detection + clearing `defaultRaw`; text level for + * the render itself — see task-2-report.md "the defaults rule"): a scalar + * column with a literal `DEFAULT` (`sort_order INTEGER NOT NULL DEFAULT 0`, + * `year_type TEXT NOT NULL DEFAULT 'calendar'`) is exactly what + * `DayNotes`/`BudgetCategoryOrder`/`VacayUserSettings` render as + * `.default(0)`/`.default('calendar')` — plain JS defaults, never + * `.defaultRaw()`. The stock renderer instead either (a) renders + * `.defaultRaw('0')` whenever introspection only populated `defaultRaw` and + * left `prop.default` unset (observed for columns whose declared type the + * generator leaves `unknown`, e.g. REAL, before Rule 1 retypes them), or (b) + * for a column where BOTH are populated and consistent, renders *neither*: + * traced to `SourceFile.js#getCommonDecoratorOptions`'s `.default()` branch, + * which additionally requires `breakdownOfIType(prop) !== undefined` — true + * only when the property's type is "ambiguous" in some way, which a boring, + * unambiguous `INTEGER NOT NULL DEFAULT 0` column never is. Confirmed by + * generating the real schema and inspecting the metadata mid-hook, not + * assumed. Since nothing in `GenerateOptions` can force that branch to fire, + * this is unavoidably a two-part rule: clear the raw default here (so the + * renderer's OTHER branch, `.defaultRaw()`, never fires either) and record + * a (className, propName, literal) triple for `injectMissingDefaults` to + * append `.default()` in the rendered text whenever the property's + * line ends up with neither call. A genuine SQL expression default + * (`CURRENT_TIMESTAMP`, `datetime('now')`) never matches + * `PLAIN_LITERAL_DEFAULT_RE` and is left completely alone — that path + * already renders correctly today (Rule 2's `DbTimestampType` columns). + */ +export function RULE_normalizeLiteralDefaults(metadata: EntityMetadata[]): DefaultFixup[] { + const fixups: DefaultFixup[] = []; + for (const meta of metadata) { + for (const prop of Object.values(meta.properties)) { + if (prop.kind !== undefined && prop.kind !== ReferenceKind.SCALAR) continue; + // Nullable columns are Task 3's PARITY-005 territory (still `it.skip`d, + // see task-0-report.md): Phase 0's own hand-written files are not + // internally consistent about restating a nullable column's DB default + // in metadata at all (`DayNotes.icon` — nullable, `TEXT DEFAULT '📝'` — + // has none; `DayNotes.sort_order` — nullable, `REAL DEFAULT 0` — has + // `.default(0)`). Guessing a rule to match that inconsistency would be + // exactly the kind of drift Task 0 deferred; only a NOT NULL column + // is unambiguous — there, the default is not cosmetic, it is the only + // thing that makes the property optional at all (see this rule's doc + // comment above). + if (prop.nullable) continue; + if (typeof prop.defaultRaw !== 'string') continue; + if (!PLAIN_LITERAL_DEFAULT_RE.test(prop.defaultRaw)) continue; // an expression — leave to Rule 2's defaultRaw path + if (prop.default === undefined || prop.default === null) continue; // introspection gave us no typed value — nothing safe to render + const literal = + typeof prop.default === 'string' ? `'${prop.default.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` : String(prop.default); + delete prop.defaultRaw; + fixups.push({ className: meta.className, propName: prop.name, literal }); + } + } + return fixups; +} + +export interface RuleFixups { + joinColumns: JoinColumnFixup[]; + defaults: DefaultFixup[]; + timestamps: TimestampFixup[]; + jsonColumns: JsonColumnFixup[]; + /** Rule 1's + Rule 1b's + Rule 2's retyped scalars together — all three need the same `.columnType()` strip (see `stripRedundantColumnType`). */ + retypedScalars: RetypedScalarFixup[]; + /** Rule 10's repository-type markers — one per entity class. */ + repositoryMarkers: RepositoryTypeMarkerFixup[]; + /** Rule 11's non-PK FK referenced-column fixups. */ + referencedColumns: ReferencedColumnsFixup[]; + /** Rule 12's garbled-CHECK-expression repairs. */ + checkExpressions: CheckExpressionFixup[]; + /** Rule 13's deleteRule-drift pins — reporting only, no text pass depends on it (the renderer already emits an explicit `.deleteRule(...)` from `prop.deleteRule` alone). */ + deleteRules: DeleteRuleFixup[]; +} + +/** + * Every rule above, composed into the single hook MikroORM calls. Order + * matters (see comments). `implicitUniques`/`fkDeleteRules` default to empty + * so a caller (the fixture tests) that doesn't care about Rule 9/Rule 13 + * need not pass them. + */ +export function applyRules( + metadata: EntityMetadata[], + _platform: Platform, + implicitUniques: ReadonlyMap = new Map(), + fkDeleteRules: ReadonlyMap> = new Map(), +): RuleFixups { + const retypedByRule1 = RULE1_fixUnknownScalarTypes(metadata); + const jsonColumns = RULE1b_markJsonColumns(metadata); + // Unlike Rule 1's/Rule 1b's retypes, a boolean column's own declaration + // matches its raw INTEGER column type cleanly on SQLite (BooleanType's + // getColumnType() returns the same spelling needsExplicitColumnType() + // compares against) — confirmed by generating the real schema: no stray + // `.columnType()` appears, so RULE1c's fixups do NOT belong in + // `retypedScalars` (stripRedundantColumnType's "found no .columnType() to + // strip" throw catches this immediately if that ever stops being true). + RULE1c_markBooleanColumns(metadata); + RULE1d_fixNaNNumericDefaults(metadata); + const timestamps = RULE2_datetimeToDbTimestampType(metadata); + RULE3_integerPkAutoincrement(metadata); + RULE4_hideAllRelations(metadata); + const joinColumns = RULE5_renameOwningRelations(metadata); + RULE6_renameInverseCollections(metadata); + RULE7_dropNoActionRules(metadata); + RULE8_bindRepositories(metadata); + RULE9_addImplicitUniqueConstraints(metadata, implicitUniques); + const defaults = RULE_normalizeLiteralDefaults(metadata); + const repositoryMarkers = RULE10_repositoryTypeMarker(metadata); + // Runs after RULE5 so it reads the FINAL (post-rename) owning-relation + // property names — the same names the text pass below must find in the + // rendered `properties: {...}` block. + const referencedColumns = RULE11_referencedColumns(metadata); + // Independent of every rule above (it only touches `meta.checks`, which + // the renderer dumps verbatim — no text pass depends on it, same as + // RULE7/RULE9); placed last only by convention (highest rule number). + const checkExpressions = RULE12_fixGarbledCheckExpressions(metadata); + // Must run after RULE7 (which already dropped the genuinely redundant + // `deleteRule('no action')` noise) so `prop.deleteRule` reflects what the + // generated source would actually carry before this rule decides whether + // that's still wrong against the live schema. + const deleteRules = RULE13_pinDeleteRuleDrift(metadata, fkDeleteRules); + return { + joinColumns, + defaults, + timestamps, + jsonColumns, + retypedScalars: [...retypedByRule1, ...timestamps, ...jsonColumns], + repositoryMarkers, + referencedColumns, + checkExpressions, + deleteRules, + }; +} + +/** + * DB-dependent half of Rule 9: for every table name given, finds its implicit + * (`sqlite_autoindex_...`) UNIQUE indexes via the same two PRAGMAs + * `SqliteSchemaHelper.getIndexes()` itself uses (`index_list`, `index_info`), + * and returns their column sets in declared order (`index_info.seqno`). + * + * Kept separate from `RULE9_addImplicitUniqueConstraints` so that rule stays + * a pure function over plain data — this is the only piece of Rule 9 that + * touches a `Connection`, and it is exercised end-to-end (not unit-tested in + * isolation) by `generateEntities()`'s own tests against the real migrated + * schema. + */ +export async function collectImplicitUniqueIndexes( + connection: Connection, + tableNames: readonly string[], +): Promise> { + const result = new Map(); + for (const tableName of tableNames) { + const indexList = (await connection.execute(`pragma index_list(\`${tableName}\`)`, [], 'all')) as { + name: string; + unique: number; + }[]; + const autoUniques = indexList.filter((idx) => idx.unique && idx.name.startsWith('sqlite_autoindex_')); + if (autoUniques.length === 0) continue; + const perTable: string[][] = []; + for (const idx of autoUniques) { + const info = (await connection.execute(`pragma index_info(\`${idx.name}\`)`, [], 'all')) as { + seqno: number; + cid: number; + name: string; + }[]; + perTable.push([...info].sort((a, b) => a.seqno - b.seqno).map((c) => c.name)); + } + result.set(tableName, perTable); + } + return result; +} + +/** + * DB-dependent half of Rule 13: for every table name given, `PRAGMA + * foreign_key_list`'s `on_delete` per `from` (owning) column, lowercased — + * the same physical fact `tests/unit/db/entity-schema-parity.test.ts`'s + * PARITY-009b reads at test time, queried once here so `RULE13_pinDeleteRuleDrift` + * stays a pure function over plain data, same shape as Rule 9's split. + */ +export async function collectForeignKeyDeleteRules( + connection: Connection, + tableNames: readonly string[], +): Promise>> { + const result = new Map>(); + for (const tableName of tableNames) { + const fkRows = (await connection.execute(`pragma foreign_key_list(\`${tableName}\`)`, [], 'all')) as { + from: string; + on_delete: string; + }[]; + if (fkRows.length === 0) continue; + const byColumn = new Map(); + for (const row of fkRows) byColumn.set(row.from, row.on_delete.toLowerCase()); + result.set(tableName, byColumn); + } + return result; +} + +// --------------------------------------------------------------------------- +// onImport — resolves the two identifiers the rules above add to a file's +// entityImports that are NOT sibling `*.entity.ts` classes: `DbTimestampType` +// (rule 2) and any `Repository` (rule 8). Everything else (another entity +// class) falls through to the generator's own default resolution. +// --------------------------------------------------------------------------- +export const onImport: ImportsResolver = (alias) => { + if (alias === 'DbTimestampType') return { path: '../types', name: 'DbTimestampType' }; + if (alias.endsWith('Repository')) { + const entityName = alias.slice(0, -'Repository'.length); + return { path: `../repositories/${entityName}.repository`, name: alias }; + } + return undefined; +}; + +// --------------------------------------------------------------------------- +// Text-level passes — only for what the metadata cannot carry into the +// rendered `defineEntity` call (see the doc comment at the top of this file +// and task-2-report.md for why each of these specifically cannot be a rule +// on the metadata). +// --------------------------------------------------------------------------- + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * The property's line inside `properties: {...}` — i.e. the metadata builder + * call (`propName: p.integer()...` or `propName: () => p.manyToOne(...)...`), + * never the class-field declaration above it. The two can share an identical + * `propName:` prefix (an `& Opt`-style field like `sort_order: number & Opt = 0;` + * has no `!`/`?`/parens between the name and the colon, same as the metadata + * line `sort_order: p.integer(),` — anchoring on `propName:.*` alone matches + * whichever comes first in the file, which is always the class field). + * + * The leading padding is `[ \t]*`, not `\s*` — `\s` matches a newline too, so + * on a property preceded by a blank line (common: one blank line separates + * the class body from the `properties: {...}` block, or separates unrelated + * groups within it) `\s*^` could swallow that blank line into the captured + * "line", handing every caller a string with a leading `\n` embedded in it. + * `appendCallBeforeTrailingComma`'s own `^…$` anchors (Fix round 1, I3) are + * not `/m`-flagged and silently fail to match a string shaped like that — + * caught by `TEXT-DEFAULTS-001`, which has exactly that blank line between + * the class field and the metadata line. + */ +function findPropertyBuilderLine(source: string, propName: string): RegExpExecArray | null { + const lineRe = new RegExp(`^([ \\t]*${escapeRegExp(propName)}: (?:\\(\\) => )?p\\..*)$`, 'm'); + return lineRe.exec(source); +} + +/** The class-field declaration line for a scalar property — the mirror image of `findPropertyBuilderLine` (same `[ \t]*` reasoning). */ +function findClassFieldLine(source: string, propName: string): RegExpExecArray | null { + const lineRe = new RegExp(`^([ \\t]*${escapeRegExp(propName)}[?!]?: (?!\\(\\) => p\\.|p\\.).*)$`, 'm'); + return lineRe.exec(source); +} + +/** + * `DbTimestampType.runtimeType` is `'string'` (it stores the SQLite wire text + * verbatim, never a JS `Date` — see `src/db/types/db-timestamp.type.ts`), but + * the generator has no way to know that: `prop.type` is just the plain string + * `'DbTimestampType'` (Rule 2), unrecognised by `@mikro-orm/core`'s `types` + * registry, and `SourceFile.js#breakdownOfIType`'s fallback for an + * unrecognised declared type re-derives the class field's TS type from the + * COLUMN's own mapped type instead (`DATETIME` -> `DateTimeType` -> `'Date'`) + * — a fallback that, unlike the metadata builder's `p.type(DbTimestampType)` + * output, never consults `prop.runtimeType` at all. Confirmed by generating + * the real schema (`DayNotes.created_at`, `Trips.created_at`/`updated_at`) + * and inspecting the rendered class field, not assumed; there is no metadata + * option that changes this fallback's source. Rewriting `Date` to `string` on + * exactly the class-field line for each Rule-2-retyped property is therefore + * a text pass, not a rule. + */ +export function fixDbTimestampClassFieldTypes(source: string, fixups: readonly TimestampFixup[]): string { + let result = source; + for (const { className, propName } of fixups) { + const match = findClassFieldLine(result, propName); + if (!match) { + throw new Error(`generate-entities: fixDbTimestampClassFieldTypes could not find the class field line for "${className}.${propName}".`); + } + const line = match[1]; + const newLine = line.replace(/\bDate\b/g, 'string'); + if (newLine === line) { + throw new Error( + `generate-entities: fixDbTimestampClassFieldTypes found the class field for "${className}.${propName}" but it had no "Date" to rewrite — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length); + } + return result; +} + +/** + * Every property Rule 1 or Rule 2 retyped (`p.unknown()` -> `p.double()`, + * `p.datetime()` -> `p.type(DbTimestampType)`) picks up a spurious explicit + * `.columnType()` from `SourceFile.js#needsExplicitColumnType()`: + * that check compares the introspected raw column spelling (`'REAL'`, + * `'DATETIME'`, verbatim from the migration) against the abstract type's OWN + * declaration string (`DoubleType`/base `Platform#getDoubleDeclarationSQL()` + * literally returns `'double'`; `DbTimestampType` has no declaration of its + * own so the comparison falls back the same way) — they can never textually + * match, so the "is this ambiguous" check is always true for exactly these + * two retyped families, even though `double`/`DbTimestampType` already + * correctly mean `REAL`/`DATETIME` on SQLite. Confirmed by generating the + * real schema (`DayNotes.sort_order`/`.created_at`) and reading + * `getColumnType()`, not assumed. No `GenerateOptions` flag skips this check, + * so the explicit call is stripped here rather than never emitted. + */ +export function stripRedundantColumnType(source: string, fixups: readonly RetypedScalarFixup[]): string { + let result = source; + for (const { className, propName } of fixups) { + const match = findPropertyBuilderLine(result, propName); + if (!match) { + throw new Error(`generate-entities: stripRedundantColumnType could not find the property line for "${className}.${propName}".`); + } + const line = match[1]; + const newLine = line.replace(/\.columnType\('[^']*'\)/, ''); + if (newLine === line) { + throw new Error( + `generate-entities: stripRedundantColumnType found the property line for "${className}.${propName}" but it had no .columnType() to strip — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length); + } + return result; +} + +/** + * Inserts `injected` right before a builder line's trailing comma, tolerating + * a trailing line comment after it (`.ref(), // pins country`) — the plain + * `line.replace(/,\s*$/, …)` this replaced only matched a comma at the very + * end of the line, so a trailing comment made the append silently do nothing + * (Fix round 1, task-2-review.md I3, adversarial fixture A6). Greedy `.*` + * anchors on the LAST comma on the line (the statement terminator), not the + * first one a string-literal default might contain earlier in the chain. + * Returns `undefined` if the line has no trailing comma to anchor on at all. + */ +function appendCallBeforeTrailingComma(line: string, injected: string): string | undefined { + const match = /^(.*)(,)(\s*(?:\/\/.*)?)$/.exec(line); + if (!match) return undefined; + return `${match[1]}${injected}${match[2]}${match[3]}`; +} + +/** + * For every collision-class relation Rule 5 renamed, turns the renderer's own + * (inert, for a relation) `.name('')` override into the + * `.joinColumn('')` that actually pins the FK column — or appends + * `.joinColumn(...)` if the renderer decided no override was needed at all + * (belt and braces; not observed in practice, but the rule must not silently + * do nothing if the renderer's heuristic ever changes). Every branch either + * makes an edit or throws (Fix round 1, task-2-review.md I3) — a fixup was + * recorded specifically because this relation's FK column pin is not + * otherwise correct, so a no-op here would ship a wrong/missing FK silently. + */ +export function injectJoinColumns(source: string, fixups: readonly JoinColumnFixup[]): string { + let result = source; + for (const { className, propName, column } of fixups) { + const quotedColumn = `'${column}'`; + const match = findPropertyBuilderLine(result, propName); + if (!match) { + throw new Error( + `generate-entities: injectJoinColumns could not find the property line for "${className}.${propName}" — the recorded joinColumn fixup produced no edit.`, + ); + } + const line = match[1]; + const nameCallRe = new RegExp(`\\.name\\(${escapeRegExp(quotedColumn)}\\)`); + let newLine: string; + if (nameCallRe.test(line)) { + newLine = line.replace(nameCallRe, `.joinColumn(${quotedColumn})`); + } else if (line.includes('.joinColumn(')) { + newLine = line; // already explicit — legitimately nothing to do, not a no-op bug + } else { + const appended = appendCallBeforeTrailingComma(line, `.joinColumn(${quotedColumn})`); + if (appended === undefined) { + throw new Error( + `generate-entities: injectJoinColumns found the property line for "${className}.${propName}" but could not append .joinColumn(${quotedColumn}) to it — check the renderer shape.`, + ); + } + newLine = appended; + } + result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length); + } + return result; +} + +/** + * The text half of `RULE_normalizeLiteralDefaults`: appends `.default()` + * to a property's line when it has neither `.default(` nor `.defaultRaw(` — + * the exact gap that rule's doc comment traces. + */ +export function injectMissingDefaults(source: string, fixups: readonly DefaultFixup[]): string { + let result = source; + for (const { className, propName, literal } of fixups) { + const match = findPropertyBuilderLine(result, propName); + if (!match) { + throw new Error(`generate-entities: injectMissingDefaults could not find the property line for "${className}.${propName}".`); + } + const line = match[1]; + if (line.includes('.default(') || line.includes('.defaultRaw(')) continue; // already renders one — legitimately nothing to do + const appended = appendCallBeforeTrailingComma(line, `.default(${literal})`); + if (appended === undefined) { + throw new Error( + `generate-entities: injectMissingDefaults found the property line for "${className}.${propName}" but could not append .default(${literal}) to it — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + appended + result.slice(match.index + line.length); + } + return result; +} + +/** + * `p.json()` has no metadata-level channel for a TypeScript generic type + * parameter (see `JSON_COLUMNS`/`JSON_INTERFACES` above) — this inserts the + * recorded type name after each known JSON column's bare `p.json(` call, on + * exactly that property's line (Fix round 1, task-2-review.md M4/I3: the + * previous blanket `source.replace(/\bp\.json\(\)/g, …)` also rewrote a + * `p.json()` mentioned inside a comment — adversarial fixture A2). Already + * being generic (`p.json()`) is a legitimate no-op, not a bug; + * anything else finding no bare call throws. + */ +export function injectJsonTypeParams(source: string, fixups: readonly JsonColumnFixup[]): string { + let result = source; + for (const { className, propName, typeName } of fixups) { + const match = findPropertyBuilderLine(result, propName); + if (!match) { + throw new Error(`generate-entities: injectJsonTypeParams could not find the property line for "${className}.${propName}".`); + } + const line = match[1]; + if (/p\.json<[^>]*>\(/.test(line)) continue; // already explicitly typed + const newLine = line.replace(/\bp\.json\(\)/, `p.json<${typeName}>()`); + if (newLine === line) { + throw new Error( + `generate-entities: injectJsonTypeParams found the property line for "${className}.${propName}" but it had no bare p.json() call to type — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length); + } + return result; +} + +/** + * `JsonType#runtimeType` (`@mikro-orm/core/types/JsonType.js`) is a hardcoded + * getter that always returns `'any'`, never consulting the property at all — + * so even with `prop.runtimeType` correctly set to the interface name (Rule + * 1b), `SourceFile.js#breakdownOfIType` still sees `prop.runtimeType + * ('AddonConfig') !== rawType ('any')` and wraps the class field in + * `IType` (confirmed by generating the real schema and + * reading the rendered field, not assumed — there is no metadata option that + * changes this). `any` in generated output is not acceptable (Fix round 1, + * task-2-review.md C3), so this text pass simplifies the class field's + * `IType` down to the plain `TypeName`, the same shape a + * hand-written entity uses. A field already rendering the plain type name is + * a legitimate no-op; anything else finding neither shape throws. + */ +export function fixJsonClassFieldTypes(source: string, fixups: readonly JsonColumnFixup[]): string { + let result = source; + for (const { className, propName, typeName } of fixups) { + const match = findClassFieldLine(result, propName); + if (!match) { + throw new Error(`generate-entities: fixJsonClassFieldTypes could not find the class field line for "${className}.${propName}".`); + } + const line = match[1]; + const iTypeRe = new RegExp(`IType<${escapeRegExp(typeName)}(?:\\s*,\\s*any)?>`); + const newLine = line.replace(iTypeRe, typeName); + if (newLine === line) { + if (line.includes(typeName) && !line.includes('any')) continue; // already the plain type — nothing to fix + throw new Error( + `generate-entities: fixJsonClassFieldTypes found the class field for "${className}.${propName}" but no IType<${typeName}, any> to simplify — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + newLine + result.slice(match.index + line.length); + } + return result; +} + +/** + * Inserts the `JSON_INTERFACES` declaration for every distinct type name this + * file's JSON columns reference, right before `export class ` — the + * same position a hand-written file like the current `Addons.entity.ts` uses. + * Throws if `JSON_COLUMNS` ever references a type name with no matching + * `JSON_INTERFACES` entry (a maintenance gap, never a schema fact to guess + * at) or if the file has no `export class` line to anchor on. + * + * Also strips the bogus self-import the renderer adds for the type name: + * `breakdownOfIType`'s import-collection step (`SourceFile.js`) treats any + * non-primitive `prop.runtimeType` as if it names a sibling entity class and + * adds it to `entityImports` — `onImport` has no "this identifier needs no + * import, it is declared right here" signal, so the generator's default + * resolution guesses `import { AddonConfig } from './AddonConfig.entity';`, + * a file that does not exist. Confirmed by generating the real schema, not + * assumed. + */ +export function injectJsonInterfaces(source: string, fixups: readonly JsonColumnFixup[]): string { + if (fixups.length === 0) return source; + const typeNames = [...new Set(fixups.map((f) => f.typeName))]; + const blocks: string[] = []; + let result = source; + for (const typeName of typeNames) { + const decl = JSON_INTERFACES[typeName]; + if (!decl) { + throw new Error(`generate-entities: JSON_COLUMNS references type "${typeName}" but JSON_INTERFACES has no declaration for it.`); + } + blocks.push(decl); + const escaped = escapeRegExp(typeName); + const bogusImportRe = new RegExp(`^import \\{ ${escaped} \\} from '\\./${escaped}\\.entity';\\n`, 'm'); + result = result.replace(bogusImportRe, ''); + // The generator resolves an unknown type name to a sibling entity import; + // nothing else may still import the interface from a file that never exists. + if (new RegExp(`from '\\./${escaped}\\.entity'`).test(result)) { + throw new Error(`generate-entities: injectJsonInterfaces left an import of "${typeName}" from a nonexistent ./${typeName}.entity file.`); + } + } + const classRe = /^export class \w+ /m; + const match = classRe.exec(result); + if (!match) { + throw new Error('generate-entities: injectJsonInterfaces could not find "export class" to insert the interface declaration before.'); + } + return result.slice(0, match.index) + blocks.join('\n') + '\n' + result.slice(match.index); +} + +/** + * Removes `identifier` from the `@mikro-orm/core` named import line if + * nothing after that line still references it as a bare word — shared by + * `stripHiddenTypeAnnotation` (`Hidden`) and `fixJsonClassFieldTypes`'s + * caller (`IType`), both of which can make an import genuinely unused. + */ +function removeUnusedCoreImportIfUnused(source: string, identifier: string): string { + const importLineRe = /^import \{[^}]*\} from '@mikro-orm\/core';$/m; + const coreImportLine = importLineRe.exec(source)?.[0]; + if (!coreImportLine) return source; + const bodyAfterImports = source.slice(source.indexOf(coreImportLine) + coreImportLine.length); + if (new RegExp(`\\b${escapeRegExp(identifier)}\\b`).test(bodyAfterImports)) return source; // still used elsewhere + const escaped = escapeRegExp(identifier); + const cleaned = coreImportLine.replace(new RegExp(`type ${escaped}, `), '').replace(new RegExp(`, type ${escaped}\\b`), ''); + return source.replace(coreImportLine, cleaned); +} + +/** + * Strips the class-field `& Hidden` TypeScript annotation the renderer adds, + * unconditionally, to every field whose property is `.hidden()` + * (`SourceFile.js#getPropertyDefinition`: `if (prop.hidden) { hiddenType += ' & Hidden'; }` + * — there is no metadata option that suppresses it; it does not read a + * config flag, it reads `prop.hidden` directly). Phase 0's hand-written files + * never use this marker on any of their many hidden relations/collections — + * the runtime source of truth is `defineEntity`'s own `.hidden()` call kept + * on the property builder (rule 4), which this text pass never touches; the + * class field is compile-time-only TypeScript, so dropping the redundant + * annotation changes nothing observable. + * + * A `Collection` field only gets an explicit `: Collection` type + * annotation in the first place *because* of `& Hidden` (the same ternary); + * once the marker is gone the whole annotation is dropped too, reverting to + * plain `field = new Collection(this);` — Phase 0's actual style. + * + * Scoped to the CLASS BODY only — everything from the start of the file up + * to (not including) `export const Schema = defineEntity(` — never the + * `properties: {...}` block that follows it. Fix round 1 (task-2-review.md + * M3/I3): the previous whole-file `/ & Hidden/g` also matched a string + * literal default containing that exact text (adversarial fixture A1, + * `note: p.text().default('a & Hidden b')`), corrupting the default's value; + * that text only ever appears in the schema block, which this scoping never + * touches. The `Collection` pattern is also anchored to a single line + * (`[^=\n]*`, not `[^=]*`) so it cannot span two adjacent field declarations + * where the first has no initialiser (fixture A3) — unreachable against + * today's renderer (every hidden `Collection` field DOES get an initialiser, + * see rule 4's doc comment), but no longer exploitable either way. + */ +export function stripHiddenTypeAnnotation(source: string): string { + const boundaryRe = /^export const \w+Schema = defineEntity\(/m; + const boundaryMatch = boundaryRe.exec(source); + const splitIndex = boundaryMatch ? boundaryMatch.index : source.length; + const classBody = source.slice(0, splitIndex); + const rest = source.slice(splitIndex); + + const strippedClassBody = classBody + // Collection & Hidden = new Collection(this); -> = new Collection(this); + .replace(/: Collection<[^=\n]*> & Hidden = /g, ' = ') + // (Ref | null) & Hidden -> Ref | null (parenthesised nullable ref) + .replace(/\(([^()\n]*)\) & Hidden/g, '$1') + // trip!: Ref & Hidden; -> trip!: Ref; (everything else, one class-field line at a time) + .replace(/^(\s*\S[^\n]*?) & Hidden/gm, '$1'); + + if (/&\s*Hidden\b/.test(strippedClassBody)) { + throw new Error('generate-entities: stripHiddenTypeAnnotation left an "& Hidden" marker unstripped in the class body — check the renderer shape.'); + } + + return removeUnusedCoreImportIfUnused(strippedClassBody + rest, 'Hidden'); +} + +/** + * Case-sensitive alpha sort key for a `@mikro-orm/core` named-import entry — + * matches `@mikro-orm/entity-generator`'s own `generateImports()` (sorts the + * raw identifiers, THEN prepends `type ` to whichever ones need it), read + * directly out of `SourceFile.js`. Ignoring any existing `type ` prefix for + * ordering purposes is what keeps this idempotent against the generator's + * own output — re-running `--write` after this pass must never reorder what + * the generator already got right. + */ +function coreImportSortKey(entry: string): string { + return entry.replace(/^type /, ''); +} + +/** + * Adds `identifier` (a VALUE import — never `type`-prefixed; see callers) to + * the file's `@mikro-orm/core` named import line, re-sorting the whole list + * with `coreImportSortKey` so the result is byte-identical to what + * `@mikro-orm/entity-generator` would have rendered had it known about this + * identifier itself. A no-op if the identifier is already present (every + * generated file has exactly one `@mikro-orm/core` import line — `defineEntity` + * and `p` are unconditional — so "no import line at all" is a renderer-shape + * change, not a legitimate input, and throws rather than silently doing + * nothing. + */ +function addNamedCoreImport(source: string, identifier: string): string { + const importLineRe = /^import \{ ([^}]*) \} from '@mikro-orm\/core';$/m; + const match = importLineRe.exec(source); + if (!match) { + throw new Error(`generate-entities: addNamedCoreImport could not find the "@mikro-orm/core" import line to add "${identifier}" to.`); + } + const entries = match[1].split(',').map((e) => e.trim()); + if (entries.some((e) => coreImportSortKey(e) === identifier)) return source; // already present + entries.push(identifier); + entries.sort((a, b) => { + const ka = coreImportSortKey(a); + const kb = coreImportSortKey(b); + return ka < kb ? -1 : ka > kb ? 1 : 0; + }); + const newLine = `import { ${entries.join(', ')} } from '@mikro-orm/core';`; + return source.slice(0, match.index) + newLine + source.slice(match.index + match[0].length); +} + +/** + * The text half of Rule 10: inserts `[EntityRepositoryType]?: XRepository;` + * as the class's FIRST member — before an existing `[PrimaryKeyProp]`/ + * `[EagerProps]` marker, if either is present (`generateClassDefinition()` + * only ever emits those two, plus `[Config]` for a custom base entity this + * codebase does not use) — and adds `EntityRepositoryType` to the + * `@mikro-orm/core` named import line. + * + * `EntityRepositoryType` is a real runtime `unique symbol` const, not an + * erased type (`POSSIBLE_TYPE_IMPORTS` in the installed entity-generator's + * `CoreImportsHelper.js` does not list it), so this is a VALUE import, never + * `import type` — the same way the generator's own decorator-mode renderer + * imports it, and the same way every existing `[PrimaryKeyProp]` marker in + * this tree already imports `PrimaryKeyProp` as a value (see e.g. + * `AppSettings.entity.ts`). + */ +export function injectRepositoryTypeMarker(source: string, fixups: readonly RepositoryTypeMarkerFixup[]): string { + let result = source; + for (const { className, repositoryClassName } of fixups) { + const classOpenRe = new RegExp(`^(export class ${escapeRegExp(className)} \\{)$`, 'm'); + const match = classOpenRe.exec(result); + if (!match) { + throw new Error( + `generate-entities: injectRepositoryTypeMarker could not find "export class ${className} {" to insert the marker after.`, + ); + } + const insertAt = match.index + match[0].length; + const marker = `\n [EntityRepositoryType]?: ${repositoryClassName};`; + result = result.slice(0, insertAt) + marker + result.slice(insertAt); + result = addNamedCoreImport(result, 'EntityRepositoryType'); + } + return result; +} + +/** + * The text half of Rule 11: appends `.referencedColumnNames(, …)` to a + * FK relation's property line whenever the renderer left it out despite the + * metadata already carrying the real (non-PK) referenced column — see + * `RULE11_referencedColumns`'s doc comment for why the renderer never emits + * this itself for a single-column FK. `p.manyToOne(...)`'s + * `.referencedColumnNames(...)` is a rest-args method (confirmed against the + * installed `node_modules/@mikro-orm/core/entity/defineEntity.d.ts`), so this + * renders bare comma-separated quoted column names, never a `[...]` array + * literal. A property line that already has an explicit + * `.referencedColumnNames(` call is a legitimate no-op (never observed today + * — the renderer's own emission path never fires for this shape, see the + * rule's doc comment — but a future generator upgrade could change that); + * anything else finding no trailing comma to anchor on throws. + */ +export function injectReferencedColumns(source: string, fixups: readonly ReferencedColumnsFixup[]): string { + let result = source; + for (const { className, propName, referencedColumnNames } of fixups) { + const match = findPropertyBuilderLine(result, propName); + if (!match) { + throw new Error(`generate-entities: injectReferencedColumns could not find the property line for "${className}.${propName}".`); + } + const line = match[1]; + if (line.includes('.referencedColumnNames(')) continue; // already explicit — nothing to do + const quoted = referencedColumnNames.map((column) => `'${column}'`).join(', '); + const appended = appendCallBeforeTrailingComma(line, `.referencedColumnNames(${quoted})`); + if (appended === undefined) { + throw new Error( + `generate-entities: injectReferencedColumns found the property line for "${className}.${propName}" but could not append ` + + `.referencedColumnNames(${quoted}) to it — check the renderer shape.`, + ); + } + result = result.slice(0, match.index) + appended + result.slice(match.index + line.length); + } + return result; +} + +/** Every text pass, applied in order. */ +export function applyTextPasses(source: string, fixups: RuleFixups): string { + let result = injectJoinColumns(source, fixups.joinColumns); + result = injectMissingDefaults(result, fixups.defaults); + result = fixDbTimestampClassFieldTypes(result, fixups.timestamps); + result = stripRedundantColumnType(result, fixups.retypedScalars); + result = injectJsonTypeParams(result, fixups.jsonColumns); + result = fixJsonClassFieldTypes(result, fixups.jsonColumns); + result = injectJsonInterfaces(result, fixups.jsonColumns); + result = removeUnusedCoreImportIfUnused(result, 'IType'); + result = stripHiddenTypeAnnotation(result); + result = injectRepositoryTypeMarker(result, fixups.repositoryMarkers); + result = injectReferencedColumns(result, fixups.referencedColumns); + return result; +} + +// --------------------------------------------------------------------------- +// Temp database — a throwaway file, migrated the same way buildApp() does +// (same Migrator extension, same migrations path/pathTs), never data/travel.db. +// +// This deliberately does NOT import src/db/orm.ts / src/db/database.ts / +// src/mikro-orm.config.ts: database.ts opens resolveDbPath() (real +// data/travel.db, absent an env override) as a side effect of being imported +// at all, and orm.ts/mikro-orm.config.ts both pull that in transitively. A +// self-contained MikroORM.init() against our own temp path has no such +// side effect and needs nothing from those modules. +// --------------------------------------------------------------------------- + +function tempDbPath(): string { + return path.join(os.tmpdir(), `trek-gen-entities-${crypto.randomUUID()}.db`); +} + +async function migrateTempDb(dbPath: string): Promise { + const orm = await SqliteMikroORM.init({ + entities: [], + discovery: { warnWhenNoEntities: false }, + dbName: dbPath, + extensions: [Migrator], + // Anchored to SERVER_ROOT (__dirname-based), not process.cwd() — Fix + // round 1, task-2-review.md I5: run from the repo root instead of + // server/, the old relative paths silently resolved to nothing and + // `generator.generate()` produced zero files with exit 0. `assertFilesGenerated` + // below is the second half of the fix: even if some future refactor + // reintroduces a cwd-relative path, a zero-file run throws instead of + // looking like a quiet success. + // silent: the 260 `[migrator] Processing/Applied` lines are the migrator's own + // progress log, not this script's output; check:entities and the generator test + // report in their own words and a failure still throws. + migrations: { path: path.join(SERVER_ROOT, 'dist/db/migrations'), pathTs: path.join(SERVER_ROOT, 'src/db/migrations'), snapshot: false, silent: true }, + }); + try { + const migrator = orm.config.getExtension('@mikro-orm/migrator'); + await migrator.up(); + } finally { + await orm.close(true); + } +} + +// --------------------------------------------------------------------------- +// Generation +// --------------------------------------------------------------------------- + +export interface GenerateResult { + /** `X.entity.ts` -> final, post-text-pass file content. */ + files: Map; +} + +/** + * Throws if `files` is empty — a zero-entity run is never a legitimate + * result of this wrapper (the migrated schema always has tables), only a + * silent path-resolution failure (Fix round 1, task-2-review.md I5: + * `node --import tsx server/scripts/generate-entities.ts --out …` run from + * the repo root instead of `server/` printed `dumped 0 files`, exit 0, + * before the migrations path was anchored to `SERVER_ROOT`). Exported so it + * can be pinned by a fixture test independent of a real schema/cwd. + */ +export function assertFilesGenerated(files: ReadonlyMap): void { + if (files.size === 0) { + throw new Error( + 'generate-entities: generated zero entity files — check the migrations path/cwd (should be anchored to SERVER_ROOT) and that the schema actually has tables.', + ); + } +} + +/** + * Generates every entity from the migrated schema into memory (nothing is + * written to disk by this function alone — see `writeEntities`/`main` below + * for the on-disk half `--write` uses). + */ +export async function generateEntities(): Promise { + const dbPath = tempDbPath(); + try { + await migrateTempDb(dbPath); + + const orm = await SqliteMikroORM.init({ + entities: [], + discovery: { warnWhenNoEntities: false }, + dbName: dbPath, + namingStrategy: SnakeProps, + extensions: [EntityGenerator], + }); + try { + const generator = orm.config.getExtension('@mikro-orm/entity-generator'); + let fixups: RuleFixups = { + joinColumns: [], + defaults: [], + timestamps: [], + jsonColumns: [], + retypedScalars: [], + repositoryMarkers: [], + referencedColumns: [], + checkExpressions: [], + deleteRules: [], + }; + const rawFiles = await generator.generate({ + entityDefinition: 'defineEntity', + scalarPropertiesForRelations: 'always', + identifiedReferences: true, + bidirectionalRelations: true, + forceUndefined: false, + undefinedDefaults: true, + skipTables: ['mikro_orm_migrations'], + fileName: (className: string) => `${className}.entity`, + onImport, + onProcessedMetadata: async (metadata, platform) => { + // Same connection the generator itself just introspected the schema + // through (`orm`, in scope from just above) — Rule 9/Rule 13 need + // no second connection or a second migrated temp DB. + const tableNames = metadata.map((meta) => meta.tableName); + const implicitUniques = await collectImplicitUniqueIndexes(orm.em.getConnection(), tableNames); + const fkDeleteRules = await collectForeignKeyDeleteRules(orm.em.getConnection(), tableNames); + fixups = applyRules(metadata, platform, implicitUniques, fkDeleteRules); + }, + }); + + const files = new Map(); + for (const raw of rawFiles) { + const classMatch = /\nexport class (\w+) /.exec(raw) ?? /^export class (\w+) /.exec(raw); + if (!classMatch) continue; // a routine/enum file, no class of its own — nothing to text-pass + const className = classMatch[1]; + const relevantFixups: RuleFixups = { + joinColumns: fixups.joinColumns.filter((f) => f.className === className), + defaults: fixups.defaults.filter((f) => f.className === className), + timestamps: fixups.timestamps.filter((f) => f.className === className), + jsonColumns: fixups.jsonColumns.filter((f) => f.className === className), + retypedScalars: fixups.retypedScalars.filter((f) => f.className === className), + repositoryMarkers: fixups.repositoryMarkers.filter((f) => f.className === className), + referencedColumns: fixups.referencedColumns.filter((f) => f.className === className), + checkExpressions: fixups.checkExpressions.filter((f) => f.className === className), + deleteRules: fixups.deleteRules.filter((f) => f.className === className), + }; + files.set(`${className}.entity.ts`, applyTextPasses(raw, relevantFixups)); + } + assertFilesGenerated(files); + return { files }; + } finally { + await orm.close(true); + } + } finally { + fs.rmSync(dbPath, { force: true }); + } +} + +/** Writes `files` into `dir` (creating it if needed). Used for the scratch dump and by `--write`. */ +export function dumpEntities(dir: string, files: ReadonlyMap): void { + fs.mkdirSync(dir, { recursive: true }); + for (const [fileName, content] of files) { + fs.writeFileSync(path.join(dir, fileName), content); + } +} + +/** + * For every generated entity, writes `src/db/repositories/.repository.ts` + * if (and only if) it does not already exist — Rule 8's file-system half. + * Returns the list of newly-created files. + */ +export function writeRepositoriesIfMissing(repositoriesDir: string, files: ReadonlyMap): string[] { + const created: string[] = []; + fs.mkdirSync(repositoriesDir, { recursive: true }); + for (const fileName of files.keys()) { + const className = fileName.replace(/\.entity\.ts$/, ''); + const repoFile = path.join(repositoriesDir, `${className}.repository.ts`); + if (fs.existsSync(repoFile)) continue; + // `TrekRepository`, never the raw `EntityRepository` (program RULING, + // Plan 3b Task 1 — every repository validates the request context and + // defaults reads to `disableIdentityMap: true`; Task 7 review H2): a + // scaffold on the bare base class silently opts the next table out of + // both guarantees, with nothing to catch it. See + // `_shared/trek-repository.ts` and the `instanceof TrekRepository` + // ratchet in `tests/unit/db/entities-index.test.ts`. + const content = + `import type { ${className} } from '../entities/${className}.entity';\n` + + `import { TrekRepository } from './_shared/trek-repository';\n\n` + + `export class ${className}Repository extends TrekRepository<${className}> {}\n`; + fs.writeFileSync(repoFile, content); + created.push(repoFile); + } + return created; +} + +/** Builds `src/db/entities/index.ts`'s source for a given, already-sorted class-name list. */ +export function buildEntitiesIndexSource(classNames: readonly string[]): string { + const imports = classNames.map((name) => `import { ${name}Schema } from './${name}.entity';`).join('\n'); + const exportList = classNames.map((name) => ` ${name}Schema,`).join('\n'); + return ( + `import type { EntitySchema } from '@mikro-orm/core';\n${imports}\n\n` + + `export const ALL_ENTITIES: readonly EntitySchema[] = [\n${exportList}\n];\n` + ); +} + +/** Regenerates `src/db/entities/index.ts` from the directory, sorted, exporting `ALL_ENTITIES`. */ +export function regenerateEntitiesIndex(entitiesDir: string): string { + const classNames = fs + .readdirSync(entitiesDir) + .filter((f) => f.endsWith('.entity.ts')) + .sort() + .map((f) => f.replace(/\.entity\.ts$/, '')); + return buildEntitiesIndexSource(classNames); +} + +/** What `--check` found: every generated file (entity + `index.ts`) that differs from — or is missing from — disk, and every repository `--write` would create. Read-only; never touches disk. */ +export interface CheckReport { + /** `X.entity.ts` (or `'index.ts'`) for every generated file whose on-disk content differs, byte-for-byte, from what the generator produces now. */ + differingFiles: string[]; + /** `src/db/repositories/.repository.ts` for every entity with no repository file yet — `--write` would create these. */ + missingRepositories: string[]; +} + +/** + * The read-only half of `--check` (I1 in `task-4-review-gates.md`): compares + * `files` (already generated in memory by `generateEntities()`) plus the + * `index.ts` they imply against `entitiesDir`, and reports which + * `repositoriesDir/.repository.ts` files `writeRepositoriesIfMissing` + * would create. Never reads `regenerateEntitiesIndex`'s directory-listing + * variant — the *expected* index is built straight from `files`' own key + * set, not from whatever happens to already be on disk, so a stray or + * missing entity file cannot mask itself out of the index comparison. + */ +export function checkEntities(entitiesDir: string, repositoriesDir: string, files: ReadonlyMap): CheckReport { + const differingFiles: string[] = []; + for (const [fileName, content] of files) { + const onDiskPath = path.join(entitiesDir, fileName); + const onDisk = fs.existsSync(onDiskPath) ? fs.readFileSync(onDiskPath, 'utf8') : undefined; + if (onDisk !== content) differingFiles.push(fileName); + } + + const classNames = [...files.keys()].map((f) => f.replace(/\.entity\.ts$/, '')).sort(); + const expectedIndex = buildEntitiesIndexSource(classNames); + const indexPath = path.join(entitiesDir, 'index.ts'); + const onDiskIndex = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : undefined; + if (onDiskIndex !== expectedIndex) differingFiles.push('index.ts'); + + // The comparison must also run the other way: a hand-added `*.entity.ts` + // that the generator does not produce would be folded into index.ts by the + // next --write, so it is drift too. + const generated = new Set(files.keys()); + const onDiskEntityFiles = fs.existsSync(entitiesDir) + ? fs.readdirSync(entitiesDir).filter((f) => f.endsWith('.entity.ts')).sort() + : []; + for (const fileName of onDiskEntityFiles) { + if (!generated.has(fileName)) differingFiles.push(`${fileName} (not produced by the generator)`); + } + + const missingRepositories: string[] = []; + for (const fileName of files.keys()) { + const className = fileName.replace(/\.entity\.ts$/, ''); + const repoFile = path.join(repositoriesDir, `${className}.repository.ts`); + if (!fs.existsSync(repoFile)) missingRepositories.push(repoFile); + } + + return { differingFiles, missingRepositories }; +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +async function main(): Promise { + const args = process.argv.slice(2); + const write = args.includes('--write'); + const check = args.includes('--check'); + const outIndex = args.indexOf('--out'); + const outDir = outIndex >= 0 ? args[outIndex + 1] : undefined; + + const { files } = await generateEntities(); + + if (check) { + const { differingFiles, missingRepositories } = checkEntities(ENTITIES_DIR, REPOSITORIES_DIR, files); + for (const fileName of differingFiles) { + console.error(`[generate-entities] DRIFT: src/db/entities/${fileName} does not match the generator.`); + } + for (const repoFile of missingRepositories) { + console.error(`[generate-entities] MISSING: ${path.relative(SERVER_ROOT, repoFile)} would be created by --write.`); + } + if (differingFiles.length > 0 || missingRepositories.length > 0) { + console.error('[generate-entities] --check failed. Run: node --import tsx scripts/generate-entities.ts --write'); + process.exit(1); + } + console.log('[generate-entities] --check: entity files match the generator'); + return; + } + + if (write) { + dumpEntities(ENTITIES_DIR, files); + const created = writeRepositoriesIfMissing(REPOSITORIES_DIR, files); + fs.writeFileSync(path.join(ENTITIES_DIR, 'index.ts'), regenerateEntitiesIndex(ENTITIES_DIR)); + console.log(`[generate-entities] wrote ${files.size} entities, ${created.length} new repositories, index.ts`); + return; + } + + const target = outDir ?? path.join(os.tmpdir(), `trek-generated-entities-${crypto.randomUUID()}`); + dumpEntities(target, files); + console.log(`[generate-entities] dumped ${files.size} files to ${target}`); +} + +if (require.main === module) { + main().catch((err: unknown) => { + console.error('[generate-entities] failed:', err); + process.exit(1); + }); +} diff --git a/server/src/app-config/nest-log-levels.ts b/server/src/app-config/nest-log-levels.ts new file mode 100644 index 000000000..84e86111b --- /dev/null +++ b/server/src/app-config/nest-log-levels.ts @@ -0,0 +1,25 @@ +import type { LogLevel } from '@nestjs/common'; + +/** + * LOG_LEVEL → the Nest logger levels `NestFactory.create` should keep. + * + * Nest's built-in logger ignores LOG_LEVEL and prints everything, so every + * boot wrote its full route map (`RouterExplorer`/`RoutesResolver`/ + * `InstanceLoader`, ~700 lines) no matter what the operator asked for — and + * every e2e boot in the test suite did the same, which is most of what a + * green CI log was made of. The file logger (`nest/audit/audit-log.logger.ts`) + * already ranks error < warn < info < debug; this is the same ladder expressed + * in Nest's vocabulary. Unset or unknown falls back to `info`, as index.ts does. + */ +export function nestLogLevels(logLevel: string | undefined): LogLevel[] { + switch ((logLevel ?? 'info').toLowerCase()) { + case 'error': + return ['fatal', 'error']; + case 'warn': + return ['fatal', 'error', 'warn']; + case 'debug': + return ['fatal', 'error', 'warn', 'log', 'debug', 'verbose']; + default: + return ['fatal', 'error', 'warn', 'log']; + } +} diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index ddca87bcd..da02cb5cc 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -1,25 +1,31 @@ -import nodeHttp from 'node:http'; -import express from 'express'; -import type { Request, Response, NextFunction, RequestHandler } from 'express'; -import { NestFactory } from '@nestjs/core'; -import { ExpressAdapter } from '@nestjs/platform-express'; -import type { INestApplication } from '@nestjs/common'; -import type { ConfigType } from '@nestjs/config'; -import { AppModule } from './nest/app.module'; -import { httpConfig } from './nest/app-config'; +import { attachOrm, runSchemaBootstrap } from './db/orm'; import { applyGlobalMiddleware, routingCspOrigins } from './middleware/globalMiddleware'; -import { SettingsService } from './nest/settings/settings.service'; -import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes'; +import { readEnv } from './app-config'; +import { nestLogLevels } from './app-config/nest-log-levels'; +import { httpConfig } from './nest/app-config'; +import { AppModule } from './nest/app.module'; import { apiDocsEnabled } from './nest/common/api-docs.kill-switch'; +import { validateBodyContracts } from './nest/common/validate-body-contracts'; +import { validateManagedRoutes } from './nest/common/validate-managed-routes'; +import { validateRouteGuards } from './nest/common/validate-route-guards'; import { setupApiDocs } from './nest/platform/api-docs'; import { MCP_METADATA_MIDDLEWARE } from './nest/platform/mcp-metadata.middleware'; -import { validateBodyContracts } from './nest/common/validate-body-contracts'; -import { validateRouteGuards } from './nest/common/validate-route-guards'; -import { validateManagedRoutes } from './nest/common/validate-managed-routes'; +import { applyPlatformUploads, applyPlatformStatic } from './nest/platform/platform.routes'; import { TrekWsAdapter } from './nest/realtime/trek-ws.adapter'; +import { SettingsService } from './nest/settings/settings.service'; import { StorageService } from './nest/storage/storage.service'; +import { MikroORM } from '@mikro-orm/core'; +import { withRequestContext } from './nest/database/request-context'; +import type { INestApplication } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import { ExpressAdapter } from '@nestjs/platform-express'; import { MAX_COLLECTION_FILE_BYTES } from '@trek/shared'; +import express from 'express'; +import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import nodeHttp from 'node:http'; + /** * Builds the unified TREK NestJS application that serves the ENTIRE surface — the * former Express app is gone. One builder is shared by the production bootstrap @@ -70,7 +76,19 @@ export async function buildApp(): Promise { // rawBody keeps the unparsed request bytes on req.rawBody so a plugin webhook // route can verify a provider's HMAC signature over the exact payload (the // parsed JSON alone can't be re-serialised byte-for-byte). - const app = await NestFactory.create(AppModule, new ExpressAdapter(), { rawBody: true }); + // `logger` is the only place Nest's own logger learns about LOG_LEVEL; without + // it every boot prints its full route map whatever the operator asked for. + const app = await NestFactory.create(AppModule, new ExpressAdapter(), { + rawBody: true, + logger: nestLogLevels(readEnv().app.logLevel), + }); + // Schema first, before ANY consumer reads it. `database.ts` only opens the + // connection now; migrating and seeding is MikroORM's job and it is async, so + // this is the earliest point it can happen. It has to stay above the + // SettingsService resolution below, which is the boot's first DB read. + const orm = app.get(MikroORM); + attachOrm(orm); + await runSchemaBootstrap(orm); const instance = app.getHttpAdapter().getInstance(); // The http server is created HERE, not by the caller after buildApp returns, // and that ordering is the whole point: Nest binds gateways during app.init(), @@ -80,7 +98,7 @@ export async function buildApp(): Promise { // boot succeeds, the gateway logs as registered, every test passes, and no // browser can connect. Callers take the server from getHttpServer() below. boundHttpServer = nodeHttp.createServer(instance); - app.useWebSocketAdapter(new TrekWsAdapter(boundHttpServer)); + app.useWebSocketAdapter(new TrekWsAdapter(boundHttpServer, orm)); // ConfigModule.forRoot's load factories already ran inside NestFactory.create, // so the boot-stable snapshot is resolvable here, BEFORE app.init() — this is // the one bridge that lets the pre-init Express layer consume the validated @@ -91,19 +109,19 @@ export async function buildApp(): Promise { // engines go through the same door — the second one answers the avoidance questions // the first cannot, and is blocked just as silently when the policy leaves it out. const settings = app.get(SettingsService, { strict: false }); - const defaults = settings?.getAdminUserDefaults(); + // Boot runs outside any HTTP request, so there is no per-request EntityManager + // yet and `allowGlobalContext` is off in production: every repository-backed + // read at boot goes through D6's explicit context (see request-context.ts). + const defaults = await withRequestContext(orm, () => settings?.getAdminUserDefaults()); const asUrl = (value: unknown) => (typeof value === 'string' ? value : null); applyGlobalMiddleware(instance, { http, - extraConnectSrc: routingCspOrigins([ - asUrl(defaults?.routing_base_url), - asUrl(defaults?.valhalla_base_url), - ]), + extraConnectSrc: routingCspOrigins([asUrl(defaults?.routing_base_url), asUrl(defaults?.valhalla_base_url)]), }); // Same pre-init consumption bridge as httpConfig above: the StorageService // instance is resolvable before init, and the handlers only *register* here — // per-request resolution runs after app.init() completed the registry load. - applyPlatformUploads(instance, app.get(StorageService)); + applyPlatformUploads(instance, app.get(StorageService), orm); // The SDK discovery router (+ its addon gate). Container-built so its deps // are injected (same pre-init consumption bridge as httpConfig above), but // applied here as a PATHLESS app.use: the SDK router matches absolute @@ -159,8 +177,7 @@ export async function buildApp(): Promise { const json = express.json({ limit: '100kb', verify: rawBodyKeeper }); const urlencoded = express.urlencoded({ limit: '100kb', extended: true, verify: rawBodyKeeper }); const isMcp = (req: Request) => req.path === '/mcp' || req.path === '/mcp/'; - const isBookWrite = (req: Request) => - req.method === 'PUT' && /^\/api\/journeys\/\d+\/book$/.test(req.path); + const isBookWrite = (req: Request) => req.method === 'PUT' && /^\/api\/journeys\/\d+\/book$/.test(req.path); const isListFile = (req: Request) => req.method === 'POST' && /^\/api\/addons\/collections\/(import|gpx\/read|\d+\/import)$/.test(req.path); @@ -173,6 +190,17 @@ export async function buildApp(): Promise { return isMcp(req) ? next() : urlencoded(req, res, next); }); if (apiDocsEnabled()) setupApiDocs(app); + // The per-request EntityManager fork every Nest route runs inside (D6). This + // used to be @mikro-orm/nestjs's own middleware, which Nest 11 mounts as an + // app.all('/{*all}') route: Express decodes the wildcard param before the + // handler runs, so a malformed %-escape anywhere (GET /uploads/avatars/%ZZ) + // died as a 400 "Failed to decode param" instead of falling through to the + // 404 envelope (UPLOADS-P16). A pathless app.use has no param to decode. + // Registered last before init, where the module middleware used to sit. + // AppModule turns the module's own registration off (registerRequestContext). + instance.use(function mikroOrmRequestContext(_req: Request, _res: Response, next: NextFunction) { + withRequestContext(orm, next); + }); await app.init(); // Fail closed on unvalidated mutation bodies: every POST/PUT/PATCH @Body() // must carry a createZodDto class (validated by the global ZodValidationPipe) diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts index b2923add2..a4659cd54 100644 --- a/server/src/db/connection.ts +++ b/server/src/db/connection.ts @@ -3,7 +3,8 @@ import Database from 'better-sqlite3'; /** * Opens a SQLite connection. Every handle the server opens comes from here (the * main database, each plugin's own database, a backup being checked before a - * restore), so what has to hold for all of them is set in one place. + * restore), so what has to hold for all of them is set in one place. A Buffer + * opens a serialized database in memory (the test schema snapshot). * * Temporary storage stays in memory. Statement journals, sort runs and transient * indices move to a temp file once they outgrow a small in-memory buffer, and @@ -20,7 +21,7 @@ import Database from 'better-sqlite3'; * being a tmpfs. VACUUM INTO, the only VACUUM TREK runs, writes straight into * its target file and builds no temporary database. */ -export function openDatabase(filename: string, options?: Database.Options): Database.Database { +export function openDatabase(filename: string | Buffer, options?: Database.Options): Database.Database { const db = new Database(filename, options); db.pragma('temp_store = MEMORY'); return db; diff --git a/server/src/db/database.ts b/server/src/db/database.ts index dbab97d67..ef7404f7d 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -1,46 +1,41 @@ -import type Database from 'better-sqlite3'; -import path from 'path'; -import fs from 'fs'; import { readEnv } from '../app-config'; import { openDatabase } from './connection'; +import { resolveDbPath } from './db-path'; import { applyDurabilityPragmas } from './durability'; -import { createTables } from './schema'; -import { runMigrations } from './migrations'; -import { runSeeds } from './seeds'; -import { Place, Tag } from '../types'; +import { readSchemaSnapshot } from './schema-snapshot'; + +import type Database from 'better-sqlite3'; // In test mode each vitest worker gets an isolated in-memory DB so that // parallel forks can't race on the same file or share migration state. const isTest = readEnv().app.isTest; - -let dbPath: string; -if (isTest) { - dbPath = ':memory:'; -} else if (readEnv().db.trekDbFile) { - // Explicit DB file (used by the Playwright E2E harness to run against an - // isolated, throwaway database instead of the real data/travel.db). Purely - // additive — when unset the default path below is used exactly as before. - dbPath = readEnv().db.trekDbFile!; - const dir = path.dirname(dbPath); - if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); -} else { - const dataDir = path.join(__dirname, '../../data'); - if (!fs.existsSync(dataDir)) { - fs.mkdirSync(dataDir, { recursive: true }); - } - dbPath = path.join(dataDir, 'travel.db'); -} +const dbPath = resolveDbPath(); let _db: Database.Database | null = null; +/** + * Opens the connection and applies the pragmas. It deliberately does NOT build + * the schema: migrations and seeding are MikroORM's job now and run from + * `db/orm.ts` during `buildApp()`, because the migrator is async and this + * function is called at module load. + * + * The one exception is test mode, where there is no `buildApp()` to hook into + * for the handful of suites that use this singleton directly. Those open a copy + * of a schema snapshot the vitest global setup migrated once for the whole run. + */ function initDb(): void { if (_db) { - try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} - try { _db.close(); } catch (e) {} + try { + _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + } catch (e) {} + try { + _db.close(); + } catch (e) {} _db = null; } - _db = openDatabase(dbPath); + const snapshot = isTest ? readSchemaSnapshot() : null; + _db = openDatabase(snapshot ?? dbPath); // Ahead of the journal switch now: changing journal_mode needs an exclusive // lock, which a sibling process (reset-admin, the rotation script) may hold. _db.exec('PRAGMA busy_timeout = 5000'); @@ -51,15 +46,38 @@ function initDb(): void { if (dbPath !== ':memory:') { console.log(`[DB] journal_mode=${durability.journalMode}, synchronous=${durability.synchronous}`); } - - createTables(_db); - runMigrations(_db); - - runSeeds(_db); } initDb(); +/** + * The live better-sqlite3 handle, for the MikroORM dialect in `orm-driver.ts`. + * + * Deliberately not the `db` Proxy below: Kysely holds whatever it is given for + * the life of its client, and a Proxy would hide the swap a restore performs. + * The driver calls this on every connect so it always binds the current handle. + */ +function getRawConnection(): Database.Database { + if (!_db) throw new Error('Database connection is not available (restore in progress?)'); + return _db; +} + +/** + * Plan 4 Task 4 deleted `DatabaseService`/`DatabaseModule` and every `src/` + * importer of this Proxy is gone — but the export itself stays, deliberately: + * ~190 test files' `vi.mock('…/db/database', …)` factories return a + * `{ db: , … }` shape (`buildDbMock`/the ad-hoc equivalents), + * and their own `import { db as testDb } from '…/db/database'` line is typed + * against THIS file's real exports, not the runtime mock — Vitest's + * `vi.mock` swaps the module at runtime, but `tsc` resolves the import + * statically regardless. Deleting this export makes every one of those + * files fail `typecheck:tests` even though none of them use the exported + * Proxy in production. That test-harness idiom is Track B's own territory + * (5a/5b/5c's "e2e pattern"), not enumerated in this task's file set — + * left for a follow-up that swaps the idiom, not deleted out from under it. + * `src/` never imports it, so ESLint's `no-restricted-imports` shrink still + * holds this file to its permanent allow-list entry (rule 4) regardless. + */ const db = new Proxy({} as Database.Database, { get(_, prop: string | symbol) { if (!_db) throw new Error('Database connection is not available (restore in progress?)'); @@ -72,102 +90,81 @@ const db = new Proxy({} as Database.Database, { }, }); -if (readEnv().demo.enabled) { +/** + * Demo seeding, which used to run here at module load. + * + * It has to follow the schema now: `demo-seed.ts` inserts places against + * hard-coded category ids 1-9, and with `foreign_keys = ON` those rows only + * resolve once the category seeder has run. Called from the schema bootstrap in + * `db/orm.ts`, immediately after the seeders. + */ +// Plan 3i Task 3: `seedDemoData` converted onto `DemoRepository` (an +// EntityManager-backed repository), so it is `async` now and no longer takes +// the raw `db` handle — it resolves its EntityManager itself via +// `RequestContext.getEntityManager()`, which `runSchemaBootstrap`'s existing +// `withRequestContext(orm, () => runDemoSeed())` wrap (`db/orm.ts:59`) +// already populates by the time this runs. `runDemoSeed` awaits it now too: +// previously both were fully synchronous, so the try/catch below already +// caught everything before this function returned; without the `await` a +// rejected promise from `seedDemoData` would become an unhandled rejection +// instead of the `[Demo] Seed error:` log line this catch has always produced. +async function runDemoSeed(): Promise { + if (!readEnv().demo.enabled) return; try { const { seedDemoData } = require('../demo/demo-seed'); - seedDemoData(_db); + await seedDemoData(); } catch (err: unknown) { console.error('[Demo] Seed error:', err instanceof Error ? err.message : err); } } +/** + * Lets `db/orm.ts` rebuild the ORM's connection when this module swaps handles, + * without this module importing the ORM (which imports this module back). + */ +let onReinitialize: (() => Promise) | null = null; +function registerReinitializeHook(hook: () => Promise): void { + onReinitialize = hook; +} + function closeDb(): void { if (_db) { - try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} - try { _db.close(); } catch (e) {} + try { + _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + } catch (e) {} + try { + _db.close(); + } catch (e) {} _db = null; console.log('[DB] Database connection closed'); } } -function reinitialize(): void { +/** + * Reopens the connection after a restore has swapped the file underneath us. + * + * Async now, because bringing a restored backup up to date means running the + * migrations, and the hook also has to rebuild the ORM's Kysely client — it + * cached the handle this function is about to replace. + */ +async function reinitialize(): Promise { console.log('[DB] Reinitializing database connection after restore...'); if (_db) closeDb(); initDb(); + if (onReinitialize) await onReinitialize(); console.log('[DB] Database reinitialized successfully'); } -interface PlaceWithCategory extends Place { - category_name: string | null; - category_color: string | null; - category_icon: string | null; -} +// getPlaceWithTags/canAccessTrip/isOwner and the TripAccess/PlaceWithTags +// interfaces lived here through Plan 3c Task 0a's async sweep. Task 0b moved +// their bodies onto TripsRepository.findAccessible/isOwner and +// PlacesRepository.findWithTagsAndRatings — db/repositories/Trips.repository.ts's +// TripAccess is now the single source. Plan 4 Task 4 deleted `DatabaseService` +// (its last `src/` consumer) once every caller moved onto a repository, the +// `UnitOfWork`, or (for the two whole-database-file statements no +// entity/repository call can express) `MaintenanceRepository`/ +// `DemoRepository`'s own `connection.execute()`. The `db` Proxy itself stays +// exported — see its own docstring above for why (~190 test files' static +// typecheck, not a production `src/` need). -interface PlaceWithTags extends Place { - category: { id: number; name: string; color: string; icon: string } | null; - tags: Tag[]; - ratings: { user_id: number; username: string; avatar: string | null; rating: number }[]; - rating_avg: number | null; - rating_count: number; -} - -function getPlaceWithTags(placeId: number | string): PlaceWithTags | null { - const place = db.prepare(` - SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon - FROM places p - LEFT JOIN categories c ON p.category_id = c.id - WHERE p.id = ? - `).get(placeId) as PlaceWithCategory | undefined; - - if (!place) return null; - - const tags = db.prepare(` - SELECT t.* FROM tags t - JOIN place_tags pt ON t.id = pt.tag_id - WHERE pt.place_id = ? - `).all(placeId) as Tag[]; - - // Collaborative ratings (#1435): every voter with username/avatar for the - // who-voted tooltip; the displayed value is the average. - const ratings = db.prepare(` - SELECT pr.user_id, u.username, u.avatar, pr.rating FROM place_ratings pr - JOIN users u ON pr.user_id = u.id - WHERE pr.place_id = ? ORDER BY pr.created_at - `).all(placeId) as { user_id: number; username: string; avatar: string | null; rating: number }[]; - - return { - ...place, - category: place.category_id ? { - id: place.category_id, - name: place.category_name!, - color: place.category_color!, - icon: place.category_icon!, - } : null, - tags, - ratings, - rating_avg: ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : null, - rating_count: ratings.length, - }; -} - -interface TripAccess { - id: number; - user_id: number; - currency: string | null; -} - -function canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined { - return db.prepare(` - SELECT t.id, t.user_id, t.currency FROM trips t - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? - WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) - `).get(userId, tripId, userId) as TripAccess | undefined; -} - -function isOwner(tripId: number | string, userId: number): boolean { - return !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId); -} - - -export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner }; -export type { TripAccess, PlaceWithTags }; +export { db, closeDb, reinitialize, getRawConnection, registerReinitializeHook, runDemoSeed }; diff --git a/server/src/db/db-path.ts b/server/src/db/db-path.ts new file mode 100644 index 000000000..6d9eb86f9 --- /dev/null +++ b/server/src/db/db-path.ts @@ -0,0 +1,34 @@ +import { readEnv } from '../app-config'; + +import fs from 'fs'; +import path from 'path'; + +/** + * The SQLite file this process talks to. + * + * Shared by `database.ts` (which opens the connection) and `mikro-orm.config.ts` + * (which needs the same target for the CLI and for the Nest-owned ORM). Kept in + * one place because a config that names a different file than the connection is + * the kind of mistake that only shows up as an empty database. + * + * Creates the containing directory as a side effect, exactly as the original + * inline block in `database.ts` did. + */ +export function resolveDbPath(): string { + // In test mode each vitest worker gets an isolated in-memory DB so that + // parallel forks can't race on the same file or share migration state. + if (readEnv().app.isTest) return ':memory:'; + + const explicit = readEnv().db.trekDbFile; + if (explicit) { + // Explicit DB file (used by the Playwright E2E harness to run against an + // isolated, throwaway database instead of the real data/travel.db). + const dir = path.dirname(explicit); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return explicit; + } + + const dataDir = path.join(__dirname, '../../data'); + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + return path.join(dataDir, 'travel.db'); +} diff --git a/server/src/db/dialect/sql-functions.ts b/server/src/db/dialect/sql-functions.ts new file mode 100644 index 000000000..b20e4d807 --- /dev/null +++ b/server/src/db/dialect/sql-functions.ts @@ -0,0 +1,997 @@ +import { raw, type Platform, type RawQueryFragment } from '@mikro-orm/core'; +import { SqlitePlatform } from '@mikro-orm/sql'; +import { sql, type Expression, type ExpressionBuilder, type ExpressionWrapper, type RawBuilder, type ReferenceExpression, type SqlBool, type StringReference } from 'kysely'; + +/** + * The only place a repository may spell a database function. + * + * Every helper takes the MikroORM `Platform` the caller's EntityManager is + * running on (`em.getPlatform()`) plus an already-qualified column reference + * (`'t.created_at'`), and dispatches on that platform at runtime. TREK ships + * one binary and the database is chosen by configuration, so the dialect is a + * runtime property of the live connection, not a build-time swap: when the + * Postgres driver arrives this file grows a second branch and no repository + * changes. An unknown platform fails closed rather than guessing a spelling. + * + * Values are never interpolated — the day count in `dateAdd` is checked to be + * an integer before it is spelled. + */ + +function column(ref: string): string { + if (!/^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)?$/i.test(ref)) { + throw new Error(`sql-functions: not a column reference: ${ref}`); + } + return ref; +} + +function unsupported(platform: Platform): never { + throw new Error(`sql-functions: no implementation for platform ${platform.constructor.name}`); +} + +/** The calendar date (`YYYY-MM-DD`) of a timestamp column. */ +export function dateOf(platform: Platform, ref: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`date(${column(ref)})`); + return unsupported(platform); +} + +/** The timestamp column shifted by whole days, as a calendar date. */ +export function dateAdd(platform: Platform, ref: string, days: number): RawQueryFragment { + if (!Number.isInteger(days)) throw new Error(`sql-functions: dateAdd needs an integer day count, got ${days}`); + if (platform instanceof SqlitePlatform) { + const sign = days < 0 ? '-' : '+'; + return raw(`date(${column(ref)}, '${sign}${Math.abs(days)} days')`); + } + return unsupported(platform); +} + +/** The database clock, in the same text the column defaults produce. */ +export function currentTimestamp(platform: Platform): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw('CURRENT_TIMESTAMP'); + return unsupported(platform); +} + +/** + * A bare reference to another column of the same row, for a filter or update + * value that must compare against or combine with a sibling column instead + * of a bound parameter (e.g. `used_count < max_uses` — `InviteTokensRepository + * .incrementUsedCount`'s capacity guard). Portable across dialects on its own, + * but still routed through this file (not spelled with `raw()` in a + * repository) so every escape hatch stays in one place ESLint's + * `no-restricted-syntax` rule for `src/db/repositories/**` can enforce. + */ +export function columnRef(platform: Platform, ref: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(column(ref)); + return unsupported(platform); +} + +/** A column shifted by a signed integer amount: `+ n` / `- n`. */ +export function columnIncrementedBy(platform: Platform, ref: string, amount: number): RawQueryFragment { + if (!Number.isInteger(amount)) { + throw new Error(`sql-functions: columnIncrementedBy needs an integer amount, got ${amount}`); + } + if (platform instanceof SqlitePlatform) { + const sign = amount < 0 ? '-' : '+'; + return raw(`${column(ref)} ${sign} ${Math.abs(amount)}`); + } + return unsupported(platform); +} + +/** + * A case-insensitive column reference, for use as a filter's object key: + * `{ [lower(platform, 'email')]: }` produces + * `WHERE LOWER(email) = `. + * + * **SQLite's `LOWER()` is ASCII-only; JavaScript's `String.prototype + * .toLowerCase()` is full-Unicode.** They do NOT agree on every input this + * codebase stores — a locally-registered e-mail can carry any non-whitespace + * character (`EMAIL_REGEX`), including non-ASCII letters (program rule 18; + * Plan 3b Task 7 review, H1: `JOSÉ@x.com` proved a live login lockout). Two + * value-side shapes are legitimate, and they are NOT interchangeable: + * + * - `LOWER(col) = LOWER(?)` — pair with `lowerParam()` below, binding the + * RAW value. Both sides are folded by the SAME engine (SQLite's), so they + * agree on every input. This is the shape for every legacy + * `LOWER(col) = LOWER(?)` statement (AU9, AU12, UP5, UP6, O12). + * - `LOWER(col) = ?` — pair with a value the CALLER has already lowered in + * JS (`value.toLowerCase()`) as a plain string, matching a legacy + * `LOWER(col) = ?` statement exactly (only O4 today — + * `OidcService.findOrCreateUser` JS-lowers the email once upfront and the + * legacy statement it replaced never re-lowered it in SQL). Do not use + * this shape for a value the caller has not already lowered. + * + * Typed `RawQueryFragment & symbol` (not the bare `RawQueryFragment` the + * other helpers in this file return): TypeScript only accepts a + * `string | number | symbol` as a computed property name, and a raw + * fragment is only usable as an object key because `raw()`'s default + * generic already brands it with a `[Symbol.toPrimitive]`. The other + * helpers here are only ever used as VALUES, where that brand isn't needed. + */ +export function lower(platform: Platform, ref: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`LOWER(${column(ref)})`); + return unsupported(platform); +} + +/** + * The value-side twin of `lower()`: `LOWER(?)` bound with the RAW, + * untransformed value, for the right-hand side of a `lower(platform, col)` + * filter key. Together they render `LOWER() = LOWER(?)`, + * byte-for-byte a legacy `LOWER(col) = LOWER(?)` statement, because SQLite + * folds BOTH sides with the same (ASCII-only) engine — proven directly + * against `better-sqlite3`: `SELECT LOWER(?)` on `'JOSÉ@x.com'` yields + * `'josÉ@x.com'` (only the ASCII letters fold; the `É` is untouched), the + * same transform SQLite would apply to a stored column. Do NOT pre-lower + * the value in JS before calling this (program rule 18) — that reintroduces + * the mixed-engine bug this helper exists to close. + */ +export function lowerParam(platform: Platform, value: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw('LOWER(?)', [value]); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3c Task 0b (R6) — no consumer yet: Tasks 1–8 wire these in as each +// converts the statement that needs them (`place-shadow.service.ts:192`, +// `places.service.ts:642,651-652`, the ~16 `COALESCE` sites across +// `days`/`assignments`/`trip-members`/`places`). Added here, with their own +// SQLF-0xx tests pinning the SQLite text against a raw statement, so a later +// task imports a tested helper instead of writing one inline. +// --------------------------------------------------------------------------- + +/** + * `datetime('now', '-N days')` — the SQLite clock shifted backwards by whole + * days, as TEXT (not a stored column's value — `dateAdd` above is the wrong + * shape for this: it shifts a *column*, not "now"). `days` is validated to be + * a non-negative integer and spelled directly into the fragment rather than + * bound: the legacy statement (`place-shadow.service.ts:192`) binds the + * WHOLE modifier string (`'-30 days'`) as one parameter, and MikroORM's raw + * fragments do not accept a placeholder inside the modifier argument of a + * SQLite date/time function — `datetime('now', ?)` with `'-30 days'` bound + * does work in raw SQLite, but composing that shape through a `raw()` + * fragment used as a QueryBuilder value needs the modifier already in the + * SQL text, the same way `dateAdd` above spells its own day count. + */ +export function nowMinusDays(platform: Platform, days: number): RawQueryFragment { + if (!Number.isInteger(days) || days < 0) { + throw new Error(`sql-functions: nowMinusDays needs a non-negative integer day count, got ${days}`); + } + if (platform instanceof SqlitePlatform) return raw(`datetime('now', '-${days} days')`); + return unsupported(platform); +} + +/** + * `TRIM()`, standalone. **Does NOT compose with `lower()`** to build + * `LOWER(TRIM(name))` (Task 0b review M3): `lower()`'s `ref` argument goes + * through `column()`, whose regex rejects anything but a bare + * `alias.column` reference — `TRIM(name)` fails it — and even if it didn't, + * `coalesce()`'s own docstring in this file documents that a + * `RawQueryFragment` cannot be nested inside another one (`raw()`'s + * `[Symbol.toPrimitive]` only implements the `'string'` coercion hint, which + * is the object-key position, not template-literal interpolation). Use + * `lowerTrim()` below for the composed shape. + */ +export function trim(platform: Platform, ref: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`TRIM(${column(ref)})`); + return unsupported(platform); +} + +/** + * `LOWER(TRIM())` as one fragment — the shape `places.service.ts:642` + * (`lower(trim(name))`, PL26) actually needs; `lower()`/`trim()` above + * cannot be composed to build it (see `trim()`'s docstring, Task 0b review + * M3). Typed `RawQueryFragment & symbol`, the same brand `lower()` carries, + * for use as a filter KEY: `{ [lowerTrim(platform, 'name')]: lowerParam(...) }`. + * SQLite's `LOWER()` is ASCII-only (program rule 18) — unchanged by the + * `TRIM()`. + */ +export function lowerTrim(platform: Platform, ref: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`LOWER(TRIM(${column(ref)}))`); + return unsupported(platform); +} + +/** + * `COALESCE(, )` — both sides column references, for the + * `COALESCE(a, b)` SELECT-projection shape (`days.service.ts:93-94`, + * `assignments.service.ts:103-104`, `trip-members.service.ts:116-143`, …). + * + * Not composed by nesting a `RawQueryFragment` inside this one: MikroORM's + * `RawQueryFragment[Symbol.toPrimitive]` only returns a usable value for the + * `'string'` coercion hint (object-key position) — a template-literal + * `${fragment}` embed uses the `'default'` hint and throws + * (`node_modules/@mikro-orm/core/utils/RawQueryFragment.js`, verified + * directly, not assumed). So `coalesce`'s second argument is a second + * `column()`-validated identifier, spelled into the same fragment as the + * first — two columns, one statement, matching `lower`/`lowerParam`'s + * two-function split for the same reason (a filter KEY vs a bound VALUE + * are different shapes). + */ +export function coalesce(platform: Platform, ref: string, fallbackRef: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`COALESCE(${column(ref)}, ${column(fallbackRef)})`); + return unsupported(platform); +} + +/** + * `COALESCE(, ?)` — the value-side twin of `coalesce()` above, for the + * `COALESCE(col, ?)` UPDATE-SET shape (`places.service.ts:344,351,363,…`). + * `value` is bound as a genuine parameter, never interpolated. + * + * Typed `RawQueryFragment & symbol` (widened from a bare `RawQueryFragment` + * in Plan 3d Task 0, R3 — `_shared/reservation-visibility.ts`'s + * `publicReservationCondition` needs `COALESCE(alias.ingest_state, 'live')` + * as a `.where({...})` filter KEY, `{ [coalesceParam(...)]: { $ne: 'staged' + * } }`, the same brand `lower()`/`lowerTrim()`/`castInteger()` carry for + * that use): every existing UPDATE-SET caller (`Places.repository.ts`'s six + * `coalesceParam(...)` assignments into a loosely-typed `data` object) keeps + * typechecking unchanged — `RawQueryFragment & symbol` is assignable + * anywhere a plain `RawQueryFragment` is expected, a strict widening with no + * behaviour change (the function body is untouched). + */ +export function coalesceParam(platform: Platform, ref: string, value: string | number | null): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`COALESCE(${column(ref)}, ?)`, [value]); + return unsupported(platform); +} + +/** + * `COALESCE(?, )` — the MIRROR-IMAGE value-side shape of + * {@link coalesceParam} above: the bound value goes FIRST, the column is + * the fallback. NOT interchangeable with `coalesceParam` — that one keeps + * the EXISTING column unless it is NULL (`places.service.ts`'s own legacy + * shape); this one prefers the NEW value unless IT is null, keeping the + * existing column only then. `doc-sync.service.ts`'s DS23/DS24 + * (`upsertItem`'s `file_id=COALESCE(?,file_id)`, + * `remote_id=COALESCE(?,remote_id)`, … eight columns, both the plain + * `nativeUpdate` and the Kysely `ON CONFLICT` forms) is the shape this + * exists for — confirmed genuinely the opposite direction from + * `coalesceParam`'s own doc'd precedent by reading the legacy SQL text + * literally, not assumed from the function name alone (a same-named + * "coalesce" helper reused in the wrong direction silently drops every + * incoming update — verified directly against a live row: a `recordAttempt` + * call passing a genuinely new `remote_version`/`content_sha256` left both + * columns exactly as they were before, since `coalesceParam`'s `COALESCE(col, + * ?)` always preferred the non-null existing value). + */ +export function coalesceOverride(platform: Platform, value: string | number | null, ref: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`COALESCE(?, ${column(ref)})`, [value]); + return unsupported(platform); +} + +/** + * `ABS(- ?)`, for use as a filter KEY (the `'string'`-hint coercion + * `lower()` above relies on) so it composes with `$lte`/`$gte`: + * `{ [absDifference(platform, 'lat', targetLat)]: { $lte: tolerance } }` + * renders `WHERE ABS(lat - ?) <= ?`, matching `places.service.ts:651-652`'s + * `abs(lat - ?) <= ?` (and the sibling `lng` comparison at `:652`) exactly. + * Typed `RawQueryFragment & symbol`, same reason `lower()` is: only a + * `string | number | symbol` is a valid computed property name, and the + * brand only lives on that typing for values meant to be used as a key. + */ +export function absDifference(platform: Platform, ref: string, value: number): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`ABS(${column(ref)} - ?)`, [value]); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3c Task 1 — aggregate SELECT projections (`PlaceShadowPicksRepository +// .totals`/`countBySource`/`countByLiveRank`, `place-shadow.service.ts:144, +// 149, 157`). `alias` is a hand-written literal at every call site today +// (never user input), validated the same way `column()` validates a column +// reference, so a future caller cannot smuggle SQL through it either. +// --------------------------------------------------------------------------- + +function alias(name: string): string { + if (!/^[a-z_][a-z0-9_]*$/i.test(name)) { + throw new Error(`sql-functions: not an alias: ${name}`); + } + return name; +} + +/** `COUNT(*) as `, for a single-row totals read or a `GROUP BY` count column. */ +export function countAll(platform: Platform, aliasName: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`COUNT(*) as ${alias(aliasName)}`); + return unsupported(platform); +} + +/** `MIN() as `. */ +export function minOf(platform: Platform, ref: string, aliasName: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`MIN(${column(ref)}) as ${alias(aliasName)}`); + return unsupported(platform); +} + +/** `MAX() as `. */ +export function maxOf(platform: Platform, ref: string, aliasName: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`MAX(${column(ref)}) as ${alias(aliasName)}`); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3c Task 6 — `TripMembersRepository.listWithUserAndInviter` +// (`trip-members.service.ts:117`, TM2): the `role` column, a `CASE WHEN` +// comparing the joined user's id against the trip's owner id. +// --------------------------------------------------------------------------- + +/** + * `CASE WHEN = ? THEN ? ELSE ? END`, for use as a `.select()` value + * (chain `.as(aliasName)` at the call site, the same way `coalesce()`'s + * callers do — this file does not bake the alias into the SQL text itself + * for this one, unlike `countAll`/`minOf`/`maxOf`, because `.as()` already + * covers it and a `value`-bearing fragment cannot reuse `alias()`'s bare + * identifier validation the same way an aggregate's literal alias does). + * `value`/`whenTrue`/`whenFalse` are all passed through `raw()`'s own + * parameter escaping, never spliced into the SQL text by this function — + * even though today's only caller (TM2's owner/member role label) passes + * fixed literal strings, parity is by VALUE, not by whether the string + * happens to be a source-code literal at the call site. (Task 6 review's + * I1: downstream, MikroORM still formats the FINAL query with every + * parameter inlined as an escaped literal in the SQL text it hands + * better-sqlite3 — not as a separate bound-params array — so a non-finite + * `value` renders as the bare token `NaN`/`Infinity` and fails at PREPARE + * time regardless of this function's own escaping; callers must guard a + * non-finite `value` before calling.) + */ +export function caseWhenEquals(platform: Platform, ref: string, value: number, whenTrue: string, whenFalse: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`CASE WHEN ${column(ref)} = ? THEN ? ELSE ? END`, [value, whenTrue, whenFalse]); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3c Task 1 fix round (review M1) — `PlaceShadowPicksRepository +// .countBySource` (PS5): `.orderBy()` on `countAll()`'s aliased column throws +// ("Trying to query by not existing property" — `raw()`'s alias carries no +// literal type for `ExtractRawAliases` to register), and sorting the fetched +// rows in JS is NOT equivalent to the legacy `ORDER BY count DESC`: SQLite's +// own sorter is not stable, so on a tie it orders descending by GROUP BY key, +// while `Array.prototype.sort` (stable) leaves ties in the rows' incoming +// (ascending) order — verified directly (five sources, two tied pairs). +// --------------------------------------------------------------------------- + +/** + * `COUNT(*)` as an ORDER BY EXPRESSION (not an alias — `.orderBy()` cannot + * order by `countAll()`'s aliased column; ordering by the expression itself + * works and reproduces the legacy `ORDER BY count DESC` tie order exactly). + * Typed `RawQueryFragment & symbol`, the same brand `lower()`/`lowerTrim()` + * carry, so it is usable as an `.orderBy()` object key: + * `.orderBy({ [countAllRef(platform)]: 'desc' })`. + */ +export function countAllRef(platform: Platform): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw('COUNT(*)'); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3d Task 0 (R6) — no consumer yet: Tasks 2/4 wire these in as each +// converts the statement that needs them (`reservations.service.ts` RS11 and +// RS20 `listUpcoming`, `reservation-visibility.ts` RV2, DY23 in +// `days.service.ts`). Added here, with their own SQLF-0xx tests pinning the +// SQLite text against a raw statement on seeded rows, so Tasks 1-6 import a +// tested helper instead of writing one inline. +// --------------------------------------------------------------------------- + +/** + * `GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'` — "does this + * column's TEXT value start with an ISO calendar date (`YYYY-MM-DD`)?", the + * legacy `DATED` constant (`reservations.service.ts:120`: + * `` `r.reservation_time GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'` ``, + * used at `:453-454` inside RS20's two `CASE WHEN` arms). Spelled with + * SQLite's `[0-9]` character-class glob syntax, NOT the plan's shorthand + * `'????-??-??*'` — verified directly against the legacy source, not + * assumed: a `?` in SQLite GLOB matches any single character (letters + * included), so `????-??-??*` is a strictly WIDER, non-equivalent pattern + * (it would also match `abcd-ef-gh...`); only the digit-class form matches + * the legacy's actual behaviour byte-for-byte. + * + * A full boolean expression, not a bare value — pair with a `CASE WHEN` + * builder or a QB/Kysely raw condition, never nest inside another raw + * fragment (see `coalesce()`'s docstring on why nesting doesn't work). + * Kysely has no `GLOB` comparison operator (`operator-node.d.ts`'s + * `ComparisonOperator` union omits it — verified against the installed + * 0.29.6 typings, per the inventory's §19 MikroORM/Kysely capability table) + * — this helper's SQLite-only reach is therefore the only portable spelling + * for this shape until a second platform is added. + */ +export function startsWithIsoDate(platform: Platform, ref: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) { + return raw(`${column(ref)} GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'`); + } + return unsupported(platform); +} + +/** + * `substr(, [, ])`, 1-based on both ends — the legacy + * shape at `reservations.service.ts:453` (`substr(r.reservation_time, 1, + * 10)`, three-arg), `:454` (`substr(r.reservation_time, 12)`, two-arg — "from + * position 12 to the end of the string") and `days.service.ts` DY23 + * (`SUBSTR(reservation_time, 11)`, same two-arg shape, spelled uppercase in + * that statement — SQL function names are case-insensitive, so this helper's + * lowercase spelling is behaviourally identical). `length` is optional, + * matching SQLite's own two-arg `substr(X, Y)` form (everything from `Y` to + * the end) — passing it renders the three-arg form instead. `start` and + * `length` are validated integers and spelled directly (not bound): they are + * always source-code constants at every known call site (`1`, `10`, `11`, + * `12`), never user input, the same trust boundary `dateAdd`'s day count and + * `columnIncrementedBy`'s amount already rely on in this file. + */ +export function substring(platform: Platform, ref: string, start: number, length?: number): RawQueryFragment { + if (!Number.isInteger(start) || start < 1) { + throw new Error(`sql-functions: substring needs a 1-based integer start, got ${start}`); + } + if (length !== undefined && (!Number.isInteger(length) || length < 0)) { + throw new Error(`sql-functions: substring needs a non-negative integer length, got ${length}`); + } + if (platform instanceof SqlitePlatform) { + if (length === undefined) return raw(`substr(${column(ref)}, ${start})`); + return raw(`substr(${column(ref)}, ${start}, ${length})`); + } + return unsupported(platform); +} + +/** + * A part of a `concat()` expression: either a column reference (rendered + * through `column()`, the same identifier validation every other helper in + * this file applies) or a plain value bound as a genuine parameter (`?`) — + * never interpolated, even for what today's only legacy caller spells as a + * literal (`'T'` in `d.date || 'T' || a.check_in`, + * `reservations.service.ts:475, :493`): a bound constant and an interpolated + * one produce byte-identical SQLite behaviour for a plain string, and + * binding is the shape every other value-side helper in this file already + * prefers (`coalesceParam`, `lowerParam`, `absDifference`). + */ +export type ConcatPart = { column: string } | { value: string }; + +/** + * ` || || …` — SQLite (and Postgres) string concatenation, the + * legacy shape at `reservations.service.ts:475, :493` + * (`d.date || 'T' || a.check_in`, RS20's two check-in/check-out arms) and + * `days.service.ts` DY23 (`:date || SUBSTR(reservation_time, 11)`, inside an + * `UPDATE … SET`). Needs at least two parts — a one-part "concatenation" is + * a caller bug, not a legitimate call. Each part is a `ConcatPart` (see its + * own docstring) rather than a bare string, so a column reference and a + * bound literal can never be confused with each other the way an untyped + * `...parts: string[]` would invite (a plain string could mean either). + * + * Deliberately does NOT accept a nested `RawQueryFragment` as a part (e.g. + * the output of `substring()` above) — the same limitation `coalesce()`'s + * docstring documents: `RawQueryFragment`'s `[Symbol.toPrimitive]` only + * implements the `'string'` coercion hint (the object-key position), not the + * `'default'` hint a template-literal embed or an array-join would need, so + * composing `concat(platform, substring(...), ...)` throws at the `raw()` + * boundary rather than rendering silently wrong SQL. A caller that needs + * `SUBSTR(...)` as one side of a `||` builds that one statement directly + * (Kysely's `'||'` binary operator, per the inventory's §17a/§19), the same + * way RS20's own CASE/CTE shape is built outside this file's helpers. + */ +export function concat(platform: Platform, ...parts: readonly ConcatPart[]): RawQueryFragment { + if (parts.length < 2) { + throw new Error(`sql-functions: concat needs at least two parts, got ${parts.length}`); + } + if (platform instanceof SqlitePlatform) { + const bindings: string[] = []; + const sql = parts + .map((part) => { + if ('column' in part) return column(part.column); + bindings.push(part.value); + return '?'; + }) + .join(' || '); + return raw(sql, bindings); + } + return unsupported(platform); +} + +/** + * `CAST(AS INTEGER)` — the legacy shape at `reservations.service.ts:471, + * :489` (inside RS20's correlated `stay_first_reservation_id`/title + * subqueries: `CAST(res.accommodation_id AS INTEGER) = a.id`) and + * `reservation-visibility.ts:106-107` (RV2's `publicStaySql`: + * `CAST(vr.accommodation_id AS INTEGER) = ${alias}.id`). Exists specifically + * for `reservations.accommodation_id`, a TEXT column holding integer ids + * with no FK (§18.1 of the inventory — some rows read back as `"14.0"`): + * casting it to INTEGER before comparing against a genuine INTEGER column + * (`day_accommodations.id`) applies numeric affinity to BOTH sides, matching + * `"14"` and `"14.0"` alike, the same way the legacy statement does. + * + * Typed `RawQueryFragment & symbol`, the same brand `lower()`/`lowerTrim()` + * carry: both known legacy call sites compare the cast against ANOTHER + * COLUMN, not a bound value (`= a.id`, `= ${alias}.id`) — the shape is + * `{ [castInteger(platform, 'vr.accommodation_id')]: columnRef(platform, + * 'a.id') }`, pairing this helper's filter-KEY brand with `columnRef()`'s + * existing VALUE-side column reference. The brand also lets it appear in a + * `.select()` projection via `.as(alias)` (the `lowerTrim`/SQLF-027 dual-use + * precedent) for a caller that only needs the cast INTEGER value itself. + */ +export function castInteger(platform: Platform, ref: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`CAST(${column(ref)} AS INTEGER)`); + return unsupported(platform); +} + +/** + * `ABS(JULIANDAY() - JULIANDAY(?))` — the legacy shape at + * `reservations.service.ts:307` (RS11, `resolveDayIdFromTime`'s + * nearest-day fallback: `ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, + * date ASC LIMIT 1`), which clamps an imported booking's date to the + * closest day row in the trip when no exact match exists. `isoDate` is + * bound as a genuine parameter (never interpolated), matching the legacy + * statement's own bind. Distinct from `absDifference()` above: + * `absDifference` is `ABS(- ?)` on plain NUMERIC values (for a + * lat/lng tolerance filter); this helper compares two `JULIANDAY(...)` + * calendar values. Typed `RawQueryFragment & symbol`, the same brand + * `countAllRef()` carries, so it is usable as an `.orderBy()` object key — + * `.orderBy({ [dayDistance(platform, 'date', isoDate)]: 'asc' })` — + * matching the legacy's own `ORDER BY ABS(JULIANDAY(...) - JULIANDAY(?)) ASC` + * shape exactly (an aliased column cannot be ordered by, the same + * `ExtractRawAliases` limitation `countAllRef`'s own docstring / SQLF-024's + * comment documents; ordering by the EXPRESSION itself is what works). + */ +export function dayDistance(platform: Platform, ref: string, isoDate: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`ABS(JULIANDAY(${column(ref)}) - JULIANDAY(?))`, [isoDate]); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3d Task 0 review (H1/L1) — the five helpers above return a MikroORM +// `RawQueryFragment`. Handed into a Kysely statement, a `RawQueryFragment` +// stringifies through its own `[Symbol.toPrimitive]('string')` coercion into +// the literal text `?` (its parameter-placeholder marker, meant for the +// MikroORM QueryBuilder's OWN parameter interpolation, not Kysely's) — Kysely +// binds it as an actual `?` value parameter and the statement fails at +// execution (verified directly, not assumed: `db.selectFrom(...).where(..., +// startsWithIsoDate(platform, 'col'))` throws `SqliteError: near "?": syntax +// error` the first time it runs). Task 2 absorbed this finding (its report, +// H1/L1) — these are genuinely separate functions, not a wrapper over the +// ones above, because a Kysely `Expression` and a MikroORM `RawQueryFragment` +// are different types with no conversion between them. Each takes the +// caller's own `ExpressionBuilder` (the same "no consumer-facing +// generic DB" shape `_shared/reservation-visibility.ts`'s `publicStayExists` +// documents is the only one that survives `tsc` for a shared Kysely helper — +// unlike that one, these ARE fully generic over ``, because they only +// ever call `eb.fn`/`eb.cast`/`eb(...)`, none of which hit the QueryBuilder +// method-chaining inference limit that forced `publicStayExists` to fix its +// shape). Platform-dispatched and fail-closed like every helper above; each +// has an SQLF-0xx test pinning its compiled `{sql, parameters}` AND a +// raw-statement equivalence on seeded rows (`tests/unit/db/dialect/sql-functions.test.ts`). +// --------------------------------------------------------------------------- + +/** + * The Kysely-expression twin of {@link startsWithIsoDate}: ` GLOB + * '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'`, spelled through SQLite's + * function form (`glob(X, Y)` is exactly `Y GLOB X` — the inventory's own + * §19 finding, since Kysely's `ComparisonOperator` union has no `GLOB` + * entry). Returns a boolean `Expression`, usable in a `.where()` + * or inside a `CASE WHEN` condition. Same digit-class pattern as the + * MikroORM version — NOT the wider `'????-??-??*'` shorthand (a `?` in + * SQLite GLOB matches any character, letters included). + */ +export function startsWithIsoDateKysely( + platform: Platform, + eb: ExpressionBuilder, + ref: ReferenceExpression, +): ExpressionWrapper { + if (platform instanceof SqlitePlatform) { + return eb.fn('glob', [eb.val('[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]*'), ref]); + } + return unsupported(platform); +} + +/** + * The Kysely-expression twin of {@link substring}: `substr(, + * [, ])`, 1-based on both ends. `start`/`length` are + * validated integers, the same guard `substring()` above applies, and bound + * as genuine parameters here (`eb.val`) rather than spelled into the SQL + * text — Kysely's `fn()` takes `ReferenceExpression`s, not a raw text + * fragment, so there is no equivalent "spell a constant into the SQL" + * shape to match; a bound integer literal renders identically. + */ +export function substringKysely( + platform: Platform, + eb: ExpressionBuilder, + ref: ReferenceExpression, + start: number, + length?: number, +): ExpressionWrapper { + if (!Number.isInteger(start) || start < 1) { + throw new Error(`sql-functions: substringKysely needs a 1-based integer start, got ${start}`); + } + if (length !== undefined && (!Number.isInteger(length) || length < 0)) { + throw new Error(`sql-functions: substringKysely needs a non-negative integer length, got ${length}`); + } + if (platform instanceof SqlitePlatform) { + const args: ReferenceExpression[] = length === undefined + ? [ref, eb.val(start)] + : [ref, eb.val(start), eb.val(length)]; + return eb.fn('substr', args); + } + return unsupported(platform); +} + +/** A part of a `concatKysely()` expression — a column reference, a bound value, or a nested Kysely `Expression` (composes freely, unlike `concat()`'s MikroORM `RawQueryFragment` form). */ +export type KyselyConcatPart = + | { column: StringReference } + | { value: string } + | { expression: Expression }; + +/** + * The Kysely-expression twin of {@link concat}: ` || || …`, + * SQLite's/Postgres's string concatenation operator, built through the + * expression builder's own binary-operator call (`eb(lhs, '||', rhs)`) so + * it composes with {@link substringKysely}/{@link castIntegerKysely} — + * exactly the composition `concat()`'s MikroORM form cannot do (its + * docstring explains why). + */ +export function concatKysely( + platform: Platform, + eb: ExpressionBuilder, + ...parts: readonly KyselyConcatPart[] +): ExpressionWrapper { + if (parts.length < 2) { + throw new Error(`sql-functions: concatKysely needs at least two parts, got ${parts.length}`); + } + if (!(platform instanceof SqlitePlatform)) return unsupported(platform); + const operand = (part: KyselyConcatPart): Expression => + 'column' in part ? eb.ref(part.column).$castTo() : 'value' in part ? eb.val(part.value) : part.expression; + let acc: Expression = operand(parts[0]!); + for (const part of parts.slice(1)) { + acc = eb(acc, '||', operand(part)); + } + // The loop above always runs at least once (the `parts.length < 2` guard + // above throws otherwise), so `acc` is always the result of the `eb(...)` + // binary-operator call — a real `ExpressionWrapper`, not the bare + // `Expression` interface `operand()`'s own return type declares. + return acc as ExpressionWrapper; +} + +/** + * The Kysely-expression twin of {@link castInteger}: `CAST( AS + * INTEGER)`, for `reservations.accommodation_id` (TEXT) compared against an + * INTEGER column inside a Kysely statement (RS20/RV2's join shape — a + * correlated `EXISTS`/scalar-subquery context the MikroORM QueryBuilder + * cannot express, per the inventory's own T6 ruling). + */ +export function castIntegerKysely( + platform: Platform, + eb: ExpressionBuilder, + ref: ReferenceExpression, +): ExpressionWrapper { + if (platform instanceof SqlitePlatform) return eb.cast(ref, 'integer'); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3f Task 0 (R9) — no consumer yet: Task 2 (`collateNoCase`, SH9's +// `school-holidays.service.ts#checkName` duplicate-name guard) and Task 4 +// (`nowMinusHours`, RJ4's `reminder-jobs.service.ts#todoTick` +// `datetime('now', '-20 hours')`) wire these in when each converts the +// statement that needs them. `lowerTrimParam` (AT31's value side, atlas's +// bucket-list dedup) is Task 1's. Added here, with their own SQLF-0xx tests +// pinning the SQLite text against a raw statement on seeded rows, so each +// task imports a tested helper instead of writing one inline. +// --------------------------------------------------------------------------- + +/** + * A case-insensitive column reference via SQLite's `NOCASE` collating + * sequence, for use as a filter's object key — the same `{ [collateNoCase( + * platform, 'name')]: value }` shape `lower()` documents, rendering `WHERE + * name COLLATE NOCASE = `. The legacy statement this replaces + * (`school-holidays.service.ts:50`, SH9) spells the COLLATE on the VALUE + * side instead (`name = ? COLLATE NOCASE`) — textually different, but SQLite + * resolves a binary comparison's collating sequence from the FIRST operand + * that carries an explicit `COLLATE`, scanning left to right, so `col + * COLLATE NOCASE = ?` and `col = ? COLLATE NOCASE` select the same collation + * (NOCASE) for the whole comparison either way — verified directly + * (SQLF-060: both forms select the identical row set on a mixed-case + * fixture), not assumed from the operator's documented precedence alone. + * + * **Not the same helper as `lower()`, and not reusable in its place.** + * SQLite's built-in `NOCASE` collation is documented to fold ONLY the 26 + * ASCII letters, exactly like `LOWER()`'s own ASCII-only fold (program rule + * 18) — verified directly against `better-sqlite3` (SQLF-061): on every + * non-ASCII fixture this file's own `LOWER()` test (SQLF-014) already + * carries, `x = y COLLATE NOCASE` and `LOWER(x) = LOWER(y)` agree, letter for + * letter — neither engine folds a non-ASCII case pair. So `collateNoCase` + * does NOT diverge from `lower()` on any input constructed here; it is a + * NEW helper anyway (per R9's own instruction) because the two are + * textually different SQL constructs matching two textually different + * legacy statements (`COLLATE NOCASE` vs `LOWER(...)`), and because a + * `COLLATE`-based comparison lets SQLite use an index on the un-wrapped + * column in a way `LOWER(col) = ?` cannot — a real, if not row-visible, + * difference between the two shapes. + */ +export function collateNoCase(platform: Platform, ref: string): RawQueryFragment & symbol { + if (platform instanceof SqlitePlatform) return raw(`${column(ref)} COLLATE NOCASE`); + return unsupported(platform); +} + +/** + * `datetime('now', '-N hours')` — the hour-granularity sibling of + * `nowMinusDays()` above, for `reminder-jobs.service.ts#todoTick`'s + * `datetime('now', '-20 hours')` (RJ4) — 20 hours is not expressible as + * `nowMinusDays` with a fractional day. Same validation shape as + * `nowMinusDays` (a non-negative integer, spelled directly into the + * fragment rather than bound — MikroORM's raw fragments do not accept a + * placeholder inside a SQLite date/time function's modifier argument, the + * same reasoning `nowMinusDays`'s own docstring gives). + */ +export function nowMinusHours(platform: Platform, hours: number): RawQueryFragment { + if (!Number.isInteger(hours) || hours < 0) { + throw new Error(`sql-functions: nowMinusHours needs a non-negative integer hour count, got ${hours}`); + } + if (platform instanceof SqlitePlatform) return raw(`datetime('now', '-${hours} hours')`); + return unsupported(platform); +} + +/** + * The value-side twin of `lowerTrim()`: `LOWER(TRIM(?))` bound with the RAW, + * untransformed value — confirmed genuinely missing by reading this file's + * full export list before adding it (R9), not assumed from the inventory's + * own flag. Pairs with `lowerTrim()` as a filter KEY for the composed + * `LOWER(TRIM()) = LOWER(TRIM(?))` shape + * `atlas.service.ts:876-877`'s AT31 needs (`lower(trim(name)) = + * lower(trim(?))`), the same "bind the raw value, let SQL apply the SAME + * transform to both sides" reasoning `lowerParam()`'s own docstring gives — + * do NOT pre-trim/pre-lower the value in JS before calling this (program + * rule 18: mixing engines is the bug this pairing exists to avoid). + */ +export function lowerTrimParam(platform: Platform, value: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw('LOWER(TRIM(?))', [value]); + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3g Task 0 (R1) — no consumer yet: Task 1 (`getJourneyFull`'s gallery +// read) and Task 3 (`getPublicJourney`'s gallery read) each fold this into +// their own rebuild of `journey-gallery-order.ts`'s `GALLERY_CHRONOLOGICAL_ORDER` +// text (a `COALESCE(NULLIF(tp.taken_at, ''), , +// )` ORDER BY, worked out in full in Task 0's report for both to paste +// in verbatim, neither re-deriving it). Added here, Kysely-only (no MikroORM +// `raw()` sibling): the shape it exists for is genuinely Kysely-only (a +// correlated scalar subquery cannot be expressed through the MikroORM +// QueryBuilder — the same "T6 ruling" `castIntegerKysely`'s own docstring +// cites for RS20/RV2), and nothing else in this plan's inventory needs a +// MikroORM-form epoch-to-ISO conversion (§6: "no other strftime/julianday +// sites found elsewhere in this cluster"). A raw twin can be added later, +// same as every other helper here, the moment a real consumer needs one. +// --------------------------------------------------------------------------- + +/** + * `strftime('%Y-%m-%dT%H:%M:%SZ', / 1000, 'unixepoch')` — the last of + * `GALLERY_CHRONOLOGICAL_ORDER`'s three fallback tiers (R1): when a gallery + * photo has neither a capture time (`tp.taken_at`) nor a linked entry to + * borrow a date from, the moment it was added is what is left to order by. + * `` is an epoch-MILLISECONDS column (`journey_photos.created_at`, like + * every other `*_at` epoch column in this codebase) — SQLite's `unixepoch` + * modifier expects whole SECONDS, so the division is load-bearing, not + * decorative, and matches the legacy text's own `gp.created_at / 1000` + * exactly. SQLite's `/` on two integers truncates instead of rounding; that + * sub-second loss is the legacy statement's own behaviour, preserved here + * rather than "fixed" into a float divide that would render a different ISO + * string for any timestamp not an exact multiple of 1000ms. + * + * Named for what it computes, not the SQL function it spells — matching + * every other `*Kysely` twin in this file (`castIntegerKysely`, not + * `castKysely`; `startsWithIsoDateKysely`, not `globKysely`). + */ +export function unixEpochToIsoKysely( + platform: Platform, + eb: ExpressionBuilder, + ref: ReferenceExpression, +): ExpressionWrapper { + if (platform instanceof SqlitePlatform) { + const seconds = eb(ref, '/', eb.val(1000)); + return eb.fn('strftime', [eb.val('%Y-%m-%dT%H:%M:%SZ'), seconds, eb.val('unixepoch')]); + } + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3h Task 0 — no consumer yet: Task 3 (`dawarich-sync.service.ts`'s +// `listTripsToSync`, DSY2 — `date('now','-400 days')` / `date('now','+1 +// day')`) and Task 4 (`route-usage.service.ts`'s `purgeExpired`, RU4 — +// `date('now', ?)` bound with the modifier `-${RETENTION_DAYS} days`, and +// RETENTION_DAYS (`route-usage.service.ts:13`) is a compile-time module +// constant (`400`), not a runtime value — so RU4's "bound parameter" and +// DSY2's "literal" are the SAME shape at the call site once the modifier is +// known, confirmed directly against both files' actual source rather than +// assumed from the legacy SQL's own binding style) wire `nowDateOffset` in +// when each converts its own statement. +// +// Plan 3f's own four `date('now')` sites (atlas.service.ts AT39/AT42/AT44/ +// AT45 — 3g's inventory's own open question, inherited from 3f and never +// resolved until now) do NOT go through a dialect helper at all: `todayUtc()` +// (a `@trek/shared` JS function) resolves the calendar date once in the +// CALLING SERVICE (`atlas.service.ts:912`, `const trip = await +// this.trips.lastStartedTrip(userId, todayUtc())`) and the repository binds +// it as a plain parameter (`Trips.repository.ts#lastStartedTrip`: +// `.where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), '<=', +// today))`) — confirmed by reading the actual converted code at HEAD, not +// the plan document's description of it. That pattern covers a BARE +// `date('now')` (no offset) cleanly, and needs no new helper here. It does +// not extend to an OFFSET the way DSY2/RU4 need without every call site +// re-deriving day arithmetic in JS by hand, so this file instead grows the +// sibling `nowMinusDays`/`nowMinusHours` already establish for exactly this +// family (Plan 3c Task 0b/Plan 3f Task 0 R9): a signed day-count offset from +// "now", spelled into the fragment after integer validation, the same +// reasoning those two helpers give (MikroORM's raw fragments do not accept a +// placeholder inside a SQLite date/time function's modifier argument). +// `PlaceShadowPicksRepository.expireStale` (Plan 3c Task 1) is this shape's +// own closest working precedent — a retention-window purge built on +// `nowMinusDays` exactly the way `RouteUsageDailyRepository`'s own purge +// (RU4) will be built on `nowDateOffset` below. +// --------------------------------------------------------------------------- + +/** + * `date('now', 'N days')` — the SQLite clock's CALENDAR DATE (`date()`, + * not `datetime()` — `nowMinusDays` above is the wrong shape for this: it + * targets `datetime()`, and only ever subtracts), shifted by a signed whole + * day count. Unlike `nowMinusDays`, `days` may be POSITIVE (forward), + * NEGATIVE (backward) or zero — `dawarich-sync.service.ts`'s + * `listTripsToSync` (DSY2) needs both directions in the SAME statement + * (`date('now','-400 days')` for the trailing edge, `date('now','+1 day')` + * for the leading one). `days` is spelled directly into the fragment (not + * bound), the same reasoning `nowMinusDays`'s own docstring gives. + * + * Verified directly (not assumed) that SQLite's modifier keyword tolerates + * the singular/plural mismatch between the two legacy texts (`'+1 day'` + * singular, `'-400 days'` plural): `date('now','+1 day')` and + * `date('now','+1 days')` render the identical result against a live + * `better-sqlite3` connection, so this helper's always-plural spelling is + * behaviourally identical to both legacy statements, not just the plural one. + */ +export function nowDateOffset(platform: Platform, days: number): RawQueryFragment { + if (!Number.isInteger(days)) { + throw new Error(`sql-functions: nowDateOffset needs an integer day count, got ${days}`); + } + if (platform instanceof SqlitePlatform) { + const sign = days < 0 ? '-' : '+'; + return raw(`date('now', '${sign}${Math.abs(days)} days')`); + } + return unsupported(platform); +} + +/** + * `datetime('now', '+N seconds')` — the seconds-granularity, ADDING sibling + * of `nowMinusDays`/`nowMinusHours` above: the shape `doc-sync.service.ts`'s + * `upsertItem` (DS23, inside an `ON CONFLICT DO UPDATE`'s `CASE WHEN`; DS26, + * inside the sibling `INSERT ... VALUES`) and `recordLinkFailure` (a third, + * structurally identical call site found re-reading the file at HEAD — + * `next_attempt_at = datetime('now', '+' || ? || ' seconds')`, + * `doc-sync.service.ts:967`, not individually numbered in the plan's own + * inventory) all need: a caller-computed backoff window (`backoffSeconds( + * ...)`, a genuine RUNTIME value, never a source-code constant the way + * `nowMinusDays`'s callers' day counts are) added to the database clock. + * `seconds` is validated non-negative-integer and spelled directly into the + * fragment — the same reasoning (and the same MikroORM raw-fragment + * placeholder limitation) `nowMinusDays`'s own docstring gives; every known + * call site's `seconds` argument is already a JS-computed `number`, never + * user input, the same trust boundary this file relies on elsewhere + * (`dateAdd`'s day count, `substring`'s start/length). + */ +export function nowPlusSeconds(platform: Platform, seconds: number): RawQueryFragment { + if (!Number.isInteger(seconds) || seconds < 0) { + throw new Error(`sql-functions: nowPlusSeconds needs a non-negative integer second count, got ${seconds}`); + } + if (platform instanceof SqlitePlatform) return raw(`datetime('now', '+${seconds} seconds')`); + return unsupported(platform); +} + +/** + * The Kysely-expression twin of {@link nowPlusSeconds}: DS23/DS24's + * `ON CONFLICT ... DO UPDATE` / `INSERT ... VALUES` (R3) is a hand-typed + * Kysely statement — MikroORM's `em.upsert` cannot express a + * partial-unique-index conflict target (R3's own ruling) — so the + * `next_attempt_at` computation inside its `CASE WHEN` needs a Kysely + * `Expression`, not a MikroORM `RawQueryFragment` (SQLF-048: a + * `RawQueryFragment` throws when handed into a Kysely statement). Same + * validation, same always-non-negative-integer trust boundary as the + * MikroORM form above. + */ +export function nowPlusSecondsKysely( + platform: Platform, + eb: ExpressionBuilder, + seconds: number, +): ExpressionWrapper { + if (!Number.isInteger(seconds) || seconds < 0) { + throw new Error(`sql-functions: nowPlusSecondsKysely needs a non-negative integer second count, got ${seconds}`); + } + if (platform instanceof SqlitePlatform) { + return eb.fn('datetime', [eb.val('now'), eb.val(`+${seconds} seconds`)]); + } + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// Plan 3h Task 5 (doc-sync) — `FOUND_AGAIN` (`doc-sync.service.ts:53-54`), the +// module-level SQL-text CASE constant reused by four legacy UPDATEs (DS2 +// `relocate`, DS3 `rename_remote`, DS5 `rename_local`, DS12 `touch`). Unlike +// every other CASE in this file's DS2-DS12 neighbourhood — whose WHEN +// condition binds a value already known in JS at the call site (a boolean +// flag, a state string), so the whole CASE collapses to a plain JS ternary +// deciding which field to include in a `nativeUpdate` call — this one +// compares the ROW'S OWN CURRENT `state`/`file_id` columns, which are not +// knowable until the UPDATE executes. That is genuinely SQL-level, and the +// four call sites share one method (`DocumentSyncItemsRepository`'s private +// `foundAgain` builder) rather than four independent re-derivations, per the +// task's own instruction to keep `FOUND_AGAIN` "a reusable typed CASE +// expression ... consumed by all four". +// --------------------------------------------------------------------------- + +/** + * `CASE WHEN = 'remote_missing' AND IS NOT NULL THEN + * 'synced' ELSE END` — doc-sync's `FOUND_AGAIN` clause, for the + * `state` column of an UPDATE `document_sync_items` SET list. Only a copy + * that is BOTH on record as missing AND still paired to a file recovers this + * way; every other state (`error`, `conflict`, a row with no file at all) + * passes through unchanged. `remote_missing_at` is cleared unconditionally + * by the SAME UPDATE's own `remote_missing_at = NULL` (a plain value, not + * part of this fragment — the caller sets it alongside). + */ +export function foundAgainState(platform: Platform, stateRef: string, fileIdRef: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) { + return raw( + `CASE WHEN ${column(stateRef)} = 'remote_missing' AND ${column(fileIdRef)} IS NOT NULL THEN 'synced' ELSE ${column(stateRef)} END`, + ); + } + return unsupported(platform); +} + +/** + * The Kysely-expression twin of {@link currentTimestamp}: the bare + * `CURRENT_TIMESTAMP` keyword, for a value position inside a hand-typed + * Kysely statement (DS24's `ON CONFLICT ... DO UPDATE SET last_seen_at = + * CURRENT_TIMESTAMP` — R3's partial-index upsert). Verified directly against + * `better-sqlite3` that SQLite accepts the bare keyword (`SELECT + * CURRENT_TIMESTAMP`) but rejects it called as a function + * (`SELECT CURRENT_TIMESTAMP()` → `near "(": syntax error`), so + * `eb.fn('current_timestamp', [])` — every other helper in this file's + * usual Kysely shape — is not an option here; the whole point of a keyword + * literal is that it takes no parentheses. This is this file's one + * Kysely-side use of the `sql` template tag — confined here, the sanctioned + * escape hatch this file already is for every raw-SQL spelling in the + * codebase (the MikroORM helpers above use `raw()` for the identical + * reason), never inline in a repository (the TRAP list's "Kysely `sql` + * banned under repositories" is about repository FILES, not this one). + */ +export function currentTimestampKysely(platform: Platform): RawBuilder { + if (platform instanceof SqlitePlatform) return sql`CURRENT_TIMESTAMP`; + return unsupported(platform); +} + +// --------------------------------------------------------------------------- +// `UsersRepository.setImmichSettings` (IM4, #2475): the self-signed switch is +// trusted for ONE server, so whether a null incoming value may keep the stored +// choice depends on whether the URL written in the same SET list is the URL +// already stored. Like `foundAgainState` above, the WHEN condition reads the +// ROW'S OWN CURRENT column, which is not knowable in JS before the UPDATE runs +// — a read-then-write would be two statements where the legacy SQL had one. +// --------------------------------------------------------------------------- + +/** + * `CASE WHEN IS ? THEN COALESCE(?, ) ELSE COALESCE(?, 0) END` — + * {@link coalesceOverride}'s "new value wins unless it is null" shape, but the + * stored column is only a valid fallback WHILE the key column still holds + * `keyValue` (the value the same UPDATE writes to it). Once the key changes, + * a null `value` falls back to the column's default `0` instead of a choice + * made for a different key. SET expressions see the row as it was before the + * UPDATE, so ` IS ?` compares the STORED key with the new one even + * though the same statement overwrites it. `value` is bound twice on purpose: + * the fragment has two value slots and `raw()` binds positionally. + */ +export function coalesceOverrideWhileSame(platform: Platform, value: number | null, ref: string, keyRef: string, keyValue: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) { + return raw(`CASE WHEN ${column(keyRef)} IS ? THEN COALESCE(?, ${column(ref)}) ELSE COALESCE(?, 0) END`, [keyValue, value, value]); + } + return unsupported(platform); +} + + +// --------------------------------------------------------------------------- +// Tours (#2586): `DayAssignmentsRepository`'s assignment projection carries a +// place's route geometry only when the place is a Tour, so the day list never +// hauls an ordinary track's geometry along with every stop. +// --------------------------------------------------------------------------- + +/** + * `CASE WHEN IS NOT NULL THEN END`, for use as a + * `.select()` value (chain `.as(aliasName)` at the call site). The missing + * ELSE is deliberate: a null test column yields SQL NULL. + */ +export function caseWhenNotNull(platform: Platform, testRef: string, thenRef: string): RawQueryFragment { + if (platform instanceof SqlitePlatform) return raw(`CASE WHEN ${column(testRef)} IS NOT NULL THEN ${column(thenRef)} END`); + return unsupported(platform); +} diff --git a/server/src/db/document-provider-seed.ts b/server/src/db/document-provider-seed.ts index 16e2467a8..6d51e6f50 100644 --- a/server/src/db/document-provider-seed.ts +++ b/server/src/db/document-provider-seed.ts @@ -18,7 +18,7 @@ import type Database from 'better-sqlite3'; * scanning the addon shelf should find the product they actually run. */ -interface ProviderRow { +export interface ProviderRow { id: string; name: string; description: string; @@ -26,7 +26,7 @@ interface ProviderRow { sort_order: number; } -interface ProviderFieldRow { +export interface ProviderFieldRow { provider_id: string; field_key: string; label: string; @@ -38,7 +38,7 @@ interface ProviderFieldRow { sort_order: number; } -const PROVIDERS: ProviderRow[] = [ +export const DOCUMENT_PROVIDERS: ProviderRow[] = [ { id: 'paperless', name: 'Paperless-ngx', @@ -82,56 +82,286 @@ const PROVIDERS: ProviderRow[] = [ * instance with a self-signed certificate is common enough to need the switch * and not common enough to justify shipping it on. */ -const FIELDS: ProviderFieldRow[] = [ +export const DOCUMENT_PROVIDER_FIELDS: ProviderFieldRow[] = [ // Paperless-ngx: token auth, tag scope. - { provider_id: 'paperless', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://paperless.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'paperless', field_key: 'api_token', label: 'providerApiToken', input_type: 'password', placeholder: 'API token', hint: 'hintPaperlessToken', required: 1, secret: 1, sort_order: 1 }, - { provider_id: 'paperless', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 2 }, + { + provider_id: 'paperless', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://paperless.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'paperless', + field_key: 'api_token', + label: 'providerApiToken', + input_type: 'password', + placeholder: 'API token', + hint: 'hintPaperlessToken', + required: 1, + secret: 1, + sort_order: 1, + }, + { + provider_id: 'paperless', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 2, + }, // Papra: bearer key, and an organisation the key must already belong to. - { provider_id: 'papra', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://papra.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'papra', field_key: 'api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'ppapi_…', hint: 'hintPapraKey', required: 1, secret: 1, sort_order: 1 }, - { provider_id: 'papra', field_key: 'organization_id', label: 'providerOrganization', input_type: 'text', placeholder: 'org_…', hint: 'hintPapraOrg', required: 1, secret: 0, sort_order: 2 }, - { provider_id: 'papra', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 3 }, + { + provider_id: 'papra', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://papra.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'papra', + field_key: 'api_key', + label: 'providerApiKey', + input_type: 'password', + placeholder: 'ppapi_…', + hint: 'hintPapraKey', + required: 1, + secret: 1, + sort_order: 1, + }, + { + provider_id: 'papra', + field_key: 'organization_id', + label: 'providerOrganization', + input_type: 'text', + placeholder: 'org_…', + hint: 'hintPapraOrg', + required: 1, + secret: 0, + sort_order: 2, + }, + { + provider_id: 'papra', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 3, + }, // Nextcloud: an app password, never the account password. The latter breaks // the moment the account turns on 2FA or logs in through OIDC. - { provider_id: 'nextcloud', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://cloud.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'nextcloud', field_key: 'login_name', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintNextcloudLogin', required: 1, secret: 0, sort_order: 1 }, - { provider_id: 'nextcloud', field_key: 'app_password', label: 'providerAppPassword', input_type: 'password', placeholder: 'app password', hint: 'hintNextcloudAppPassword', required: 1, secret: 1, sort_order: 2 }, - { provider_id: 'nextcloud', field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/TREK', hint: 'hintBasePath', required: 0, secret: 0, sort_order: 3 }, - { provider_id: 'nextcloud', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 4 }, + { + provider_id: 'nextcloud', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://cloud.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'nextcloud', + field_key: 'login_name', + label: 'providerUsername', + input_type: 'text', + placeholder: 'username', + hint: 'hintNextcloudLogin', + required: 1, + secret: 0, + sort_order: 1, + }, + { + provider_id: 'nextcloud', + field_key: 'app_password', + label: 'providerAppPassword', + input_type: 'password', + placeholder: 'app password', + hint: 'hintNextcloudAppPassword', + required: 1, + secret: 1, + sort_order: 2, + }, + { + provider_id: 'nextcloud', + field_key: 'base_path', + label: 'providerBasePath', + input_type: 'text', + placeholder: '/TREK', + hint: 'hintBasePath', + required: 0, + secret: 0, + sort_order: 3, + }, + { + provider_id: 'nextcloud', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 4, + }, // OpenCloud: auth-app token, spaces instead of folders. - { provider_id: 'opencloud', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://opencloud.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'opencloud', field_key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: null, required: 1, secret: 0, sort_order: 1 }, - { provider_id: 'opencloud', field_key: 'app_token', label: 'providerAppToken', input_type: 'password', placeholder: 'app token', hint: 'hintOpenCloudToken', required: 1, secret: 1, sort_order: 2 }, - { provider_id: 'opencloud', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 3 }, + { + provider_id: 'opencloud', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://opencloud.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'opencloud', + field_key: 'username', + label: 'providerUsername', + input_type: 'text', + placeholder: 'username', + hint: null, + required: 1, + secret: 0, + sort_order: 1, + }, + { + provider_id: 'opencloud', + field_key: 'app_token', + label: 'providerAppToken', + input_type: 'password', + placeholder: 'app token', + hint: 'hintOpenCloudToken', + required: 1, + secret: 1, + sort_order: 2, + }, + { + provider_id: 'opencloud', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 3, + }, // Synology: DSM has no app tokens at all, so this is a real account. The OTP // is used once to obtain a device token, which is what gets stored. - { provider_id: 'synologydrive', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://nas.example.com:5001', hint: 'hintSynologyUrl', required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'synologydrive', field_key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintSynologyUser', required: 1, secret: 0, sort_order: 1 }, - { provider_id: 'synologydrive', field_key: 'password', label: 'providerPassword', input_type: 'password', placeholder: 'password', hint: null, required: 1, secret: 1, sort_order: 2 }, - { provider_id: 'synologydrive', field_key: 'otp_code', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: 'hintSynologyOtp', required: 0, secret: 1, sort_order: 3 }, - { provider_id: 'synologydrive', field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/trek', hint: 'hintBasePath', required: 0, secret: 0, sort_order: 4 }, - { provider_id: 'synologydrive', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 5 }, + { + provider_id: 'synologydrive', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://nas.example.com:5001', + hint: 'hintSynologyUrl', + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'synologydrive', + field_key: 'username', + label: 'providerUsername', + input_type: 'text', + placeholder: 'username', + hint: 'hintSynologyUser', + required: 1, + secret: 0, + sort_order: 1, + }, + { + provider_id: 'synologydrive', + field_key: 'password', + label: 'providerPassword', + input_type: 'password', + placeholder: 'password', + hint: null, + required: 1, + secret: 1, + sort_order: 2, + }, + { + provider_id: 'synologydrive', + field_key: 'otp_code', + label: 'providerOTP', + input_type: 'text', + placeholder: '123456', + hint: 'hintSynologyOtp', + required: 0, + secret: 1, + sort_order: 3, + }, + { + provider_id: 'synologydrive', + field_key: 'base_path', + label: 'providerBasePath', + input_type: 'text', + placeholder: '/trek', + hint: 'hintBasePath', + required: 0, + secret: 0, + sort_order: 4, + }, + { + provider_id: 'synologydrive', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 5, + }, ]; export function seedDocumentProviders(db: Database.Database): void { const insertProvider = db.prepare( 'INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)', ); - for (const p of PROVIDERS) insertProvider.run(p.id, p.name, p.description, p.icon, p.sort_order); + for (const p of DOCUMENT_PROVIDERS) insertProvider.run(p.id, p.name, p.description, p.icon, p.sort_order); const insertField = db.prepare( `INSERT OR IGNORE INTO document_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); - for (const f of FIELDS) { - insertField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.sort_order); + for (const f of DOCUMENT_PROVIDER_FIELDS) { + insertField.run( + f.provider_id, + f.field_key, + f.label, + f.input_type, + f.placeholder, + f.hint, + f.required, + f.secret, + f.sort_order, + ); } } -export const DOCUMENT_PROVIDER_SEED_IDS = PROVIDERS.map((p) => p.id); +export const DOCUMENT_PROVIDER_SEED_IDS = DOCUMENT_PROVIDERS.map((p) => p.id); diff --git a/server/src/db/entities/Addons.entity.ts b/server/src/db/entities/Addons.entity.ts new file mode 100644 index 000000000..06d317782 --- /dev/null +++ b/server/src/db/entities/Addons.entity.ts @@ -0,0 +1,33 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { AddonsRepository } from '../repositories/Addons.repository'; + +export interface AddonConfig { + [key: string]: unknown; +} + +export class Addons { + [EntityRepositoryType]?: AddonsRepository; + id?: string | null; + name!: string; + description?: string | null; + type: string & Opt = 'global'; + icon?: string | null = 'Puzzle'; + enabled?: boolean | null = false; + config?: AddonConfig | null; + sort_order?: number | null = 0; +} + +export const AddonsSchema = defineEntity({ + class: Addons, + repository: () => AddonsRepository, + properties: { + id: p.text().primary().nullable(), + name: p.text(), + description: p.text().nullable(), + type: p.text().default('global'), + icon: p.text().nullable(), + enabled: p.boolean().nullable(), + config: p.json().nullable().default('{}'), + sort_order: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/AppSettings.entity.ts b/server/src/db/entities/AppSettings.entity.ts new file mode 100644 index 000000000..cc57c6318 --- /dev/null +++ b/server/src/db/entities/AppSettings.entity.ts @@ -0,0 +1,18 @@ +import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { AppSettingsRepository } from '../repositories/AppSettings.repository'; + +export class AppSettings { + [EntityRepositoryType]?: AppSettingsRepository; + [PrimaryKeyProp]?: 'key'; + key?: string | null; + value?: string | null; +} + +export const AppSettingsSchema = defineEntity({ + class: AppSettings, + repository: () => AppSettingsRepository, + properties: { + key: p.text().primary().nullable(), + value: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/AssignmentParticipants.entity.ts b/server/src/db/entities/AssignmentParticipants.entity.ts new file mode 100644 index 000000000..d1b4dd36a --- /dev/null +++ b/server/src/db/entities/AssignmentParticipants.entity.ts @@ -0,0 +1,26 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { AssignmentParticipantsRepository } from '../repositories/AssignmentParticipants.repository'; +import { DayAssignments } from './DayAssignments.entity'; +import { Users } from './Users.entity'; + +export class AssignmentParticipants { + [EntityRepositoryType]?: AssignmentParticipantsRepository; + id!: number & Opt; + assignment!: Ref; + assignment_id!: number; + user!: Ref; + user_id!: number; +} + +export const AssignmentParticipantsSchema = defineEntity({ + class: AssignmentParticipants, + repository: () => AssignmentParticipantsRepository, + uniques: [{ properties: ['assignment', 'user'] }], + properties: { + id: p.integer().primary(), + assignment: () => p.manyToOne(DayAssignments).ref().deleteRule('cascade').hidden().index('idx_assignment_participants_assignment'), + assignment_id: p.integer().persist(false).index('idx_assignment_participants_assignment'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + }, +}); diff --git a/server/src/db/entities/AuditLog.entity.ts b/server/src/db/entities/AuditLog.entity.ts new file mode 100644 index 000000000..696b57a0a --- /dev/null +++ b/server/src/db/entities/AuditLog.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { AuditLogRepository } from '../repositories/AuditLog.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class AuditLog { + [EntityRepositoryType]?: AuditLogRepository; + id!: number & Opt; + created_at?: string | null; + user?: Ref | null; + user_id?: number | null; + action!: string; + resource?: string | null; + details?: string | null; + ip?: string | null; +} + +export const AuditLogSchema = defineEntity({ + class: AuditLog, + repository: () => AuditLogRepository, + properties: { + id: p.integer().primary(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`).index('idx_audit_log_created'), + user: () => p.manyToOne(Users).ref().nullable().hidden(), + user_id: p.integer().nullable().persist(false), + action: p.text(), + resource: p.text().nullable(), + details: p.text().nullable(), + ip: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/BucketList.entity.ts b/server/src/db/entities/BucketList.entity.ts new file mode 100644 index 000000000..3fa087617 --- /dev/null +++ b/server/src/db/entities/BucketList.entity.ts @@ -0,0 +1,46 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BucketListRepository } from '../repositories/BucketList.repository'; +import { DbTimestampType } from '../types'; +import { DawarichVisitSuggestions } from './DawarichVisitSuggestions.entity'; +import { Users } from './Users.entity'; + +export class BucketList { + [EntityRepositoryType]?: BucketListRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + name!: string; + lat?: number | null; + lng?: number | null; + country_code?: string | null; + notes?: string | null; + created_at?: string | null; + target_date?: string | null; + visited_at?: string | null; + visited_source?: string | null; + region_code?: string | null; + dawarich_visit_suggestions_collection = new Collection(this); + dawarich_visit_suggestions_collection1 = new Collection(this); +} + +export const BucketListSchema = defineEntity({ + class: BucketList, + repository: () => BucketListRepository, + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + name: p.text(), + lat: p.double().nullable(), + lng: p.double().nullable(), + country_code: p.text().nullable(), + notes: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + target_date: p.text().nullable().defaultRaw(`NULL`), + visited_at: p.text().nullable(), + visited_source: p.text().nullable(), + region_code: p.text().nullable(), + dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('acceptedBucketListItem').hidden(), + dawarich_visit_suggestions_collection1: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('matchedBucketListItem').hidden(), + }, +}); diff --git a/server/src/db/entities/BudgetCategoryOrder.entity.ts b/server/src/db/entities/BudgetCategoryOrder.entity.ts new file mode 100644 index 000000000..3fdbb27d1 --- /dev/null +++ b/server/src/db/entities/BudgetCategoryOrder.entity.ts @@ -0,0 +1,24 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BudgetCategoryOrderRepository } from '../repositories/BudgetCategoryOrder.repository'; +import { Trips } from './Trips.entity'; + +export class BudgetCategoryOrder { + [EntityRepositoryType]?: BudgetCategoryOrderRepository; + [PrimaryKeyProp]?: ['trip', 'category']; + trip!: Ref; + trip_id!: number; + category!: string; + sort_order: number & Opt = 0; +} + +export const BudgetCategoryOrderSchema = defineEntity({ + class: BudgetCategoryOrder, + repository: () => BudgetCategoryOrderRepository, + uniques: [{ properties: ['trip', 'category'] }], + properties: { + trip: () => p.manyToOne(Trips).primary().ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + category: p.text().primary(), + sort_order: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/BudgetItemMembers.entity.ts b/server/src/db/entities/BudgetItemMembers.entity.ts new file mode 100644 index 000000000..507a1cbc2 --- /dev/null +++ b/server/src/db/entities/BudgetItemMembers.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BudgetItemMembersRepository } from '../repositories/BudgetItemMembers.repository'; +import { BudgetItems } from './BudgetItems.entity'; +import { Users } from './Users.entity'; + +export class BudgetItemMembers { + [EntityRepositoryType]?: BudgetItemMembersRepository; + id!: number & Opt; + budgetItem!: Ref; + budget_item_id!: number; + user!: Ref; + user_id!: number; + paid: number & Opt = 0; + amount?: number | null; +} + +export const BudgetItemMembersSchema = defineEntity({ + class: BudgetItemMembers, + repository: () => BudgetItemMembersRepository, + uniques: [{ properties: ['budgetItem', 'user'] }], + properties: { + id: p.integer().primary(), + budgetItem: () => p.manyToOne(BudgetItems).ref().deleteRule('cascade').hidden().index('idx_budget_item_members_item'), + budget_item_id: p.integer().persist(false).index('idx_budget_item_members_item'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_budget_item_members_user'), + user_id: p.integer().persist(false).index('idx_budget_item_members_user'), + paid: p.integer().default(0), + amount: p.double().nullable(), + }, +}); diff --git a/server/src/db/entities/BudgetItemPayers.entity.ts b/server/src/db/entities/BudgetItemPayers.entity.ts new file mode 100644 index 000000000..5897e81f4 --- /dev/null +++ b/server/src/db/entities/BudgetItemPayers.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BudgetItemPayersRepository } from '../repositories/BudgetItemPayers.repository'; +import { BudgetItems } from './BudgetItems.entity'; +import { Users } from './Users.entity'; + +export class BudgetItemPayers { + [EntityRepositoryType]?: BudgetItemPayersRepository; + id!: number & Opt; + budgetItem!: Ref; + budget_item_id!: number; + user!: Ref; + user_id!: number; + amount!: number & Opt; +} + +export const BudgetItemPayersSchema = defineEntity({ + class: BudgetItemPayers, + repository: () => BudgetItemPayersRepository, + uniques: [{ properties: ['budgetItem', 'user'] }], + properties: { + id: p.integer().primary(), + budgetItem: () => p.manyToOne(BudgetItems).ref().deleteRule('cascade').hidden().index('idx_budget_item_payers_item'), + budget_item_id: p.integer().persist(false).index('idx_budget_item_payers_item'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + amount: p.double().defaultRaw(`0`), + }, +}); diff --git a/server/src/db/entities/BudgetItems.entity.ts b/server/src/db/entities/BudgetItems.entity.ts new file mode 100644 index 000000000..ad9b2bcc6 --- /dev/null +++ b/server/src/db/entities/BudgetItems.entity.ts @@ -0,0 +1,69 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BudgetItemsRepository } from '../repositories/BudgetItems.repository'; +import { DbTimestampType } from '../types'; +import { BudgetItemMembers } from './BudgetItemMembers.entity'; +import { BudgetItemPayers } from './BudgetItemPayers.entity'; +import { FileLinks } from './FileLinks.entity'; +import { Places } from './Places.entity'; +import { Reservations } from './Reservations.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class BudgetItems { + [EntityRepositoryType]?: BudgetItemsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + category: string & Opt = 'Other'; + name!: string; + total_price!: number & Opt; + persons?: number | null; + days?: number | null; + note?: string | null; + sort_order?: number | null = 0; + created_at?: string | null; + paidByUser?: Ref | null; + paid_by_user_id?: number | null; + expense_date?: string | null; + reservation?: Ref | null; + reservation_id?: number | null; + currency?: string | null; + exchange_rate!: number & Opt; + ticket_json?: string | null; + place?: Ref | null; + place_id?: number | null; + budget_item_members_collection = new Collection(this); + budget_item_payers_collection = new Collection(this); + file_links_collection = new Collection(this); +} + +export const BudgetItemsSchema = defineEntity({ + class: BudgetItems, + repository: () => BudgetItemsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_budget_items_trip_id'), + trip_id: p.integer().persist(false).index('idx_budget_items_trip_id'), + category: p.text().default('Other'), + name: p.text(), + total_price: p.double().defaultRaw(`0`), + persons: p.integer().nullable().defaultRaw(`NULL`), + days: p.integer().nullable().defaultRaw(`NULL`), + note: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + paidByUser: () => p.manyToOne(Users).ref().deleteRule('no action').nullable().hidden(), + paid_by_user_id: p.integer().nullable().persist(false), + expense_date: p.text().nullable().defaultRaw(`NULL`), + reservation: () => p.manyToOne(Reservations).ref().nullable().hidden().defaultRaw(`NULL`), + reservation_id: p.integer().nullable().persist(false).defaultRaw(`NULL`), + currency: p.text().nullable(), + exchange_rate: p.double().defaultRaw(`1`), + ticket_json: p.text().nullable(), + place: () => p.manyToOne(Places).ref().nullable().hidden().defaultRaw(`NULL`), + place_id: p.integer().nullable().persist(false).defaultRaw(`NULL`), + budget_item_members_collection: () => p.oneToMany(BudgetItemMembers).mappedBy('budgetItem').hidden(), + budget_item_payers_collection: () => p.oneToMany(BudgetItemPayers).mappedBy('budgetItem').hidden(), + file_links_collection: () => p.oneToMany(FileLinks).mappedBy('budgetItem').hidden(), + }, +}); diff --git a/server/src/db/entities/BudgetSettlements.entity.ts b/server/src/db/entities/BudgetSettlements.entity.ts new file mode 100644 index 000000000..c0543b20a --- /dev/null +++ b/server/src/db/entities/BudgetSettlements.entity.ts @@ -0,0 +1,46 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { BudgetSettlementsRepository } from '../repositories/BudgetSettlements.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class BudgetSettlements { + [EntityRepositoryType]?: BudgetSettlementsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + fromUser!: Ref; + from_user_id!: number; + toUser!: Ref; + to_user_id!: number; + amount!: number & Opt; + created_at?: string | null; + createdByUser?: Ref | null; + created_by_user_id?: number | null; + currency?: string | null; + exchange_rate!: number & Opt; + settled_at?: string | null; + note?: string | null; +} + +export const BudgetSettlementsSchema = defineEntity({ + class: BudgetSettlements, + repository: () => BudgetSettlementsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_budget_settlements_trip'), + trip_id: p.integer().persist(false).index('idx_budget_settlements_trip'), + fromUser: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + from_user_id: p.integer().persist(false), + toUser: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + to_user_id: p.integer().persist(false), + amount: p.double().defaultRaw(`0`), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + createdByUser: () => p.manyToOne(Users).ref().deleteRule('no action').nullable().hidden(), + created_by_user_id: p.integer().nullable().persist(false), + currency: p.text().nullable(), + exchange_rate: p.double().defaultRaw(`1`), + settled_at: p.text().nullable(), + note: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/Categories.entity.ts b/server/src/db/entities/Categories.entity.ts new file mode 100644 index 000000000..f087532bf --- /dev/null +++ b/server/src/db/entities/Categories.entity.ts @@ -0,0 +1,35 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CategoriesRepository } from '../repositories/Categories.repository'; +import { DbTimestampType } from '../types'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Places } from './Places.entity'; +import { Users } from './Users.entity'; + +export class Categories { + [EntityRepositoryType]?: CategoriesRepository; + id!: number & Opt; + name!: string; + color?: string | null = '#6366f1'; + icon?: string | null = '📍'; + user?: Ref | null; + user_id?: number | null; + created_at?: string | null; + collection_places_collection = new Collection(this); + places_collection = new Collection(this); +} + +export const CategoriesSchema = defineEntity({ + class: Categories, + repository: () => CategoriesRepository, + properties: { + id: p.integer().primary(), + name: p.text(), + color: p.text().nullable(), + icon: p.text().nullable(), + user: () => p.manyToOne(Users).ref().nullable().hidden(), + user_id: p.integer().nullable().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + collection_places_collection: () => p.oneToMany(CollectionPlaces).mappedBy('category').hidden(), + places_collection: () => p.oneToMany(Places).mappedBy('category').hidden(), + }, +}); diff --git a/server/src/db/entities/CollabLinks.entity.ts b/server/src/db/entities/CollabLinks.entity.ts new file mode 100644 index 000000000..9351f216b --- /dev/null +++ b/server/src/db/entities/CollabLinks.entity.ts @@ -0,0 +1,36 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabLinksRepository } from '../repositories/CollabLinks.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class CollabLinks { + [EntityRepositoryType]?: CollabLinksRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + title!: string; + url!: string; + pinned?: number | null = 0; + created_at?: string | null; + updated_at?: string | null; +} + +export const CollabLinksSchema = defineEntity({ + class: CollabLinks, + repository: () => CollabLinksRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_collab_links_trip'), + trip_id: p.integer().persist(false).index('idx_collab_links_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + title: p.text(), + url: p.text(), + pinned: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/CollabMessageReactions.entity.ts b/server/src/db/entities/CollabMessageReactions.entity.ts new file mode 100644 index 000000000..03a08475f --- /dev/null +++ b/server/src/db/entities/CollabMessageReactions.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabMessageReactionsRepository } from '../repositories/CollabMessageReactions.repository'; +import { DbTimestampType } from '../types'; +import { CollabMessages } from './CollabMessages.entity'; +import { Users } from './Users.entity'; + +export class CollabMessageReactions { + [EntityRepositoryType]?: CollabMessageReactionsRepository; + id!: number & Opt; + message!: Ref; + message_id!: number; + user!: Ref; + user_id!: number; + emoji!: string; + created_at?: string | null; +} + +export const CollabMessageReactionsSchema = defineEntity({ + class: CollabMessageReactions, + repository: () => CollabMessageReactionsRepository, + uniques: [{ properties: ['message', 'user', 'emoji'] }], + properties: { + id: p.integer().primary(), + message: () => p.manyToOne(CollabMessages).ref().deleteRule('cascade').hidden().index('idx_collab_reactions_msg'), + message_id: p.integer().persist(false).index('idx_collab_reactions_msg'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + emoji: p.text(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/CollabMessages.entity.ts b/server/src/db/entities/CollabMessages.entity.ts new file mode 100644 index 000000000..9b6865bd6 --- /dev/null +++ b/server/src/db/entities/CollabMessages.entity.ts @@ -0,0 +1,44 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabMessagesRepository } from '../repositories/CollabMessages.repository'; +import { DbTimestampType } from '../types'; +import { CollabMessageReactions } from './CollabMessageReactions.entity'; +import { TripFiles } from './TripFiles.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class CollabMessages { + [EntityRepositoryType]?: CollabMessagesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + text!: string; + reply_to?: number | null; + created_at?: string | null; + deleted?: number | null = 0; + replyToRef?: Ref | null; + collab_message_reactions_collection = new Collection(this); + collab_messages_collection = new Collection(this); + trip_files_collection = new Collection(this); +} + +export const CollabMessagesSchema = defineEntity({ + class: CollabMessages, + repository: () => CollabMessagesRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_collab_messages_trip'), + trip_id: p.integer().persist(false).index('idx_collab_messages_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + text: p.text(), + reply_to: p.integer().nullable().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + deleted: p.integer().nullable(), + replyToRef: () => p.manyToOne(CollabMessages).ref().joinColumn('reply_to').nullable().hidden(), + collab_message_reactions_collection: () => p.oneToMany(CollabMessageReactions).mappedBy('message').hidden(), + collab_messages_collection: () => p.oneToMany(CollabMessages).mappedBy('replyToRef').hidden(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('message').hidden(), + }, +}); diff --git a/server/src/db/entities/CollabNotes.entity.ts b/server/src/db/entities/CollabNotes.entity.ts new file mode 100644 index 000000000..4784161f7 --- /dev/null +++ b/server/src/db/entities/CollabNotes.entity.ts @@ -0,0 +1,45 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabNotesRepository } from '../repositories/CollabNotes.repository'; +import { DbTimestampType } from '../types'; +import { TripFiles } from './TripFiles.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class CollabNotes { + [EntityRepositoryType]?: CollabNotesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + category?: string | null = 'General'; + title!: string; + content?: string | null; + color?: string | null = '#6366f1'; + pinned?: number | null = 0; + created_at?: string | null; + updated_at?: string | null; + website?: string | null; + trip_files_collection = new Collection(this); +} + +export const CollabNotesSchema = defineEntity({ + class: CollabNotes, + repository: () => CollabNotesRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_collab_notes_trip'), + trip_id: p.integer().persist(false).index('idx_collab_notes_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + category: p.text().nullable(), + title: p.text(), + content: p.text().nullable(), + color: p.text().nullable(), + pinned: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + website: p.text().nullable(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('note').hidden(), + }, +}); diff --git a/server/src/db/entities/CollabPollVotes.entity.ts b/server/src/db/entities/CollabPollVotes.entity.ts new file mode 100644 index 000000000..45f877865 --- /dev/null +++ b/server/src/db/entities/CollabPollVotes.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabPollVotesRepository } from '../repositories/CollabPollVotes.repository'; +import { DbTimestampType } from '../types'; +import { CollabPolls } from './CollabPolls.entity'; +import { Users } from './Users.entity'; + +export class CollabPollVotes { + [EntityRepositoryType]?: CollabPollVotesRepository; + id!: number & Opt; + poll!: Ref; + poll_id!: number; + user!: Ref; + user_id!: number; + option_index!: number; + created_at?: string | null; +} + +export const CollabPollVotesSchema = defineEntity({ + class: CollabPollVotes, + repository: () => CollabPollVotesRepository, + uniques: [{ properties: ['poll', 'user', 'option_index'] }], + properties: { + id: p.integer().primary(), + poll: () => p.manyToOne(CollabPolls).ref().deleteRule('cascade').hidden(), + poll_id: p.integer().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + option_index: p.integer(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/CollabPolls.entity.ts b/server/src/db/entities/CollabPolls.entity.ts new file mode 100644 index 000000000..080d40195 --- /dev/null +++ b/server/src/db/entities/CollabPolls.entity.ts @@ -0,0 +1,41 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollabPollsRepository } from '../repositories/CollabPolls.repository'; +import { DbTimestampType } from '../types'; +import { CollabPollVotes } from './CollabPollVotes.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class CollabPolls { + [EntityRepositoryType]?: CollabPollsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + question!: string; + options!: string; + multiple?: number | null = 0; + closed?: number | null = 0; + deadline?: string | null; + created_at?: string | null; + collab_poll_votes_collection = new Collection(this); +} + +export const CollabPollsSchema = defineEntity({ + class: CollabPolls, + repository: () => CollabPollsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_collab_polls_trip'), + trip_id: p.integer().persist(false).index('idx_collab_polls_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + question: p.text(), + options: p.text(), + multiple: p.integer().nullable(), + closed: p.integer().nullable(), + deadline: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + collab_poll_votes_collection: () => p.oneToMany(CollabPollVotes).mappedBy('poll').hidden(), + }, +}); diff --git a/server/src/db/entities/CollectionLabels.entity.ts b/server/src/db/entities/CollectionLabels.entity.ts new file mode 100644 index 000000000..fd2b4ebcc --- /dev/null +++ b/server/src/db/entities/CollectionLabels.entity.ts @@ -0,0 +1,32 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollectionLabelsRepository } from '../repositories/CollectionLabels.repository'; +import { DbTimestampType } from '../types'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Collections } from './Collections.entity'; + +export class CollectionLabels { + [EntityRepositoryType]?: CollectionLabelsRepository; + id!: number & Opt; + collection!: Ref; + collection_id!: number; + name!: string; + color?: string | null = '#6366f1'; + sort_order?: number | null = 0; + created_at?: string | null; + collection_place_labels_inverse = new Collection(this); +} + +export const CollectionLabelsSchema = defineEntity({ + class: CollectionLabels, + repository: () => CollectionLabelsRepository, + properties: { + id: p.integer().primary(), + collection: () => p.manyToOne(Collections).ref().deleteRule('cascade').hidden().index('idx_collection_labels_collection'), + collection_id: p.integer().persist(false).index('idx_collection_labels_collection'), + name: p.text(), + color: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + collection_place_labels_inverse: () => p.manyToMany(CollectionPlaces).mappedBy('collection_place_labels').hidden(), + }, +}); diff --git a/server/src/db/entities/CollectionMembers.entity.ts b/server/src/db/entities/CollectionMembers.entity.ts new file mode 100644 index 000000000..c69e4fb01 --- /dev/null +++ b/server/src/db/entities/CollectionMembers.entity.ts @@ -0,0 +1,33 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollectionMembersRepository } from '../repositories/CollectionMembers.repository'; +import { DbTimestampType } from '../types'; +import { Collections } from './Collections.entity'; +import { Users } from './Users.entity'; + +export class CollectionMembers { + [EntityRepositoryType]?: CollectionMembersRepository; + id!: number & Opt; + collection!: Ref; + collection_id!: number; + user!: Ref; + user_id!: number; + status: string & Opt = 'pending'; + role: string & Opt = 'editor'; + created_at?: string | null; +} + +export const CollectionMembersSchema = defineEntity({ + class: CollectionMembers, + repository: () => CollectionMembersRepository, + uniques: [{ properties: ['collection', 'user'] }], + properties: { + id: p.integer().primary(), + collection: () => p.manyToOne(Collections).ref().deleteRule('cascade').hidden(), + collection_id: p.integer().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_collection_members_user'), + user_id: p.integer().persist(false).index('idx_collection_members_user'), + status: p.text().default('pending'), + role: p.text().default('editor'), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/CollectionPlaceRatings.entity.ts b/server/src/db/entities/CollectionPlaceRatings.entity.ts new file mode 100644 index 000000000..0ebef9482 --- /dev/null +++ b/server/src/db/entities/CollectionPlaceRatings.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollectionPlaceRatingsRepository } from '../repositories/CollectionPlaceRatings.repository'; +import { DbTimestampType } from '../types'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Users } from './Users.entity'; + +export class CollectionPlaceRatings { + [EntityRepositoryType]?: CollectionPlaceRatingsRepository; + id!: number & Opt; + collectionPlace!: Ref; + collection_place_id!: number; + user!: Ref; + user_id!: number; + rating!: number; + created_at?: string | null; +} + +export const CollectionPlaceRatingsSchema = defineEntity({ + class: CollectionPlaceRatings, + repository: () => CollectionPlaceRatingsRepository, + uniques: [{ properties: ['collectionPlace', 'user'] }], + properties: { + id: p.integer().primary(), + collectionPlace: () => p.manyToOne(CollectionPlaces).ref().deleteRule('cascade').hidden().index('idx_collection_place_ratings_place'), + collection_place_id: p.integer().persist(false).index('idx_collection_place_ratings_place'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + rating: p.integer(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/CollectionPlaces.entity.ts b/server/src/db/entities/CollectionPlaces.entity.ts new file mode 100644 index 000000000..2d76ca8ee --- /dev/null +++ b/server/src/db/entities/CollectionPlaces.entity.ts @@ -0,0 +1,86 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollectionPlacesRepository } from '../repositories/CollectionPlaces.repository'; +import { DbTimestampType } from '../types'; +import { Categories } from './Categories.entity'; +import { CollectionLabels } from './CollectionLabels.entity'; +import { CollectionPlaceRatings } from './CollectionPlaceRatings.entity'; +import { Collections } from './Collections.entity'; +import { Tags } from './Tags.entity'; +import { Users } from './Users.entity'; + +export class CollectionPlaces { + [EntityRepositoryType]?: CollectionPlacesRepository; + id!: number & Opt; + collection!: Ref; + collection_id!: number; + owner!: Ref; + owner_id!: number; + saved_by?: number | null; + name!: string; + description?: string | null; + lat?: number | null; + lng?: number | null; + address?: string | null; + category?: Ref | null; + category_id?: number | null; + price?: number | null; + currency?: string | null; + notes?: string | null; + image_url?: string | null; + google_place_id?: string | null; + google_ftid?: string | null; + osm_id?: string | null; + website?: string | null; + phone?: string | null; + status: string & Opt = 'idea'; + source_trip_id?: number | null; + source_place_id?: number | null; + links?: string | null; + sort_order?: number | null = 0; + created_at?: string | null; + updated_at?: string | null; + savedByRef?: Ref | null; + collection_place_labels = new Collection(this); + collection_place_tags = new Collection(this); + collection_place_ratings_collection = new Collection(this); +} + +export const CollectionPlacesSchema = defineEntity({ + class: CollectionPlaces, + repository: () => CollectionPlacesRepository, + properties: { + id: p.integer().primary(), + collection: () => p.manyToOne(Collections).ref().deleteRule('cascade').hidden().index('idx_collection_places_collection'), + collection_id: p.integer().persist(false).index('idx_collection_places_collection'), + owner: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + owner_id: p.integer().persist(false), + saved_by: p.integer().nullable().persist(false), + name: p.text(), + description: p.text().nullable(), + lat: p.double().nullable(), + lng: p.double().nullable(), + address: p.text().nullable(), + category: () => p.manyToOne(Categories).ref().nullable().hidden(), + category_id: p.integer().nullable().persist(false), + price: p.double().nullable(), + currency: p.text().nullable(), + notes: p.text().nullable(), + image_url: p.text().nullable(), + google_place_id: p.text().nullable(), + google_ftid: p.text().nullable(), + osm_id: p.text().nullable(), + website: p.text().nullable(), + phone: p.text().nullable(), + status: p.text().default('idea'), + source_trip_id: p.integer().nullable(), + source_place_id: p.integer().nullable(), + links: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + savedByRef: () => p.manyToOne(Users).ref().joinColumn('saved_by').nullable().hidden(), + collection_place_labels: () => p.manyToMany(CollectionLabels).pivotTable('collection_place_labels').joinColumn('collection_place_id').inverseJoinColumn('label_id').hidden(), + collection_place_tags: () => p.manyToMany(Tags).pivotTable('collection_place_tags').joinColumn('collection_place_id').inverseJoinColumn('tag_id').hidden(), + collection_place_ratings_collection: () => p.oneToMany(CollectionPlaceRatings).mappedBy('collectionPlace').hidden(), + }, +}); diff --git a/server/src/db/entities/Collections.entity.ts b/server/src/db/entities/Collections.entity.ts new file mode 100644 index 000000000..b7718755f --- /dev/null +++ b/server/src/db/entities/Collections.entity.ts @@ -0,0 +1,48 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { CollectionsRepository } from '../repositories/Collections.repository'; +import { DbTimestampType } from '../types'; +import { CollectionLabels } from './CollectionLabels.entity'; +import { CollectionMembers } from './CollectionMembers.entity'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Users } from './Users.entity'; + +export class Collections { + [EntityRepositoryType]?: CollectionsRepository; + id!: number & Opt; + owner!: Ref; + owner_id!: number; + name!: string; + description?: string | null; + color?: string | null = '#6366f1'; + icon?: string | null = 'Bookmark'; + cover_image?: string | null; + links?: string | null; + sort_order?: number | null = 0; + created_at?: string | null; + updated_at?: string | null; + collection_labels_collection = new Collection(this); + collection_members_collection = new Collection(this); + collection_places_collection = new Collection(this); +} + +export const CollectionsSchema = defineEntity({ + class: Collections, + repository: () => CollectionsRepository, + properties: { + id: p.integer().primary(), + owner: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + owner_id: p.integer().persist(false), + name: p.text(), + description: p.text().nullable(), + color: p.text().nullable(), + icon: p.text().nullable(), + cover_image: p.text().nullable(), + links: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + collection_labels_collection: () => p.oneToMany(CollectionLabels).mappedBy('collection').hidden(), + collection_members_collection: () => p.oneToMany(CollectionMembers).mappedBy('collection').hidden(), + collection_places_collection: () => p.oneToMany(CollectionPlaces).mappedBy('collection').hidden(), + }, +}); diff --git a/server/src/db/entities/DawarichConnections.entity.ts b/server/src/db/entities/DawarichConnections.entity.ts new file mode 100644 index 000000000..cad8fff72 --- /dev/null +++ b/server/src/db/entities/DawarichConnections.entity.ts @@ -0,0 +1,40 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DawarichConnectionsRepository } from '../repositories/DawarichConnections.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class DawarichConnections { + [EntityRepositoryType]?: DawarichConnectionsRepository; + [PrimaryKeyProp]?: 'user'; + user?: Ref | null; + user_id?: number | null; + url?: string | null; + api_key?: string | null; + allow_insecure_tls: number & Opt = 0; + sync_enabled: number & Opt = 1; + last_sync_at?: string | null; + last_sync_state: string & Opt = 'never'; + last_sync_error?: string | null; + capabilities?: string | null; + created_at?: string | null; + updated_at?: string | null; +} + +export const DawarichConnectionsSchema = defineEntity({ + class: DawarichConnections, + repository: () => DawarichConnectionsRepository, + properties: { + user: () => p.oneToOne(Users).primary().ref().deleteRule('cascade').nullable().hidden(), + user_id: p.integer().nullable().persist(false), + url: p.text().nullable(), + api_key: p.text().nullable(), + allow_insecure_tls: p.integer().default(0), + sync_enabled: p.integer().default(1), + last_sync_at: p.text().nullable(), + last_sync_state: p.text().default('never'), + last_sync_error: p.text().nullable(), + capabilities: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/DawarichVisitSuggestions.entity.ts b/server/src/db/entities/DawarichVisitSuggestions.entity.ts new file mode 100644 index 000000000..07b6198ca --- /dev/null +++ b/server/src/db/entities/DawarichVisitSuggestions.entity.ts @@ -0,0 +1,86 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DawarichVisitSuggestionsRepository } from '../repositories/DawarichVisitSuggestions.repository'; +import { BucketList } from './BucketList.entity'; +import { Places } from './Places.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class DawarichVisitSuggestions { + [EntityRepositoryType]?: DawarichVisitSuggestionsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + source_visit_id!: string; + trip?: Ref | null; + trip_id?: number | null; + name!: string; + lat?: number | null; + lng?: number | null; + started_at!: string; + ended_at!: string; + duration_minutes: number & Opt = 0; + local_date!: string; + source_status: string & Opt = 'suggested'; + confidence?: number | null; + confidence_band?: string | null; + country_code?: string | null; + state: string & Opt = 'new'; + target?: string | null; + acceptedPlace?: Ref | null; + accepted_place_id?: number | null; + accepted_journal_entry_id?: number | null; + acceptedBucketListItem?: Ref | null; + accepted_bucket_list_item_id?: number | null; + matchedBucketListItem?: Ref | null; + matched_bucket_list_item_id?: number | null; + source_hash!: string; + accepted_hash?: string | null; + source_missing_at?: string | null; + first_seen_at!: string & Opt; + last_seen_at!: string & Opt; +} + +export const DawarichVisitSuggestionsSchema = defineEntity({ + class: DawarichVisitSuggestions, + repository: () => DawarichVisitSuggestionsRepository, + indexes: [ + { + name: 'idx_dawarich_suggestions_user_state', + properties: ['user_id', 'state'], + }, + ], + uniques: [{ properties: ['user', 'source_visit_id'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + source_visit_id: p.text(), + trip: () => p.manyToOne(Trips).ref().nullable().hidden().index('idx_dawarich_suggestions_trip'), + trip_id: p.integer().nullable().persist(false).index('idx_dawarich_suggestions_trip'), + name: p.text(), + lat: p.double().nullable(), + lng: p.double().nullable(), + started_at: p.text(), + ended_at: p.text(), + duration_minutes: p.integer().default(0), + local_date: p.text(), + source_status: p.text().default('suggested'), + confidence: p.double().nullable(), + confidence_band: p.text().nullable(), + country_code: p.text().nullable(), + state: p.text().default('new'), + target: p.text().nullable(), + acceptedPlace: () => p.manyToOne(Places).ref().nullable().hidden(), + accepted_place_id: p.integer().nullable().persist(false), + accepted_journal_entry_id: p.integer().nullable(), + acceptedBucketListItem: () => p.manyToOne(BucketList).ref().nullable().hidden(), + accepted_bucket_list_item_id: p.integer().nullable().persist(false), + matchedBucketListItem: () => p.manyToOne(BucketList).ref().nullable().hidden(), + matched_bucket_list_item_id: p.integer().nullable().persist(false), + source_hash: p.text(), + accepted_hash: p.text().nullable(), + source_missing_at: p.text().nullable(), + first_seen_at: p.text().defaultRaw(`CURRENT_TIMESTAMP`), + last_seen_at: p.text().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/DayAccommodations.entity.ts b/server/src/db/entities/DayAccommodations.entity.ts new file mode 100644 index 000000000..71a86ea1e --- /dev/null +++ b/server/src/db/entities/DayAccommodations.entity.ts @@ -0,0 +1,47 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DayAccommodationsRepository } from '../repositories/DayAccommodations.repository'; +import { DbTimestampType } from '../types'; +import { Days } from './Days.entity'; +import { Places } from './Places.entity'; +import { Trips } from './Trips.entity'; + +export class DayAccommodations { + [EntityRepositoryType]?: DayAccommodationsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + place?: Ref | null; + place_id?: number | null; + startDay!: Ref; + start_day_id!: number; + endDay!: Ref; + end_day_id!: number; + check_in?: string | null; + check_in_end?: string | null; + check_out?: string | null; + confirmation?: string | null; + notes?: string | null; + created_at?: string | null; +} + +export const DayAccommodationsSchema = defineEntity({ + class: DayAccommodations, + repository: () => DayAccommodationsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_day_accommodations_trip_id'), + trip_id: p.integer().persist(false).index('idx_day_accommodations_trip_id'), + place: () => p.manyToOne(Places).ref().nullable().hidden(), + place_id: p.integer().nullable().persist(false), + startDay: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_accommodations_start_day_id'), + start_day_id: p.integer().persist(false).index('idx_day_accommodations_start_day_id'), + endDay: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_accommodations_end_day_id'), + end_day_id: p.integer().persist(false).index('idx_day_accommodations_end_day_id'), + check_in: p.text().nullable(), + check_in_end: p.text().nullable(), + check_out: p.text().nullable(), + confirmation: p.text().nullable(), + notes: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/DayAssignments.entity.ts b/server/src/db/entities/DayAssignments.entity.ts new file mode 100644 index 000000000..140edd2d1 --- /dev/null +++ b/server/src/db/entities/DayAssignments.entity.ts @@ -0,0 +1,66 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DayAssignmentsRepository } from '../repositories/DayAssignments.repository'; +import { DbTimestampType } from '../types'; +import { AssignmentParticipants } from './AssignmentParticipants.entity'; +import { Days } from './Days.entity'; +import { FileLinks } from './FileLinks.entity'; +import { Places } from './Places.entity'; +import { Reservations } from './Reservations.entity'; +import { RoadtripDayBoundaries } from './RoadtripDayBoundaries.entity'; + +export class DayAssignments { + [EntityRepositoryType]?: DayAssignmentsRepository; + id!: number & Opt; + day!: Ref; + day_id!: number; + place!: Ref; + place_id!: number; + order_index?: number | null = 0; + notes?: string | null; + reservation_status?: string | null = 'none'; + reservation_notes?: string | null; + reservation_datetime?: string | null; + created_at?: string | null; + assignment_time?: string | null; + assignment_end_time?: string | null; + leg_transport_mode?: string | null; + incoming_leg_transport_mode?: string | null; + end_day: number & Opt = 0; + accommodation_id?: number | null; + route_excluded: number & Opt = 0; + assignment_participants_collection = new Collection(this); + file_links_collection = new Collection(this); + reservations_collection = new Collection(this); + roadtrip_day_boundaries_collection = new Collection(this); + roadtrip_day_boundaries_collection1 = new Collection(this); +} + +export const DayAssignmentsSchema = defineEntity({ + class: DayAssignments, + repository: () => DayAssignmentsRepository, + properties: { + id: p.integer().primary(), + day: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_assignments_day_id'), + day_id: p.integer().persist(false).index('idx_day_assignments_day_id'), + place: () => p.manyToOne(Places).ref().deleteRule('cascade').hidden().index('idx_day_assignments_place_id'), + place_id: p.integer().persist(false).index('idx_day_assignments_place_id'), + order_index: p.integer().nullable(), + notes: p.text().nullable(), + reservation_status: p.text().nullable(), + reservation_notes: p.text().nullable(), + reservation_datetime: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + assignment_time: p.text().nullable(), + assignment_end_time: p.text().nullable(), + leg_transport_mode: p.text().nullable(), + incoming_leg_transport_mode: p.text().nullable(), + end_day: p.integer().default(0), + accommodation_id: p.integer().nullable().index('idx_day_assignments_accommodation_id'), + route_excluded: p.integer().default(0), + assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('assignment').hidden(), + file_links_collection: () => p.oneToMany(FileLinks).mappedBy('assignment').hidden(), + reservations_collection: () => p.oneToMany(Reservations).mappedBy('assignment').hidden(), + roadtrip_day_boundaries_collection: () => p.oneToMany(RoadtripDayBoundaries).mappedBy('fromAssignment').hidden(), + roadtrip_day_boundaries_collection1: () => p.oneToMany(RoadtripDayBoundaries).mappedBy('toAssignment').hidden(), + }, +}); diff --git a/server/src/db/entities/DayNotes.entity.ts b/server/src/db/entities/DayNotes.entity.ts new file mode 100644 index 000000000..603ec3dff --- /dev/null +++ b/server/src/db/entities/DayNotes.entity.ts @@ -0,0 +1,38 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DayNotesRepository } from '../repositories/DayNotes.repository'; +import { DbTimestampType } from '../types'; +import { Days } from './Days.entity'; +import { Trips } from './Trips.entity'; + +export class DayNotes { + [EntityRepositoryType]?: DayNotesRepository; + id!: number & Opt; + day!: Ref; + day_id!: number; + trip!: Ref; + trip_id!: number; + text!: string; + time?: string | null; + icon?: string | null = '📝'; + sort_order?: number | null; + created_at?: string | null; + color?: string | null; +} + +export const DayNotesSchema = defineEntity({ + class: DayNotes, + repository: () => DayNotesRepository, + properties: { + id: p.integer().primary(), + day: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden().index('idx_day_notes_day_id'), + day_id: p.integer().persist(false).index('idx_day_notes_day_id'), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + text: p.text(), + time: p.text().nullable(), + icon: p.text().nullable(), + sort_order: p.double().nullable().defaultRaw(`0`), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + color: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/Days.entity.ts b/server/src/db/entities/Days.entity.ts new file mode 100644 index 000000000..480e021f3 --- /dev/null +++ b/server/src/db/entities/Days.entity.ts @@ -0,0 +1,59 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DaysRepository } from '../repositories/Days.repository'; +import { DayAccommodations } from './DayAccommodations.entity'; +import { DayAssignments } from './DayAssignments.entity'; +import { DayNotes } from './DayNotes.entity'; +import { Photos } from './Photos.entity'; +import { ReservationDayPositions } from './ReservationDayPositions.entity'; +import { Reservations } from './Reservations.entity'; +import { RoadtripDayTracks } from './RoadtripDayTracks.entity'; +import { RoadtripVias } from './RoadtripVias.entity'; +import { Trips } from './Trips.entity'; + +export class Days { + [EntityRepositoryType]?: DaysRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + day_number!: number; + date?: string | null; + notes?: string | null; + title?: string | null; + default_transport_mode?: string | null; + day_accommodations_collection = new Collection(this); + day_accommodations_collection1 = new Collection(this); + day_assignments_collection = new Collection(this); + day_notes_collection = new Collection(this); + photos_collection = new Collection(this); + reservation_day_positions_collection = new Collection(this); + reservations_collection = new Collection(this); + reservations_collection1 = new Collection(this); + roadtrip_day_tracks: Ref | null = null; + roadtrip_vias_collection = new Collection(this); +} + +export const DaysSchema = defineEntity({ + class: Days, + repository: () => DaysRepository, + uniques: [{ properties: ['trip', 'day_number'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_days_trip_id'), + trip_id: p.integer().persist(false).index('idx_days_trip_id'), + day_number: p.integer(), + date: p.text().nullable(), + notes: p.text().nullable(), + title: p.text().nullable(), + default_transport_mode: p.text().nullable(), + day_accommodations_collection: () => p.oneToMany(DayAccommodations).mappedBy('startDay').hidden(), + day_accommodations_collection1: () => p.oneToMany(DayAccommodations).mappedBy('endDay').hidden(), + day_assignments_collection: () => p.oneToMany(DayAssignments).mappedBy('day').hidden(), + day_notes_collection: () => p.oneToMany(DayNotes).mappedBy('day').hidden(), + photos_collection: () => p.oneToMany(Photos).mappedBy('day').hidden(), + reservation_day_positions_collection: () => p.oneToMany(ReservationDayPositions).mappedBy('day').hidden(), + reservations_collection: () => p.oneToMany(Reservations).mappedBy('day').hidden(), + reservations_collection1: () => p.oneToMany(Reservations).mappedBy('endDay').hidden(), + roadtrip_day_tracks: () => p.oneToOne(RoadtripDayTracks).ref().mappedBy('day').hidden(), + roadtrip_vias_collection: () => p.oneToMany(RoadtripVias).mappedBy('day').hidden(), + }, +}); diff --git a/server/src/db/entities/DocumentConnections.entity.ts b/server/src/db/entities/DocumentConnections.entity.ts new file mode 100644 index 000000000..62e2b1f98 --- /dev/null +++ b/server/src/db/entities/DocumentConnections.entity.ts @@ -0,0 +1,55 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DocumentConnectionsRepository } from '../repositories/DocumentConnections.repository'; +import { DbTimestampType } from '../types'; +import { DocumentProviders } from './DocumentProviders.entity'; +import { TripDocumentLinks } from './TripDocumentLinks.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class DocumentConnections { + [EntityRepositoryType]?: DocumentConnectionsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + provider!: Ref; + provider_id!: string; + ownerUser!: Ref; + owner_user_id!: number; + base_url!: string; + secrets?: string | null; + settings: string & Opt = '{}'; + allow_insecure_tls: number & Opt = 0; + capabilities?: string | null; + last_probe_at?: string | null; + last_probe_state: string & Opt = 'never'; + last_probe_error?: string | null; + created_at?: string | null; + updated_at?: string | null; + trip_document_links_collection = new Collection(this); +} + +export const DocumentConnectionsSchema = defineEntity({ + class: DocumentConnections, + repository: () => DocumentConnectionsRepository, + uniques: [{ properties: ['trip', 'provider'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_document_connections_trip'), + trip_id: p.integer().persist(false).index('idx_document_connections_trip'), + provider: () => p.manyToOne(DocumentProviders).ref().deleteRule('cascade').hidden(), + provider_id: p.text().persist(false), + ownerUser: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_document_connections_owner'), + owner_user_id: p.integer().persist(false).index('idx_document_connections_owner'), + base_url: p.text(), + secrets: p.text().nullable(), + settings: p.text().default('{}'), + allow_insecure_tls: p.integer().default(0), + capabilities: p.text().nullable(), + last_probe_at: p.text().nullable(), + last_probe_state: p.text().default('never'), + last_probe_error: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('connection').hidden(), + }, +}); diff --git a/server/src/db/entities/DocumentProviderFields.entity.ts b/server/src/db/entities/DocumentProviderFields.entity.ts new file mode 100644 index 000000000..a9a0b8512 --- /dev/null +++ b/server/src/db/entities/DocumentProviderFields.entity.ts @@ -0,0 +1,37 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DocumentProviderFieldsRepository } from '../repositories/DocumentProviderFields.repository'; +import { DocumentProviders } from './DocumentProviders.entity'; + +export class DocumentProviderFields { + [EntityRepositoryType]?: DocumentProviderFieldsRepository; + id!: number & Opt; + provider!: Ref; + provider_id!: string; + field_key!: string; + label!: string; + input_type: string & Opt = 'text'; + placeholder?: string | null; + hint?: string | null; + required?: number | null = 0; + secret?: number | null = 0; + sort_order?: number | null = 0; +} + +export const DocumentProviderFieldsSchema = defineEntity({ + class: DocumentProviderFields, + repository: () => DocumentProviderFieldsRepository, + uniques: [{ properties: ['provider', 'field_key'] }], + properties: { + id: p.integer().primary(), + provider: () => p.manyToOne(DocumentProviders).ref().deleteRule('cascade').hidden(), + provider_id: p.text().persist(false), + field_key: p.text(), + label: p.text(), + input_type: p.text().default('text'), + placeholder: p.text().nullable(), + hint: p.text().nullable(), + required: p.integer().nullable(), + secret: p.integer().nullable(), + sort_order: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/DocumentProviders.entity.ts b/server/src/db/entities/DocumentProviders.entity.ts new file mode 100644 index 000000000..683a62280 --- /dev/null +++ b/server/src/db/entities/DocumentProviders.entity.ts @@ -0,0 +1,31 @@ +import { Collection, EntityRepositoryType, defineEntity, p } from '@mikro-orm/core'; +import { DocumentProvidersRepository } from '../repositories/DocumentProviders.repository'; +import { DocumentConnections } from './DocumentConnections.entity'; +import { DocumentProviderFields } from './DocumentProviderFields.entity'; + +export class DocumentProviders { + [EntityRepositoryType]?: DocumentProvidersRepository; + id?: string | null; + name!: string; + description?: string | null; + icon?: string | null = 'FileText'; + enabled?: number | null = 0; + sort_order?: number | null = 0; + document_connections_collection = new Collection(this); + document_provider_fields_collection = new Collection(this); +} + +export const DocumentProvidersSchema = defineEntity({ + class: DocumentProviders, + repository: () => DocumentProvidersRepository, + properties: { + id: p.text().primary().nullable(), + name: p.text(), + description: p.text().nullable(), + icon: p.text().nullable(), + enabled: p.integer().nullable(), + sort_order: p.integer().nullable(), + document_connections_collection: () => p.oneToMany(DocumentConnections).mappedBy('provider').hidden(), + document_provider_fields_collection: () => p.oneToMany(DocumentProviderFields).mappedBy('provider').hidden(), + }, +}); diff --git a/server/src/db/entities/DocumentSyncItems.entity.ts b/server/src/db/entities/DocumentSyncItems.entity.ts new file mode 100644 index 000000000..c5f6af221 --- /dev/null +++ b/server/src/db/entities/DocumentSyncItems.entity.ts @@ -0,0 +1,94 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { DocumentSyncItemsRepository } from '../repositories/DocumentSyncItems.repository'; +import { TripDocumentLinks } from './TripDocumentLinks.entity'; +import { TripFiles } from './TripFiles.entity'; +import { Trips } from './Trips.entity'; + +export class DocumentSyncItems { + [EntityRepositoryType]?: DocumentSyncItemsRepository; + id!: number & Opt; + link!: Ref; + link_id!: number; + trip!: Ref; + trip_id!: number; + file?: Ref | null; + file_id?: number | null; + trek_doc_uid!: string; + remote_id?: string | null; + remote_name?: string | null; + remote_version?: string | null; + remote_size?: number | null; + remote_modified_at?: string | null; + content_sha256?: string | null; + pushed_sha256?: string | null; + state: string & Opt = 'pending'; + error_code?: string | null; + attempts: number & Opt = 0; + next_attempt_at?: string | null; + remote_missing_at?: string | null; + first_seen_at!: string & Opt; + last_seen_at!: string & Opt; + synced_at?: string | null; + remote_trashed_at?: string | null; +} + +export const DocumentSyncItemsSchema = defineEntity({ + class: DocumentSyncItems, + repository: () => DocumentSyncItemsRepository, + indexes: [ + { + name: 'idx_document_sync_items_due', + properties: ['link_id', 'next_attempt_at'], + }, + { + name: 'idx_document_sync_items_hash', + properties: ['link_id', 'content_sha256'], + }, + { + name: 'idx_document_sync_items_trip_state', + properties: ['trip_id', 'state'], + }, + ], + uniques: [ + { + name: 'idx_document_sync_items_uid', + properties: ['link_id', 'trek_doc_uid'], + }, + { + name: 'idx_document_sync_items_file', + where: 'file_id IS NOT NULL', + properties: ['link_id', 'file_id'], + }, + { + name: 'idx_document_sync_items_remote', + where: 'remote_id IS NOT NULL', + properties: ['link_id', 'remote_id'], + }, + ], + properties: { + id: p.integer().primary(), + link: () => p.manyToOne(TripDocumentLinks).ref().deleteRule('cascade').hidden(), + link_id: p.integer().persist(false), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + file: () => p.manyToOne(TripFiles).ref().nullable().hidden(), + file_id: p.integer().nullable().persist(false), + trek_doc_uid: p.text(), + remote_id: p.text().nullable(), + remote_name: p.text().nullable(), + remote_version: p.text().nullable(), + remote_size: p.integer().nullable(), + remote_modified_at: p.text().nullable(), + content_sha256: p.text().nullable(), + pushed_sha256: p.text().nullable(), + state: p.text().default('pending'), + error_code: p.text().nullable(), + attempts: p.integer().default(0), + next_attempt_at: p.text().nullable(), + remote_missing_at: p.text().nullable(), + first_seen_at: p.text().defaultRaw(`CURRENT_TIMESTAMP`), + last_seen_at: p.text().defaultRaw(`CURRENT_TIMESTAMP`), + synced_at: p.text().nullable(), + remote_trashed_at: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/FileLinks.entity.ts b/server/src/db/entities/FileLinks.entity.ts new file mode 100644 index 000000000..a324b82e8 --- /dev/null +++ b/server/src/db/entities/FileLinks.entity.ts @@ -0,0 +1,52 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { FileLinksRepository } from '../repositories/FileLinks.repository'; +import { DbTimestampType } from '../types'; +import { BudgetItems } from './BudgetItems.entity'; +import { DayAssignments } from './DayAssignments.entity'; +import { Places } from './Places.entity'; +import { Reservations } from './Reservations.entity'; +import { TripFiles } from './TripFiles.entity'; + +export class FileLinks { + [EntityRepositoryType]?: FileLinksRepository; + id!: number & Opt; + file!: Ref; + file_id!: number; + reservation?: Ref | null; + reservation_id?: number | null; + assignment?: Ref | null; + assignment_id?: number | null; + place?: Ref | null; + place_id?: number | null; + created_at?: string | null; + budgetItem?: Ref | null; + budget_item_id?: number | null; +} + +export const FileLinksSchema = defineEntity({ + class: FileLinks, + repository: () => FileLinksRepository, + uniques: [ + { + name: 'idx_file_links_file_budget', + properties: ['file_id', 'budget_item_id'], + }, + { properties: ['file', 'place'] }, + { properties: ['file', 'assignment'] }, + { properties: ['file', 'reservation'] }, + ], + properties: { + id: p.integer().primary(), + file: () => p.manyToOne(TripFiles).ref().deleteRule('cascade').hidden(), + file_id: p.integer().persist(false), + reservation: () => p.manyToOne(Reservations).ref().deleteRule('cascade').nullable().hidden(), + reservation_id: p.integer().nullable().persist(false), + assignment: () => p.manyToOne(DayAssignments).ref().deleteRule('cascade').nullable().hidden(), + assignment_id: p.integer().nullable().persist(false), + place: () => p.manyToOne(Places).ref().deleteRule('cascade').nullable().hidden(), + place_id: p.integer().nullable().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + budgetItem: () => p.manyToOne(BudgetItems).ref().nullable().hidden().index('idx_file_links_budget_item_id'), + budget_item_id: p.integer().nullable().persist(false).index('idx_file_links_budget_item_id'), + }, +}); diff --git a/server/src/db/entities/GoogleApiUsage.entity.ts b/server/src/db/entities/GoogleApiUsage.entity.ts new file mode 100644 index 000000000..c180b76f4 --- /dev/null +++ b/server/src/db/entities/GoogleApiUsage.entity.ts @@ -0,0 +1,18 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { GoogleApiUsageRepository } from '../repositories/GoogleApiUsage.repository'; + +export class GoogleApiUsage { + [EntityRepositoryType]?: GoogleApiUsageRepository; + [PrimaryKeyProp]?: 'day'; + day?: string | null; + calls: number & Opt = 0; +} + +export const GoogleApiUsageSchema = defineEntity({ + class: GoogleApiUsage, + repository: () => GoogleApiUsageRepository, + properties: { + day: p.text().primary().nullable(), + calls: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/GooglePlacePhotoMeta.entity.ts b/server/src/db/entities/GooglePlacePhotoMeta.entity.ts new file mode 100644 index 000000000..b84c86f55 --- /dev/null +++ b/server/src/db/entities/GooglePlacePhotoMeta.entity.ts @@ -0,0 +1,22 @@ +import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { GooglePlacePhotoMetaRepository } from '../repositories/GooglePlacePhotoMeta.repository'; + +export class GooglePlacePhotoMeta { + [EntityRepositoryType]?: GooglePlacePhotoMetaRepository; + [PrimaryKeyProp]?: 'place_id'; + place_id?: string | null; + attribution?: string | null; + fetched_at!: number; + error_at?: number | null; +} + +export const GooglePlacePhotoMetaSchema = defineEntity({ + class: GooglePlacePhotoMeta, + repository: () => GooglePlacePhotoMetaRepository, + properties: { + place_id: p.text().primary().nullable(), + attribution: p.text().nullable(), + fetched_at: p.integer(), + error_at: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/HiddenCountries.entity.ts b/server/src/db/entities/HiddenCountries.entity.ts new file mode 100644 index 000000000..07ef7a8eb --- /dev/null +++ b/server/src/db/entities/HiddenCountries.entity.ts @@ -0,0 +1,26 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { HiddenCountriesRepository } from '../repositories/HiddenCountries.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class HiddenCountries { + [EntityRepositoryType]?: HiddenCountriesRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + country_code!: string; + created_at?: string | null; +} + +export const HiddenCountriesSchema = defineEntity({ + class: HiddenCountries, + repository: () => HiddenCountriesRepository, + uniques: [{ properties: ['user', 'country_code'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_hidden_countries_user'), + user_id: p.integer().persist(false).index('idx_hidden_countries_user'), + country_code: p.text(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/HiddenRegions.entity.ts b/server/src/db/entities/HiddenRegions.entity.ts new file mode 100644 index 000000000..eab7a397f --- /dev/null +++ b/server/src/db/entities/HiddenRegions.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { HiddenRegionsRepository } from '../repositories/HiddenRegions.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class HiddenRegions { + [EntityRepositoryType]?: HiddenRegionsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + region_code!: string; + country_code!: string; + created_at?: string | null; +} + +export const HiddenRegionsSchema = defineEntity({ + class: HiddenRegions, + repository: () => HiddenRegionsRepository, + uniques: [{ properties: ['user', 'region_code'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_hidden_regions_user'), + user_id: p.integer().persist(false).index('idx_hidden_regions_user'), + region_code: p.text(), + country_code: p.text(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/IdempotencyKeys.entity.ts b/server/src/db/entities/IdempotencyKeys.entity.ts new file mode 100644 index 000000000..4cb740b6a --- /dev/null +++ b/server/src/db/entities/IdempotencyKeys.entity.ts @@ -0,0 +1,32 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { IdempotencyKeysRepository } from '../repositories/IdempotencyKeys.repository'; +import { Users } from './Users.entity'; + +export class IdempotencyKeys { + [EntityRepositoryType]?: IdempotencyKeysRepository; + [PrimaryKeyProp]?: ['key', 'user', 'method', 'path']; + key!: string; + user!: Ref; + user_id!: number; + method!: string; + path!: string; + status_code!: number; + response_body!: string; + created_at!: number & Opt; +} + +export const IdempotencyKeysSchema = defineEntity({ + class: IdempotencyKeys, + repository: () => IdempotencyKeysRepository, + uniques: [{ properties: ['key', 'user', 'method', 'path'] }], + properties: { + key: p.text().primary(), + user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + method: p.text().primary(), + path: p.text().primary(), + status_code: p.integer(), + response_body: p.text(), + created_at: p.integer().defaultRaw(`(strftime('%s','now'))`).index('idx_idempotency_keys_created'), + }, +}); diff --git a/server/src/db/entities/InviteTokens.entity.ts b/server/src/db/entities/InviteTokens.entity.ts new file mode 100644 index 000000000..dbfcf7bef --- /dev/null +++ b/server/src/db/entities/InviteTokens.entity.ts @@ -0,0 +1,37 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { InviteTokensRepository } from '../repositories/InviteTokens.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class InviteTokens { + [EntityRepositoryType]?: InviteTokensRepository; + id!: number & Opt; + token!: string; + max_uses: number & Opt = 1; + used_count: number & Opt = 0; + expires_at?: string | null; + created_by!: number; + created_at?: string | null; + trip?: Ref | null; + trip_id?: number | null; + createdByRef!: Ref; +} + +export const InviteTokensSchema = defineEntity({ + class: InviteTokens, + repository: () => InviteTokensRepository, + uniques: [{ properties: ['token'] }], + properties: { + id: p.integer().primary(), + token: p.text(), + max_uses: p.integer().default(1), + used_count: p.integer().default(0), + expires_at: p.text().nullable(), + created_by: p.integer().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + trip: () => p.manyToOne(Trips).ref().nullable().hidden(), + trip_id: p.integer().nullable().persist(false), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').deleteRule('cascade').hidden(), + }, +}); diff --git a/server/src/db/entities/JourneyBooks.entity.ts b/server/src/db/entities/JourneyBooks.entity.ts new file mode 100644 index 000000000..29bcdb362 --- /dev/null +++ b/server/src/db/entities/JourneyBooks.entity.ts @@ -0,0 +1,40 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyBooksRepository } from '../repositories/JourneyBooks.repository'; +import { DbTimestampType } from '../types'; +import { Journeys } from './Journeys.entity'; +import { Users } from './Users.entity'; + +export class JourneyBooks { + [EntityRepositoryType]?: JourneyBooksRepository; + id!: number & Opt; + journey!: Ref; + journey_id!: number; + title: string & Opt = ''; + document!: string; + version: number & Opt = 1; + created_by?: number | null; + updated_by?: number | null; + created_at?: string | null; + updated_at?: string | null; + updatedByRef?: Ref | null; + createdByRef?: Ref | null; +} + +export const JourneyBooksSchema = defineEntity({ + class: JourneyBooks, + repository: () => JourneyBooksRepository, + properties: { + id: p.integer().primary(), + journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden().index('idx_journey_books_journey'), + journey_id: p.integer().persist(false).index('idx_journey_books_journey'), + title: p.text().default(''), + document: p.text(), + version: p.integer().default(1), + created_by: p.integer().nullable().persist(false), + updated_by: p.integer().nullable().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updatedByRef: () => p.manyToOne(Users).ref().joinColumn('updated_by').nullable().hidden(), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').nullable().hidden(), + }, +}); diff --git a/server/src/db/entities/JourneyContributors.entity.ts b/server/src/db/entities/JourneyContributors.entity.ts new file mode 100644 index 000000000..f0a60a3cb --- /dev/null +++ b/server/src/db/entities/JourneyContributors.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyContributorsRepository } from '../repositories/JourneyContributors.repository'; +import { Journeys } from './Journeys.entity'; +import { Users } from './Users.entity'; + +export class JourneyContributors { + [EntityRepositoryType]?: JourneyContributorsRepository; + [PrimaryKeyProp]?: ['journey', 'user']; + journey!: Ref; + journey_id!: number; + user!: Ref; + user_id!: number; + role!: string; + added_at!: number; + hide_skeletons: number & Opt = 0; +} + +export const JourneyContributorsSchema = defineEntity({ + class: JourneyContributors, + repository: () => JourneyContributorsRepository, + uniques: [{ properties: ['journey', 'user'] }], + properties: { + journey: () => p.manyToOne(Journeys).primary().ref().hidden(), + journey_id: p.integer().persist(false), + user: () => p.manyToOne(Users).primary().ref().deleteRule('no action').hidden().index('idx_journey_contributors_user'), + user_id: p.integer().persist(false).index('idx_journey_contributors_user'), + role: p.text(), + added_at: p.integer(), + hide_skeletons: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/JourneyEntries.entity.ts b/server/src/db/entities/JourneyEntries.entity.ts new file mode 100644 index 000000000..ee24b7b41 --- /dev/null +++ b/server/src/db/entities/JourneyEntries.entity.ts @@ -0,0 +1,94 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyEntriesRepository } from '../repositories/JourneyEntries.repository'; +import { JourneyEntryPhotos } from './JourneyEntryPhotos.entity'; +import { Journeys } from './Journeys.entity'; +import { Places } from './Places.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class JourneyEntries { + [EntityRepositoryType]?: JourneyEntriesRepository; + id!: number & Opt; + journey!: Ref; + journey_id!: number; + sourceTrip?: Ref | null; + source_trip_id?: number | null; + sourcePlace?: Ref | null; + source_place_id?: number | null; + author!: Ref; + author_id!: number; + type!: string; + title?: string | null; + story?: string | null; + entry_date!: string; + entry_time?: string | null; + location_name?: string | null; + location_lat?: number | null; + location_lng?: number | null; + mood?: string | null; + weather?: string | null; + tags?: string | null; + visibility?: string | null = 'private'; + sort_order?: number | null = 0; + created_at!: number; + updated_at!: number; + pros_cons?: string | null; + stats_excluded: number & Opt = 0; + dismissed: number & Opt = 0; + country_code?: string | null; + source_assignment_id?: number | null; + is_draft: number & Opt = 0; + journey_entry_photos_collection = new Collection(this); +} + +export const JourneyEntriesSchema = defineEntity({ + class: JourneyEntries, + repository: () => JourneyEntriesRepository, + indexes: [ + { + name: 'idx_journey_entries_source_assignment', + properties: ['source_place_id', 'source_assignment_id'], + }, + { + name: 'idx_journey_entries_order', + properties: ['journey_id', 'entry_date', 'sort_order'], + }, + { + name: 'idx_journey_entries_journey', + properties: ['journey_id', 'entry_date'], + }, + ], + properties: { + id: p.integer().primary(), + journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden(), + journey_id: p.integer().persist(false), + sourceTrip: () => p.manyToOne(Trips).ref().nullable().hidden(), + source_trip_id: p.integer().nullable().persist(false), + sourcePlace: () => p.manyToOne(Places).ref().nullable().hidden().index('idx_journey_entries_source'), + source_place_id: p.integer().nullable().persist(false).index('idx_journey_entries_source'), + author: () => p.manyToOne(Users).ref().hidden(), + author_id: p.integer().persist(false), + type: p.text(), + title: p.text().nullable(), + story: p.text().nullable(), + entry_date: p.text(), + entry_time: p.text().nullable(), + location_name: p.text().nullable(), + location_lat: p.double().nullable(), + location_lng: p.double().nullable(), + mood: p.text().nullable(), + weather: p.text().nullable(), + tags: p.text().nullable(), + visibility: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.integer(), + updated_at: p.integer(), + pros_cons: p.text().nullable(), + stats_excluded: p.integer().default(0), + dismissed: p.integer().default(0), + country_code: p.text().nullable(), + source_assignment_id: p.integer().nullable(), + is_draft: p.integer().default(0), + journey_entry_photos_collection: () => p.oneToMany(JourneyEntryPhotos).mappedBy('entry').hidden(), + }, +}); diff --git a/server/src/db/entities/JourneyEntryPhotos.entity.ts b/server/src/db/entities/JourneyEntryPhotos.entity.ts new file mode 100644 index 000000000..4f10cde35 --- /dev/null +++ b/server/src/db/entities/JourneyEntryPhotos.entity.ts @@ -0,0 +1,29 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyEntryPhotosRepository } from '../repositories/JourneyEntryPhotos.repository'; +import { JourneyEntries } from './JourneyEntries.entity'; +import { JourneyPhotos } from './JourneyPhotos.entity'; + +export class JourneyEntryPhotos { + [EntityRepositoryType]?: JourneyEntryPhotosRepository; + [PrimaryKeyProp]?: ['entry', 'journeyPhoto']; + entry!: Ref; + entry_id!: number; + journeyPhoto!: Ref; + journey_photo_id!: number; + sort_order?: number | null = 0; + created_at!: number; +} + +export const JourneyEntryPhotosSchema = defineEntity({ + class: JourneyEntryPhotos, + repository: () => JourneyEntryPhotosRepository, + uniques: [{ properties: ['entry', 'journeyPhoto'] }], + properties: { + entry: () => p.manyToOne(JourneyEntries).primary().ref().hidden().index('idx_journey_entry_photos_entry'), + entry_id: p.integer().persist(false).index('idx_journey_entry_photos_entry'), + journeyPhoto: () => p.manyToOne(JourneyPhotos).primary().ref().hidden().index('idx_journey_entry_photos_photo'), + journey_photo_id: p.integer().persist(false).index('idx_journey_entry_photos_photo'), + sort_order: p.integer().nullable(), + created_at: p.integer(), + }, +}); diff --git a/server/src/db/entities/JourneyPhotos.entity.ts b/server/src/db/entities/JourneyPhotos.entity.ts new file mode 100644 index 000000000..590e21679 --- /dev/null +++ b/server/src/db/entities/JourneyPhotos.entity.ts @@ -0,0 +1,43 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyPhotosRepository } from '../repositories/JourneyPhotos.repository'; +import { JourneyEntryPhotos } from './JourneyEntryPhotos.entity'; +import { Journeys } from './Journeys.entity'; +import { TrekPhotos } from './TrekPhotos.entity'; + +export class JourneyPhotos { + [EntityRepositoryType]?: JourneyPhotosRepository; + id!: number & Opt; + journey!: Ref; + journey_id!: number; + photo!: Ref; + photo_id!: number; + caption?: string | null; + shared?: number | null = 0; + sort_order?: number | null = 0; + provider?: string | null; + asset_id?: string | null; + owner_id?: number | null; + created_at!: number; + journey_entry_photos_collection = new Collection(this); +} + +export const JourneyPhotosSchema = defineEntity({ + class: JourneyPhotos, + repository: () => JourneyPhotosRepository, + uniques: [{ properties: ['journey', 'photo'] }], + properties: { + id: p.integer().primary(), + journey: () => p.manyToOne(Journeys).ref().deleteRule('cascade').hidden().index('idx_journey_photos_journey'), + journey_id: p.integer().persist(false).index('idx_journey_photos_journey'), + photo: () => p.manyToOne(TrekPhotos).ref().deleteRule('cascade').hidden(), + photo_id: p.integer().persist(false), + caption: p.text().nullable(), + shared: p.integer().nullable(), + sort_order: p.integer().nullable(), + provider: p.text().nullable(), + asset_id: p.text().nullable(), + owner_id: p.integer().nullable(), + created_at: p.integer(), + journey_entry_photos_collection: () => p.oneToMany(JourneyEntryPhotos).mappedBy('journeyPhoto').hidden(), + }, +}); diff --git a/server/src/db/entities/JourneyShareTokens.entity.ts b/server/src/db/entities/JourneyShareTokens.entity.ts new file mode 100644 index 000000000..ecc9d9117 --- /dev/null +++ b/server/src/db/entities/JourneyShareTokens.entity.ts @@ -0,0 +1,39 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyShareTokensRepository } from '../repositories/JourneyShareTokens.repository'; +import { DbTimestampType } from '../types'; +import { Journeys } from './Journeys.entity'; +import { Users } from './Users.entity'; + +export class JourneyShareTokens { + [EntityRepositoryType]?: JourneyShareTokensRepository; + id!: number & Opt; + journey!: Ref; + journey_id!: number; + token!: string; + created_by!: number; + share_timeline?: number | null = 1; + share_gallery?: number | null = 1; + share_map?: number | null = 1; + created_at?: string | null; + newest_first: number & Opt = 0; + createdByRef!: Ref; +} + +export const JourneyShareTokensSchema = defineEntity({ + class: JourneyShareTokens, + repository: () => JourneyShareTokensRepository, + uniques: [{ properties: ['token'] }], + properties: { + id: p.integer().primary(), + journey: () => p.oneToOne(Journeys).ref().deleteRule('cascade').hidden().unique('idx_journey_share_journey'), + journey_id: p.integer().persist(false).unique('idx_journey_share_journey'), + token: p.text(), + created_by: p.integer().persist(false), + share_timeline: p.integer().nullable(), + share_gallery: p.integer().nullable(), + share_map: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + newest_first: p.integer().default(0), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').hidden(), + }, +}); diff --git a/server/src/db/entities/JourneyTrips.entity.ts b/server/src/db/entities/JourneyTrips.entity.ts new file mode 100644 index 000000000..a55b1ac54 --- /dev/null +++ b/server/src/db/entities/JourneyTrips.entity.ts @@ -0,0 +1,27 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneyTripsRepository } from '../repositories/JourneyTrips.repository'; +import { Journeys } from './Journeys.entity'; +import { Trips } from './Trips.entity'; + +export class JourneyTrips { + [EntityRepositoryType]?: JourneyTripsRepository; + [PrimaryKeyProp]?: ['journey', 'trip']; + journey!: Ref; + journey_id!: number; + trip!: Ref; + trip_id!: number; + added_at!: number; +} + +export const JourneyTripsSchema = defineEntity({ + class: JourneyTrips, + repository: () => JourneyTripsRepository, + uniques: [{ properties: ['journey', 'trip'] }], + properties: { + journey: () => p.manyToOne(Journeys).primary().ref().hidden().index('idx_journey_trips_journey'), + journey_id: p.integer().persist(false).index('idx_journey_trips_journey'), + trip: () => p.manyToOne(Trips).primary().ref().hidden(), + trip_id: p.integer().persist(false), + added_at: p.integer(), + }, +}); diff --git a/server/src/db/entities/Journeys.entity.ts b/server/src/db/entities/Journeys.entity.ts new file mode 100644 index 000000000..d553100b2 --- /dev/null +++ b/server/src/db/entities/Journeys.entity.ts @@ -0,0 +1,64 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { JourneysRepository } from '../repositories/Journeys.repository'; +import { JourneyBooks } from './JourneyBooks.entity'; +import { JourneyContributors } from './JourneyContributors.entity'; +import { JourneyEntries } from './JourneyEntries.entity'; +import { JourneyPhotos } from './JourneyPhotos.entity'; +import { JourneyShareTokens } from './JourneyShareTokens.entity'; +import { JourneyTrips } from './JourneyTrips.entity'; +import { Users } from './Users.entity'; + +export class Journeys { + [EntityRepositoryType]?: JourneysRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + title!: string; + subtitle?: string | null; + cover_gradient?: string | null; + status?: string | null = 'draft'; + created_at!: number; + updated_at!: number; + cover_image?: string | null; + show_trip_tracks: number & Opt = 0; + show_verdict: number & Opt = 1; + show_mood: number & Opt = 1; + show_weather: number & Opt = 1; + status_override?: string | null; + photo_location: number & Opt = 0; + journey_books_collection = new Collection(this); + journey_contributors_collection = new Collection(this); + journey_entries_collection = new Collection(this); + journey_photos_collection = new Collection(this); + journey_share_tokens: Ref | null = null; + journey_trips_collection = new Collection(this); +} + +export const JourneysSchema = defineEntity({ + class: Journeys, + repository: () => JourneysRepository, + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().hidden().index('idx_journeys_user'), + user_id: p.integer().persist(false).index('idx_journeys_user'), + title: p.text(), + subtitle: p.text().nullable(), + cover_gradient: p.text().nullable(), + status: p.text().nullable(), + created_at: p.integer(), + updated_at: p.integer(), + cover_image: p.text().nullable(), + show_trip_tracks: p.integer().default(0), + show_verdict: p.integer().default(1), + show_mood: p.integer().default(1), + show_weather: p.integer().default(1), + status_override: p.text().nullable(), + photo_location: p.integer().default(0), + journey_books_collection: () => p.oneToMany(JourneyBooks).mappedBy('journey').hidden(), + journey_contributors_collection: () => p.oneToMany(JourneyContributors).mappedBy('journey').hidden(), + journey_entries_collection: () => p.oneToMany(JourneyEntries).mappedBy('journey').hidden(), + journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('journey').hidden(), + journey_share_tokens: () => p.oneToOne(JourneyShareTokens).ref().mappedBy('journey').hidden(), + journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('journey').hidden(), + }, +}); diff --git a/server/src/db/entities/McpTokens.entity.ts b/server/src/db/entities/McpTokens.entity.ts new file mode 100644 index 000000000..d3c8421c2 --- /dev/null +++ b/server/src/db/entities/McpTokens.entity.ts @@ -0,0 +1,37 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { McpTokensRepository } from '../repositories/McpTokens.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class McpTokens { + [EntityRepositoryType]?: McpTokensRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + name!: string; + token_hash!: string; + token_prefix!: string; + created_at?: string | null; + last_used_at?: string | null; + kind: string & Opt = 'mcp'; + scope_mode: string & Opt = 'all'; + api_scopes?: string | null; +} + +export const McpTokensSchema = defineEntity({ + class: McpTokens, + repository: () => McpTokensRepository, + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + name: p.text(), + token_hash: p.text().unique('idx_mcp_tokens_hash'), + token_prefix: p.text(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + last_used_at: p.type(DbTimestampType).nullable(), + kind: p.text().default('mcp'), + scope_mode: p.text().default('all'), + api_scopes: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/Migrations.entity.ts b/server/src/db/entities/Migrations.entity.ts new file mode 100644 index 000000000..1c4570f95 --- /dev/null +++ b/server/src/db/entities/Migrations.entity.ts @@ -0,0 +1,19 @@ +import { EntityRepositoryType, defineEntity, p } from '@mikro-orm/core'; +import { MigrationsRepository } from '../repositories/Migrations.repository'; + +export class Migrations { + [EntityRepositoryType]?: MigrationsRepository; + id!: number; + timestamp!: bigint; + name!: string; +} + +export const MigrationsSchema = defineEntity({ + class: Migrations, + repository: () => MigrationsRepository, + properties: { + id: p.integer().primary(), + timestamp: p.bigint(), + name: p.string(), + }, +}); diff --git a/server/src/db/entities/NotificationChannelPreferences.entity.ts b/server/src/db/entities/NotificationChannelPreferences.entity.ts new file mode 100644 index 000000000..478d1acf6 --- /dev/null +++ b/server/src/db/entities/NotificationChannelPreferences.entity.ts @@ -0,0 +1,26 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { NotificationChannelPreferencesRepository } from '../repositories/NotificationChannelPreferences.repository'; +import { Users } from './Users.entity'; + +export class NotificationChannelPreferences { + [EntityRepositoryType]?: NotificationChannelPreferencesRepository; + [PrimaryKeyProp]?: ['user', 'event_type', 'channel']; + user!: Ref; + user_id!: number; + event_type!: string; + channel!: string; + enabled: number & Opt = 1; +} + +export const NotificationChannelPreferencesSchema = defineEntity({ + class: NotificationChannelPreferences, + repository: () => NotificationChannelPreferencesRepository, + uniques: [{ properties: ['user', 'event_type', 'channel'] }], + properties: { + user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden().index('idx_ncp_user'), + user_id: p.integer().persist(false).index('idx_ncp_user'), + event_type: p.text().primary(), + channel: p.text().primary(), + enabled: p.integer().default(1), + }, +}); diff --git a/server/src/db/entities/Notifications.entity.ts b/server/src/db/entities/Notifications.entity.ts new file mode 100644 index 000000000..775075d67 --- /dev/null +++ b/server/src/db/entities/Notifications.entity.ts @@ -0,0 +1,68 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { NotificationsRepository } from '../repositories/Notifications.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class Notifications { + [EntityRepositoryType]?: NotificationsRepository; + id!: number & Opt; + type!: string; + scope!: string; + target!: number; + sender?: Ref | null; + sender_id?: number | null; + recipient!: Ref; + recipient_id!: number; + title_key!: string; + title_params?: string | null = '{}'; + text_key!: string; + text_params?: string | null = '{}'; + positive_text_key?: string | null; + negative_text_key?: string | null; + positive_callback?: string | null; + negative_callback?: string | null; + response?: string | null; + navigate_text_key?: string | null; + navigate_target?: string | null; + is_read?: number | null = 0; + created_at?: string | null; +} + +export const NotificationsSchema = defineEntity({ + class: Notifications, + repository: () => NotificationsRepository, + indexes: [ + { name: 'idx_notifications_target_scope', properties: ['target', 'scope'] }, + { + name: 'idx_notifications_recipient_created', + properties: ['recipient_id', 'created_at'], + }, + { + name: 'idx_notifications_recipient', + properties: ['recipient_id', 'is_read', 'created_at'], + }, + ], + properties: { + id: p.integer().primary(), + type: p.text(), + scope: p.text(), + target: p.integer(), + sender: () => p.manyToOne(Users).ref().nullable().hidden(), + sender_id: p.integer().nullable().persist(false), + recipient: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + recipient_id: p.integer().persist(false), + title_key: p.text(), + title_params: p.text().nullable(), + text_key: p.text(), + text_params: p.text().nullable(), + positive_text_key: p.text().nullable(), + negative_text_key: p.text().nullable(), + positive_callback: p.text().nullable(), + negative_callback: p.text().nullable(), + response: p.text().nullable(), + navigate_text_key: p.text().nullable(), + navigate_target: p.text().nullable(), + is_read: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/OauthClients.entity.ts b/server/src/db/entities/OauthClients.entity.ts new file mode 100644 index 000000000..03fb23478 --- /dev/null +++ b/server/src/db/entities/OauthClients.entity.ts @@ -0,0 +1,46 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { OauthClientsRepository } from '../repositories/OauthClients.repository'; +import { DbTimestampType } from '../types'; +import { OauthConsents } from './OauthConsents.entity'; +import { OauthTokens } from './OauthTokens.entity'; +import { Users } from './Users.entity'; + +export class OauthClients { + [EntityRepositoryType]?: OauthClientsRepository; + id?: string | null; + user?: Ref | null; + user_id?: number | null; + name!: string; + client_id!: string; + client_secret_hash!: string; + redirect_uris: string & Opt = '[]'; + allowed_scopes: string & Opt = '[]'; + created_at?: string | null; + is_public: number & Opt = 0; + created_via: string & Opt = 'settings_ui'; + allows_client_credentials: number & Opt = 0; + oauth_consents_collection = new Collection(this); + oauth_tokens_collection = new Collection(this); +} + +export const OauthClientsSchema = defineEntity({ + class: OauthClients, + repository: () => OauthClientsRepository, + uniques: [{ properties: ['client_id'] }], + properties: { + id: p.text().primary().nullable(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').nullable().hidden().index('idx_oauth_clients_user'), + user_id: p.integer().nullable().persist(false).index('idx_oauth_clients_user'), + name: p.text(), + client_id: p.text().unique('idx_oauth_clients_client_id'), + client_secret_hash: p.text(), + redirect_uris: p.text().default('[]'), + allowed_scopes: p.text().default('[]'), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + is_public: p.integer().default(0), + created_via: p.text().default('settings_ui'), + allows_client_credentials: p.integer().default(0), + oauth_consents_collection: () => p.oneToMany(OauthConsents).mappedBy('client').hidden(), + oauth_tokens_collection: () => p.oneToMany(OauthTokens).mappedBy('client').hidden(), + }, +}); diff --git a/server/src/db/entities/OauthConsents.entity.ts b/server/src/db/entities/OauthConsents.entity.ts new file mode 100644 index 000000000..71d867469 --- /dev/null +++ b/server/src/db/entities/OauthConsents.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { OauthConsentsRepository } from '../repositories/OauthConsents.repository'; +import { DbTimestampType } from '../types'; +import { OauthClients } from './OauthClients.entity'; +import { Users } from './Users.entity'; + +export class OauthConsents { + [EntityRepositoryType]?: OauthConsentsRepository; + id!: number & Opt; + client!: Ref; + client_id!: string; + user!: Ref; + user_id!: number; + scopes: string & Opt = '[]'; + updated_at?: string | null; +} + +export const OauthConsentsSchema = defineEntity({ + class: OauthConsents, + repository: () => OauthConsentsRepository, + uniques: [{ properties: ['client', 'user'] }], + properties: { + id: p.integer().primary(), + client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden().referencedColumnNames('client_id'), + client_id: p.text().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + scopes: p.text().default('[]'), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/OauthTokens.entity.ts b/server/src/db/entities/OauthTokens.entity.ts new file mode 100644 index 000000000..b7e912614 --- /dev/null +++ b/server/src/db/entities/OauthTokens.entity.ts @@ -0,0 +1,52 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { OauthTokensRepository } from '../repositories/OauthTokens.repository'; +import { DbTimestampType } from '../types'; +import { OauthClients } from './OauthClients.entity'; +import { Users } from './Users.entity'; + +export class OauthTokens { + [EntityRepositoryType]?: OauthTokensRepository; + id!: number & Opt; + client!: Ref; + client_id!: string; + user!: Ref; + user_id!: number; + access_token_hash!: string; + refresh_token_hash!: string; + scopes: string & Opt = '[]'; + access_token_expires_at!: string; + refresh_token_expires_at!: string; + revoked_at?: string | null; + created_at?: string | null; + parentToken?: Ref | null; + parent_token_id?: number | null; + audience?: string | null; + oauth_tokens_collection = new Collection(this); +} + +export const OauthTokensSchema = defineEntity({ + class: OauthTokens, + repository: () => OauthTokensRepository, + uniques: [ + { properties: ['refresh_token_hash'] }, + { properties: ['access_token_hash'] }, + ], + properties: { + id: p.integer().primary(), + client: () => p.manyToOne(OauthClients).ref().name('client_id').deleteRule('cascade').hidden().referencedColumnNames('client_id'), + client_id: p.text().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_oauth_tokens_user'), + user_id: p.integer().persist(false).index('idx_oauth_tokens_user'), + access_token_hash: p.text().unique('idx_oauth_tokens_access'), + refresh_token_hash: p.text().unique('idx_oauth_tokens_refresh'), + scopes: p.text().default('[]'), + access_token_expires_at: p.type(DbTimestampType), + refresh_token_expires_at: p.type(DbTimestampType), + revoked_at: p.type(DbTimestampType).nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + parentToken: () => p.manyToOne(OauthTokens).ref().deleteRule('no action').nullable().hidden().index('idx_oauth_tokens_parent'), + parent_token_id: p.integer().nullable().persist(false).index('idx_oauth_tokens_parent'), + audience: p.text().nullable(), + oauth_tokens_collection: () => p.oneToMany(OauthTokens).mappedBy('parentToken').hidden(), + }, +}); diff --git a/server/src/db/entities/PackingBags.entity.ts b/server/src/db/entities/PackingBags.entity.ts new file mode 100644 index 000000000..815907046 --- /dev/null +++ b/server/src/db/entities/PackingBags.entity.ts @@ -0,0 +1,41 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingBagsRepository } from '../repositories/PackingBags.repository'; +import { DbTimestampType } from '../types'; +import { PackingItems } from './PackingItems.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class PackingBags { + [EntityRepositoryType]?: PackingBagsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + name!: string; + color: string & Opt = '#6366f1'; + weight_limit_grams?: number | null; + sort_order?: number | null = 0; + created_at?: string | null; + user?: Ref | null; + user_id?: number | null; + packing_bag_members = new Collection(this); + packing_items_collection = new Collection(this); +} + +export const PackingBagsSchema = defineEntity({ + class: PackingBags, + repository: () => PackingBagsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + name: p.text(), + color: p.text().default('#6366f1'), + weight_limit_grams: p.integer().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + user: () => p.manyToOne(Users).ref().nullable().hidden().defaultRaw(`NULL`), + user_id: p.integer().nullable().persist(false).defaultRaw(`NULL`), + packing_bag_members: () => p.manyToMany(Users).pivotTable('packing_bag_members').joinColumn('bag_id').inverseJoinColumn('user_id').hidden(), + packing_items_collection: () => p.oneToMany(PackingItems).mappedBy('bag').hidden(), + }, +}); diff --git a/server/src/db/entities/PackingCategoryAssignees.entity.ts b/server/src/db/entities/PackingCategoryAssignees.entity.ts new file mode 100644 index 000000000..d88548fef --- /dev/null +++ b/server/src/db/entities/PackingCategoryAssignees.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingCategoryAssigneesRepository } from '../repositories/PackingCategoryAssignees.repository'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class PackingCategoryAssignees { + [EntityRepositoryType]?: PackingCategoryAssigneesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + category_name!: string; + user!: Ref; + user_id!: number; +} + +export const PackingCategoryAssigneesSchema = defineEntity({ + class: PackingCategoryAssignees, + repository: () => PackingCategoryAssigneesRepository, + uniques: [{ properties: ['trip', 'category_name', 'user'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + category_name: p.text(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + }, +}); diff --git a/server/src/db/entities/PackingItemContributors.entity.ts b/server/src/db/entities/PackingItemContributors.entity.ts new file mode 100644 index 000000000..315927fcb --- /dev/null +++ b/server/src/db/entities/PackingItemContributors.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingItemContributorsRepository } from '../repositories/PackingItemContributors.repository'; +import { DbTimestampType } from '../types'; +import { PackingItems } from './PackingItems.entity'; +import { Users } from './Users.entity'; + +export class PackingItemContributors { + [EntityRepositoryType]?: PackingItemContributorsRepository; + [PrimaryKeyProp]?: ['item', 'user']; + item!: Ref; + item_id!: number; + user!: Ref; + user_id!: number; + status: string & Opt = 'accepted'; + created_at?: string | null; +} + +export const PackingItemContributorsSchema = defineEntity({ + class: PackingItemContributors, + repository: () => PackingItemContributorsRepository, + uniques: [{ properties: ['item', 'user'] }], + properties: { + item: () => p.manyToOne(PackingItems).primary().ref().hidden(), + item_id: p.integer().persist(false), + user: () => p.manyToOne(Users).primary().ref().hidden(), + user_id: p.integer().persist(false), + status: p.text().default('accepted'), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/PackingItems.entity.ts b/server/src/db/entities/PackingItems.entity.ts new file mode 100644 index 000000000..94b422f2b --- /dev/null +++ b/server/src/db/entities/PackingItems.entity.ts @@ -0,0 +1,58 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingItemsRepository } from '../repositories/PackingItems.repository'; +import { DbTimestampType } from '../types'; +import { PackingBags } from './PackingBags.entity'; +import { PackingItemContributors } from './PackingItemContributors.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class PackingItems { + [EntityRepositoryType]?: PackingItemsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + name!: string; + checked?: number | null = 0; + category?: string | null; + sort_order?: number | null = 0; + created_at?: string | null; + weight_grams?: number | null; + bag?: Ref | null; + bag_id?: number | null; + quantity: number & Opt = 1; + updated_at?: string | null; + is_private: number & Opt = 0; + owner?: Ref | null; + owner_id?: number | null; + packed_quantity?: number | null; + packing_item_contributors = new Collection(this); + packing_item_recipients = new Collection(this); + packing_item_contributors_collection = new Collection(this); +} + +export const PackingItemsSchema = defineEntity({ + class: PackingItems, + repository: () => PackingItemsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_packing_items_trip_id'), + trip_id: p.integer().persist(false).index('idx_packing_items_trip_id'), + name: p.text(), + checked: p.integer().nullable(), + category: p.text().nullable(), + sort_order: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + weight_grams: p.integer().nullable(), + bag: () => p.manyToOne(PackingBags).ref().nullable().hidden(), + bag_id: p.integer().nullable().persist(false), + quantity: p.integer().default(1), + updated_at: p.type(DbTimestampType).nullable(), + is_private: p.integer().default(0), + owner: () => p.manyToOne(Users).ref().nullable().hidden(), + owner_id: p.integer().nullable().persist(false), + packed_quantity: p.integer().nullable(), + packing_item_contributors: () => p.manyToMany(Users).pivotTable('packing_item_contributors').pivotEntity(() => PackingItemContributors).joinColumn('item_id').inverseJoinColumn('user_id').hidden(), + packing_item_recipients: () => p.manyToMany(Users).pivotTable('packing_item_recipients').joinColumn('item_id').inverseJoinColumn('user_id').hidden(), + packing_item_contributors_collection: () => p.oneToMany(PackingItemContributors).mappedBy('item').hidden(), + }, +}); diff --git a/server/src/db/entities/PackingTemplateCategories.entity.ts b/server/src/db/entities/PackingTemplateCategories.entity.ts new file mode 100644 index 000000000..748f4d3d0 --- /dev/null +++ b/server/src/db/entities/PackingTemplateCategories.entity.ts @@ -0,0 +1,27 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingTemplateCategoriesRepository } from '../repositories/PackingTemplateCategories.repository'; +import { PackingTemplateItems } from './PackingTemplateItems.entity'; +import { PackingTemplates } from './PackingTemplates.entity'; + +export class PackingTemplateCategories { + [EntityRepositoryType]?: PackingTemplateCategoriesRepository; + id!: number & Opt; + template!: Ref; + template_id!: number; + name!: string; + sort_order: number & Opt = 0; + packing_template_items_collection = new Collection(this); +} + +export const PackingTemplateCategoriesSchema = defineEntity({ + class: PackingTemplateCategories, + repository: () => PackingTemplateCategoriesRepository, + properties: { + id: p.integer().primary(), + template: () => p.manyToOne(PackingTemplates).ref().deleteRule('cascade').hidden(), + template_id: p.integer().persist(false), + name: p.text(), + sort_order: p.integer().default(0), + packing_template_items_collection: () => p.oneToMany(PackingTemplateItems).mappedBy('category').hidden(), + }, +}); diff --git a/server/src/db/entities/PackingTemplateItems.entity.ts b/server/src/db/entities/PackingTemplateItems.entity.ts new file mode 100644 index 000000000..891ee4baa --- /dev/null +++ b/server/src/db/entities/PackingTemplateItems.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingTemplateItemsRepository } from '../repositories/PackingTemplateItems.repository'; +import { PackingTemplateCategories } from './PackingTemplateCategories.entity'; + +export class PackingTemplateItems { + [EntityRepositoryType]?: PackingTemplateItemsRepository; + id!: number & Opt; + category!: Ref; + category_id!: number; + name!: string; + sort_order: number & Opt = 0; + weight_grams?: number | null; + quantity: number & Opt = 1; + bag_name?: string | null; +} + +export const PackingTemplateItemsSchema = defineEntity({ + class: PackingTemplateItems, + repository: () => PackingTemplateItemsRepository, + properties: { + id: p.integer().primary(), + category: () => p.manyToOne(PackingTemplateCategories).ref().deleteRule('cascade').hidden(), + category_id: p.integer().persist(false), + name: p.text(), + sort_order: p.integer().default(0), + weight_grams: p.integer().nullable(), + quantity: p.integer().default(1), + bag_name: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/PackingTemplates.entity.ts b/server/src/db/entities/PackingTemplates.entity.ts new file mode 100644 index 000000000..fc35b6231 --- /dev/null +++ b/server/src/db/entities/PackingTemplates.entity.ts @@ -0,0 +1,28 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PackingTemplatesRepository } from '../repositories/PackingTemplates.repository'; +import { DbTimestampType } from '../types'; +import { PackingTemplateCategories } from './PackingTemplateCategories.entity'; +import { Users } from './Users.entity'; + +export class PackingTemplates { + [EntityRepositoryType]?: PackingTemplatesRepository; + id!: number & Opt; + name!: string; + created_by!: number; + created_at?: string | null; + createdByRef!: Ref; + packing_template_categories_collection = new Collection(this); +} + +export const PackingTemplatesSchema = defineEntity({ + class: PackingTemplates, + repository: () => PackingTemplatesRepository, + properties: { + id: p.integer().primary(), + name: p.text(), + created_by: p.integer().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').deleteRule('cascade').hidden(), + packing_template_categories_collection: () => p.oneToMany(PackingTemplateCategories).mappedBy('template').hidden(), + }, +}); diff --git a/server/src/db/entities/PasswordResetTokens.entity.ts b/server/src/db/entities/PasswordResetTokens.entity.ts new file mode 100644 index 000000000..8f015cdfc --- /dev/null +++ b/server/src/db/entities/PasswordResetTokens.entity.ts @@ -0,0 +1,32 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PasswordResetTokensRepository } from '../repositories/PasswordResetTokens.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class PasswordResetTokens { + [EntityRepositoryType]?: PasswordResetTokensRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + token_hash!: string; + expires_at!: string; + consumed_at?: string | null; + created_at?: string | null; + created_ip?: string | null; +} + +export const PasswordResetTokensSchema = defineEntity({ + class: PasswordResetTokens, + repository: () => PasswordResetTokensRepository, + uniques: [{ properties: ['token_hash'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_prt_user'), + user_id: p.integer().persist(false).index('idx_prt_user'), + token_hash: p.text().index('idx_prt_hash'), + expires_at: p.type(DbTimestampType), + consumed_at: p.type(DbTimestampType).nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + created_ip: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/PhotoProviderFields.entity.ts b/server/src/db/entities/PhotoProviderFields.entity.ts new file mode 100644 index 000000000..27758e7fc --- /dev/null +++ b/server/src/db/entities/PhotoProviderFields.entity.ts @@ -0,0 +1,41 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PhotoProviderFieldsRepository } from '../repositories/PhotoProviderFields.repository'; +import { PhotoProviders } from './PhotoProviders.entity'; + +export class PhotoProviderFields { + [EntityRepositoryType]?: PhotoProviderFieldsRepository; + id!: number & Opt; + provider!: Ref; + provider_id!: string; + field_key!: string; + label!: string; + input_type: string & Opt = 'text'; + placeholder?: string | null; + hint?: string | null; + required?: number | null = 0; + secret?: number | null = 0; + settings_key?: string | null; + payload_key?: string | null; + sort_order?: number | null = 0; +} + +export const PhotoProviderFieldsSchema = defineEntity({ + class: PhotoProviderFields, + repository: () => PhotoProviderFieldsRepository, + uniques: [{ properties: ['provider', 'field_key'] }], + properties: { + id: p.integer().primary(), + provider: () => p.manyToOne(PhotoProviders).ref().deleteRule('cascade').hidden(), + provider_id: p.text().persist(false), + field_key: p.text(), + label: p.text(), + input_type: p.text().default('text'), + placeholder: p.text().nullable(), + hint: p.text().nullable(), + required: p.integer().nullable(), + secret: p.integer().nullable(), + settings_key: p.text().nullable(), + payload_key: p.text().nullable(), + sort_order: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/PhotoProviders.entity.ts b/server/src/db/entities/PhotoProviders.entity.ts new file mode 100644 index 000000000..9cb2dd746 --- /dev/null +++ b/server/src/db/entities/PhotoProviders.entity.ts @@ -0,0 +1,28 @@ +import { Collection, EntityRepositoryType, defineEntity, p } from '@mikro-orm/core'; +import { PhotoProvidersRepository } from '../repositories/PhotoProviders.repository'; +import { PhotoProviderFields } from './PhotoProviderFields.entity'; + +export class PhotoProviders { + [EntityRepositoryType]?: PhotoProvidersRepository; + id?: string | null; + name!: string; + description?: string | null; + icon?: string | null = 'Image'; + enabled?: number | null = 0; + sort_order?: number | null = 0; + photo_provider_fields_collection = new Collection(this); +} + +export const PhotoProvidersSchema = defineEntity({ + class: PhotoProviders, + repository: () => PhotoProvidersRepository, + properties: { + id: p.text().primary().nullable(), + name: p.text(), + description: p.text().nullable(), + icon: p.text().nullable(), + enabled: p.integer().nullable(), + sort_order: p.integer().nullable(), + photo_provider_fields_collection: () => p.oneToMany(PhotoProviderFields).mappedBy('provider').hidden(), + }, +}); diff --git a/server/src/db/entities/Photos.entity.ts b/server/src/db/entities/Photos.entity.ts new file mode 100644 index 000000000..d64376d2a --- /dev/null +++ b/server/src/db/entities/Photos.entity.ts @@ -0,0 +1,45 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PhotosRepository } from '../repositories/Photos.repository'; +import { DbTimestampType } from '../types'; +import { Days } from './Days.entity'; +import { Places } from './Places.entity'; +import { Trips } from './Trips.entity'; + +export class Photos { + [EntityRepositoryType]?: PhotosRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + day?: Ref | null; + day_id?: number | null; + place?: Ref | null; + place_id?: number | null; + filename!: string; + original_name!: string; + file_size?: number | null; + mime_type?: string | null; + caption?: string | null; + taken_at?: string | null; + created_at?: string | null; +} + +export const PhotosSchema = defineEntity({ + class: Photos, + repository: () => PhotosRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_photos_trip_id'), + trip_id: p.integer().persist(false).index('idx_photos_trip_id'), + day: () => p.manyToOne(Days).ref().nullable().hidden().index('idx_photos_day_id'), + day_id: p.integer().nullable().persist(false).index('idx_photos_day_id'), + place: () => p.manyToOne(Places).ref().nullable().hidden().index('idx_photos_place_id'), + place_id: p.integer().nullable().persist(false).index('idx_photos_place_id'), + filename: p.text(), + original_name: p.text(), + file_size: p.integer().nullable(), + mime_type: p.text().nullable(), + caption: p.text().nullable(), + taken_at: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/PlaceDetailsCache.entity.ts b/server/src/db/entities/PlaceDetailsCache.entity.ts new file mode 100644 index 000000000..90adba03d --- /dev/null +++ b/server/src/db/entities/PlaceDetailsCache.entity.ts @@ -0,0 +1,24 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PlaceDetailsCacheRepository } from '../repositories/PlaceDetailsCache.repository'; + +export class PlaceDetailsCache { + [EntityRepositoryType]?: PlaceDetailsCacheRepository; + [PrimaryKeyProp]?: ['place_id', 'lang', 'expanded']; + place_id!: string; + lang: string & Opt = ''; + expanded: number & Opt = 0; + payload_json!: string; + fetched_at!: number; +} + +export const PlaceDetailsCacheSchema = defineEntity({ + class: PlaceDetailsCache, + repository: () => PlaceDetailsCacheRepository, + properties: { + place_id: p.text().primary(), + lang: p.text().primary().default(''), + expanded: p.integer().primary().default(0), + payload_json: p.text(), + fetched_at: p.integer(), + }, +}); diff --git a/server/src/db/entities/PlaceRatings.entity.ts b/server/src/db/entities/PlaceRatings.entity.ts new file mode 100644 index 000000000..dbaa519d7 --- /dev/null +++ b/server/src/db/entities/PlaceRatings.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PlaceRatingsRepository } from '../repositories/PlaceRatings.repository'; +import { DbTimestampType } from '../types'; +import { Places } from './Places.entity'; +import { Users } from './Users.entity'; + +export class PlaceRatings { + [EntityRepositoryType]?: PlaceRatingsRepository; + id!: number & Opt; + place!: Ref; + place_id!: number; + user!: Ref; + user_id!: number; + rating!: number; + created_at?: string | null; +} + +export const PlaceRatingsSchema = defineEntity({ + class: PlaceRatings, + repository: () => PlaceRatingsRepository, + uniques: [{ properties: ['place', 'user'] }], + properties: { + id: p.integer().primary(), + place: () => p.manyToOne(Places).ref().deleteRule('cascade').hidden().index('idx_place_ratings_place'), + place_id: p.integer().persist(false).index('idx_place_ratings_place'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + rating: p.integer(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/PlaceRegions.entity.ts b/server/src/db/entities/PlaceRegions.entity.ts new file mode 100644 index 000000000..11beadcf7 --- /dev/null +++ b/server/src/db/entities/PlaceRegions.entity.ts @@ -0,0 +1,25 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PlaceRegionsRepository } from '../repositories/PlaceRegions.repository'; +import { Places } from './Places.entity'; + +export class PlaceRegions { + [EntityRepositoryType]?: PlaceRegionsRepository; + [PrimaryKeyProp]?: 'place'; + place?: Ref | null; + place_id?: number | null; + country_code!: string; + region_code!: string; + region_name!: string; +} + +export const PlaceRegionsSchema = defineEntity({ + class: PlaceRegions, + repository: () => PlaceRegionsRepository, + properties: { + place: () => p.oneToOne(Places).primary().ref().deleteRule('cascade').nullable().hidden(), + place_id: p.integer().nullable().persist(false), + country_code: p.text().index('idx_place_regions_country'), + region_code: p.text().index('idx_place_regions_region'), + region_name: p.text(), + }, +}); diff --git a/server/src/db/entities/PlaceShadowPicks.entity.ts b/server/src/db/entities/PlaceShadowPicks.entity.ts new file mode 100644 index 000000000..d26ef5ee3 --- /dev/null +++ b/server/src/db/entities/PlaceShadowPicks.entity.ts @@ -0,0 +1,39 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PlaceShadowPicksRepository } from '../repositories/PlaceShadowPicks.repository'; + +export class PlaceShadowPicks { + [EntityRepositoryType]?: PlaceShadowPicksRepository; + id!: number & Opt; + created_at!: string & Opt; + query!: string; + lang?: string | null; + bias_lat?: number | null; + bias_lng?: number | null; + source!: string; + live_rank!: number; + live_count!: number; + picked_name!: string; + picked_lat!: number; + picked_lng!: number; + picked_place_id?: string | null; +} + +export const PlaceShadowPicksSchema = defineEntity({ + class: PlaceShadowPicks, + repository: () => PlaceShadowPicksRepository, + properties: { + id: p.integer().primary(), + created_at: p.text().defaultRaw(`(datetime('now'))`).index('idx_place_shadow_created'), + query: p.text(), + lang: p.text().nullable(), + bias_lat: p.double().nullable(), + bias_lng: p.double().nullable(), + source: p.text(), + live_rank: p.integer(), + live_count: p.integer(), + picked_name: p.text(), + picked_lat: p.double(), + picked_lng: p.double(), + picked_place_id: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/Places.entity.ts b/server/src/db/entities/Places.entity.ts new file mode 100644 index 000000000..6bb49853a --- /dev/null +++ b/server/src/db/entities/Places.entity.ts @@ -0,0 +1,130 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PlacesRepository } from '../repositories/Places.repository'; +import { DbTimestampType } from '../types'; +import { BudgetItems } from './BudgetItems.entity'; +import { Categories } from './Categories.entity'; +import { DawarichVisitSuggestions } from './DawarichVisitSuggestions.entity'; +import { DayAccommodations } from './DayAccommodations.entity'; +import { DayAssignments } from './DayAssignments.entity'; +import { FileLinks } from './FileLinks.entity'; +import { JourneyEntries } from './JourneyEntries.entity'; +import { Photos } from './Photos.entity'; +import { PlaceRatings } from './PlaceRatings.entity'; +import { PlaceRegions } from './PlaceRegions.entity'; +import { Reservations } from './Reservations.entity'; +import { RoadtripDayTracks } from './RoadtripDayTracks.entity'; +import { Tags } from './Tags.entity'; +import { Tours } from './Tours.entity'; +import { TripFiles } from './TripFiles.entity'; +import { Trips } from './Trips.entity'; + +export class Places { + [EntityRepositoryType]?: PlacesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + name!: string; + description?: string | null; + lat?: number | null; + lng?: number | null; + address?: string | null; + category?: Ref | null; + category_id?: number | null; + price?: number | null; + currency?: string | null; + reservation_status?: string | null = 'none'; + reservation_notes?: string | null; + reservation_datetime?: string | null; + place_time?: string | null; + end_time?: string | null; + duration_minutes?: number | null = 60; + notes?: string | null; + image_url?: string | null; + google_place_id?: string | null; + google_ftid?: string | null; + website?: string | null; + phone?: string | null; + transport_mode?: string | null = 'walking'; + created_at?: string | null; + updated_at?: string | null; + osm_id?: string | null; + route_geometry?: string | null; + route_color?: string | null; + stop_type?: string | null; + fill_percent?: number | null; + amap_poi_id?: string | null; + source?: string | null; + email?: string | null; + opening_hours?: string | null; + place_tags = new Collection(this); + budget_items_collection = new Collection(this); + dawarich_visit_suggestions_collection = new Collection(this); + day_accommodations_collection = new Collection(this); + day_assignments_collection = new Collection(this); + file_links_collection = new Collection(this); + journey_entries_collection = new Collection(this); + photos_collection = new Collection(this); + place_ratings_collection = new Collection(this); + place_regions: Ref | null = null; + reservations_collection = new Collection(this); + roadtrip_day_tracks_collection = new Collection(this); + tours: Ref | null = null; + trip_files_collection = new Collection(this); +} + +export const PlacesSchema = defineEntity({ + class: Places, + repository: () => PlacesRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_places_trip_id'), + trip_id: p.integer().persist(false).index('idx_places_trip_id'), + name: p.text(), + description: p.text().nullable(), + lat: p.double().nullable(), + lng: p.double().nullable(), + address: p.text().nullable(), + category: () => p.manyToOne(Categories).ref().nullable().hidden().index('idx_places_category_id'), + category_id: p.integer().nullable().persist(false).index('idx_places_category_id'), + price: p.double().nullable(), + currency: p.text().nullable(), + reservation_status: p.text().nullable(), + reservation_notes: p.text().nullable(), + reservation_datetime: p.text().nullable(), + place_time: p.text().nullable(), + end_time: p.text().nullable(), + duration_minutes: p.integer().nullable(), + notes: p.text().nullable(), + image_url: p.text().nullable(), + google_place_id: p.text().nullable(), + google_ftid: p.text().nullable(), + website: p.text().nullable(), + phone: p.text().nullable(), + transport_mode: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + osm_id: p.text().nullable(), + route_geometry: p.text().nullable(), + route_color: p.text().nullable(), + stop_type: p.text().nullable(), + fill_percent: p.integer().nullable(), + amap_poi_id: p.text().nullable(), + source: p.text().nullable(), + email: p.text().nullable(), + opening_hours: p.text().nullable(), + place_tags: () => p.manyToMany(Tags).pivotTable('place_tags').joinColumn('place_id').inverseJoinColumn('tag_id').hidden(), + budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('place').hidden(), + dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('acceptedPlace').hidden(), + day_accommodations_collection: () => p.oneToMany(DayAccommodations).mappedBy('place').hidden(), + day_assignments_collection: () => p.oneToMany(DayAssignments).mappedBy('place').hidden(), + file_links_collection: () => p.oneToMany(FileLinks).mappedBy('place').hidden(), + journey_entries_collection: () => p.oneToMany(JourneyEntries).mappedBy('sourcePlace').hidden(), + photos_collection: () => p.oneToMany(Photos).mappedBy('place').hidden(), + place_ratings_collection: () => p.oneToMany(PlaceRatings).mappedBy('place').hidden(), + place_regions: () => p.oneToOne(PlaceRegions).ref().mappedBy('place').hidden(), + reservations_collection: () => p.oneToMany(Reservations).mappedBy('place').hidden(), + roadtrip_day_tracks_collection: () => p.oneToMany(RoadtripDayTracks).mappedBy('place').hidden(), + tours: () => p.oneToOne(Tours).ref().mappedBy('place').hidden(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('place').hidden(), + }, +}); diff --git a/server/src/db/entities/PluginActions.entity.ts b/server/src/db/entities/PluginActions.entity.ts new file mode 100644 index 000000000..84caeece3 --- /dev/null +++ b/server/src/db/entities/PluginActions.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PluginActionsRepository } from '../repositories/PluginActions.repository'; + +export class PluginActions { + [EntityRepositoryType]?: PluginActionsRepository; + [PrimaryKeyProp]?: ['plugin_id', 'action_key']; + plugin_id!: string; + action_key!: string; + label!: string; + hint?: string | null; + danger: number & Opt = 0; + sort_order: number & Opt = 0; + scope: string & Opt = 'user'; +} + +export const PluginActionsSchema = defineEntity({ + class: PluginActions, + repository: () => PluginActionsRepository, + properties: { + plugin_id: p.text().primary(), + action_key: p.text().primary(), + label: p.text(), + hint: p.text().nullable(), + danger: p.integer().default(0), + sort_order: p.integer().default(0), + scope: p.text().default('user'), + }, +}); diff --git a/server/src/db/entities/PluginCapabilityAudit.entity.ts b/server/src/db/entities/PluginCapabilityAudit.entity.ts new file mode 100644 index 000000000..10c034cb2 --- /dev/null +++ b/server/src/db/entities/PluginCapabilityAudit.entity.ts @@ -0,0 +1,32 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginCapabilityAuditRepository } from '../repositories/PluginCapabilityAudit.repository'; + +export class PluginCapabilityAudit { + [EntityRepositoryType]?: PluginCapabilityAuditRepository; + id!: number & Opt; + plugin_id!: string; + acting_user_id?: number | null; + method!: string; + resource?: string | null; + code!: string; + ts!: string & Opt; + prev_hash?: string | null; + hash!: string; +} + +export const PluginCapabilityAuditSchema = defineEntity({ + class: PluginCapabilityAudit, + repository: () => PluginCapabilityAuditRepository, + indexes: [{ name: 'idx_plugin_audit_plugin', properties: ['plugin_id', 'id'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text(), + acting_user_id: p.integer().nullable(), + method: p.text(), + resource: p.text().nullable(), + code: p.text(), + ts: p.text().defaultRaw(`(datetime('now'))`), + prev_hash: p.text().nullable(), + hash: p.text(), + }, +}); diff --git a/server/src/db/entities/PluginEgressHosts.entity.ts b/server/src/db/entities/PluginEgressHosts.entity.ts new file mode 100644 index 000000000..e5b6398a7 --- /dev/null +++ b/server/src/db/entities/PluginEgressHosts.entity.ts @@ -0,0 +1,22 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginEgressHostsRepository } from '../repositories/PluginEgressHosts.repository'; + +export class PluginEgressHosts { + [EntityRepositoryType]?: PluginEgressHostsRepository; + id!: number & Opt; + plugin_id!: string; + host!: string; + created_at!: string & Opt; +} + +export const PluginEgressHostsSchema = defineEntity({ + class: PluginEgressHosts, + repository: () => PluginEgressHostsRepository, + uniques: [{ properties: ['plugin_id', 'host'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text().index('idx_plugin_egress_hosts_plugin'), + host: p.text(), + created_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/PluginEntityMetadata.entity.ts b/server/src/db/entities/PluginEntityMetadata.entity.ts new file mode 100644 index 000000000..818bf41e3 --- /dev/null +++ b/server/src/db/entities/PluginEntityMetadata.entity.ts @@ -0,0 +1,34 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginEntityMetadataRepository } from '../repositories/PluginEntityMetadata.repository'; + +export class PluginEntityMetadata { + [EntityRepositoryType]?: PluginEntityMetadataRepository; + id!: number & Opt; + plugin_id!: string; + entity_type!: string; + entity_id!: number; + key!: string; + value?: string | null; + updated_at!: string & Opt; +} + +export const PluginEntityMetadataSchema = defineEntity({ + class: PluginEntityMetadata, + repository: () => PluginEntityMetadataRepository, + indexes: [ + { + name: 'idx_plugin_meta_entity', + properties: ['plugin_id', 'entity_type', 'entity_id'], + }, + ], + uniques: [{ properties: ['plugin_id', 'entity_type', 'entity_id', 'key'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text(), + entity_type: p.text(), + entity_id: p.integer(), + key: p.text(), + value: p.text().nullable(), + updated_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/PluginErrorLog.entity.ts b/server/src/db/entities/PluginErrorLog.entity.ts new file mode 100644 index 000000000..e3b8f8143 --- /dev/null +++ b/server/src/db/entities/PluginErrorLog.entity.ts @@ -0,0 +1,29 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginErrorLogRepository } from '../repositories/PluginErrorLog.repository'; +import { DbTimestampType } from '../types'; + +export class PluginErrorLog { + [EntityRepositoryType]?: PluginErrorLogRepository; + id!: number & Opt; + plugin_id!: string; + ts?: string | null; + level: string & Opt = 'error'; + message?: string | null; + stack?: string | null; +} + +export const PluginErrorLogSchema = defineEntity({ + class: PluginErrorLog, + repository: () => PluginErrorLogRepository, + indexes: [ + { name: 'idx_plugin_error_log_plugin', properties: ['plugin_id', 'ts'] }, + ], + properties: { + id: p.integer().primary(), + plugin_id: p.text(), + ts: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + level: p.text().default('error'), + message: p.text().nullable(), + stack: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/PluginMetaMigrations.entity.ts b/server/src/db/entities/PluginMetaMigrations.entity.ts new file mode 100644 index 000000000..48fffae11 --- /dev/null +++ b/server/src/db/entities/PluginMetaMigrations.entity.ts @@ -0,0 +1,21 @@ +import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PluginMetaMigrationsRepository } from '../repositories/PluginMetaMigrations.repository'; +import { DbTimestampType } from '../types'; + +export class PluginMetaMigrations { + [EntityRepositoryType]?: PluginMetaMigrationsRepository; + [PrimaryKeyProp]?: ['plugin_id', 'migration_id']; + plugin_id!: string; + migration_id!: string; + applied_at?: string | null; +} + +export const PluginMetaMigrationsSchema = defineEntity({ + class: PluginMetaMigrations, + repository: () => PluginMetaMigrationsRepository, + properties: { + plugin_id: p.text().primary(), + migration_id: p.text().primary(), + applied_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/PluginOauthState.entity.ts b/server/src/db/entities/PluginOauthState.entity.ts new file mode 100644 index 000000000..2893eda14 --- /dev/null +++ b/server/src/db/entities/PluginOauthState.entity.ts @@ -0,0 +1,24 @@ +import { EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PluginOauthStateRepository } from '../repositories/PluginOauthState.repository'; + +export class PluginOauthState { + [EntityRepositoryType]?: PluginOauthStateRepository; + [PrimaryKeyProp]?: 'state'; + state?: string | null; + plugin_id!: string; + user_id!: number; + verifier!: string; + created_at!: number; +} + +export const PluginOauthStateSchema = defineEntity({ + class: PluginOauthState, + repository: () => PluginOauthStateRepository, + properties: { + state: p.text().primary().nullable(), + plugin_id: p.text(), + user_id: p.integer(), + verifier: p.text(), + created_at: p.integer(), + }, +}); diff --git a/server/src/db/entities/PluginOauthTokens.entity.ts b/server/src/db/entities/PluginOauthTokens.entity.ts new file mode 100644 index 000000000..14108533c --- /dev/null +++ b/server/src/db/entities/PluginOauthTokens.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PluginOauthTokensRepository } from '../repositories/PluginOauthTokens.repository'; + +export class PluginOauthTokens { + [EntityRepositoryType]?: PluginOauthTokensRepository; + [PrimaryKeyProp]?: ['plugin_id', 'user_id']; + plugin_id!: string; + user_id!: number; + access_token?: string | null; + refresh_token?: string | null; + expires_at?: number | null; + scope?: string | null; + updated_at!: string & Opt; +} + +export const PluginOauthTokensSchema = defineEntity({ + class: PluginOauthTokens, + repository: () => PluginOauthTokensRepository, + properties: { + plugin_id: p.text().primary(), + user_id: p.integer().primary(), + access_token: p.text().nullable(), + refresh_token: p.text().nullable(), + expires_at: p.integer().nullable(), + scope: p.text().nullable(), + updated_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/PluginScheduledTasks.entity.ts b/server/src/db/entities/PluginScheduledTasks.entity.ts new file mode 100644 index 000000000..9597eed7e --- /dev/null +++ b/server/src/db/entities/PluginScheduledTasks.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginScheduledTasksRepository } from '../repositories/PluginScheduledTasks.repository'; + +export class PluginScheduledTasks { + [EntityRepositoryType]?: PluginScheduledTasksRepository; + id!: number & Opt; + plugin_id!: string; + name!: string; + due_at!: number; + payload: string & Opt = 'null'; + every_ms?: number | null; + created_at!: string & Opt; +} + +export const PluginScheduledTasksSchema = defineEntity({ + class: PluginScheduledTasks, + repository: () => PluginScheduledTasksRepository, + uniques: [{ properties: ['plugin_id', 'name'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text(), + name: p.text(), + due_at: p.integer().index('idx_plugin_sched_due'), + payload: p.text().default('null'), + every_ms: p.integer().nullable(), + created_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/PluginSettingsFields.entity.ts b/server/src/db/entities/PluginSettingsFields.entity.ts new file mode 100644 index 000000000..0100d4515 --- /dev/null +++ b/server/src/db/entities/PluginSettingsFields.entity.ts @@ -0,0 +1,42 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginSettingsFieldsRepository } from '../repositories/PluginSettingsFields.repository'; + +export class PluginSettingsFields { + [EntityRepositoryType]?: PluginSettingsFieldsRepository; + id!: number & Opt; + plugin_id!: string; + field_key!: string; + label?: string | null; + input_type: string & Opt = 'text'; + placeholder?: string | null; + hint?: string | null; + required: number & Opt = 0; + secret: number & Opt = 0; + scope: string & Opt = 'instance'; + options?: string | null; + oauth_config?: string | null; + sort_order: number & Opt = 0; + default_value?: string | null; +} + +export const PluginSettingsFieldsSchema = defineEntity({ + class: PluginSettingsFields, + repository: () => PluginSettingsFieldsRepository, + uniques: [{ properties: ['plugin_id', 'field_key'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text(), + field_key: p.text(), + label: p.text().nullable(), + input_type: p.text().default('text'), + placeholder: p.text().nullable(), + hint: p.text().nullable(), + required: p.integer().default(0), + secret: p.integer().default(0), + scope: p.text().default('instance'), + options: p.text().nullable(), + oauth_config: p.text().nullable(), + sort_order: p.integer().default(0), + default_value: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/PluginUserConfig.entity.ts b/server/src/db/entities/PluginUserConfig.entity.ts new file mode 100644 index 000000000..b6ae59582 --- /dev/null +++ b/server/src/db/entities/PluginUserConfig.entity.ts @@ -0,0 +1,22 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { PluginUserConfigRepository } from '../repositories/PluginUserConfig.repository'; + +export class PluginUserConfig { + [EntityRepositoryType]?: PluginUserConfigRepository; + [PrimaryKeyProp]?: ['plugin_id', 'user_id']; + plugin_id!: string; + user_id!: number; + config: string & Opt = '{}'; + updated_at!: string & Opt; +} + +export const PluginUserConfigSchema = defineEntity({ + class: PluginUserConfig, + repository: () => PluginUserConfigRepository, + properties: { + plugin_id: p.text().primary(), + user_id: p.integer().primary(), + config: p.text().default('{}'), + updated_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/PluginUserErasureQueue.entity.ts b/server/src/db/entities/PluginUserErasureQueue.entity.ts new file mode 100644 index 000000000..585b5eb70 --- /dev/null +++ b/server/src/db/entities/PluginUserErasureQueue.entity.ts @@ -0,0 +1,22 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginUserErasureQueueRepository } from '../repositories/PluginUserErasureQueue.repository'; + +export class PluginUserErasureQueue { + [EntityRepositoryType]?: PluginUserErasureQueueRepository; + id!: number & Opt; + plugin_id!: string; + user_id!: number; + created_at!: string & Opt; +} + +export const PluginUserErasureQueueSchema = defineEntity({ + class: PluginUserErasureQueue, + repository: () => PluginUserErasureQueueRepository, + uniques: [{ properties: ['plugin_id', 'user_id'] }], + properties: { + id: p.integer().primary(), + plugin_id: p.text().index('idx_plugin_erasure_plugin'), + user_id: p.integer(), + created_at: p.text().defaultRaw(`(datetime('now'))`), + }, +}); diff --git a/server/src/db/entities/Plugins.entity.ts b/server/src/db/entities/Plugins.entity.ts new file mode 100644 index 000000000..53d060d15 --- /dev/null +++ b/server/src/db/entities/Plugins.entity.ts @@ -0,0 +1,76 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { PluginsRepository } from '../repositories/Plugins.repository'; +import { DbTimestampType } from '../types'; + +export class Plugins { + [EntityRepositoryType]?: PluginsRepository; + id?: string | null; + name!: string; + description?: string | null; + type: string & Opt = 'integration'; + icon?: string | null = 'Blocks'; + version?: string | null; + api_version?: number | null = 1; + min_trek_version?: string | null; + permissions?: string | null = '[]'; + granted_permissions?: string | null = '[]'; + status: string & Opt = 'inactive'; + config?: string | null = '{}'; + source_repo?: string | null; + source_commit?: string | null; + sha256?: string | null; + crash_count: number & Opt = 0; + last_error?: string | null; + reviewed_at?: string | null; + sort_order: number & Opt = 0; + installed_at?: string | null; + updated_at?: string | null; + enabled: number & Opt = 0; + capabilities: string & Opt = '{}'; + author_pubkey?: string | null; + dependencies: string & Opt = '{}'; + operator_egress: number & Opt = 0; + update_block_code?: string | null; + update_block_detail?: string | null; + update_block_version?: string | null; + trek_range?: string | null; + update_hold: number & Opt = 0; +} + +export const PluginsSchema = defineEntity({ + class: Plugins, + repository: () => PluginsRepository, + properties: { + id: p.text().primary().nullable(), + name: p.text(), + description: p.text().nullable(), + type: p.text().default('integration'), + icon: p.text().nullable(), + version: p.text().nullable(), + api_version: p.integer().nullable(), + min_trek_version: p.text().nullable(), + permissions: p.text().nullable(), + granted_permissions: p.text().nullable(), + status: p.text().default('inactive'), + config: p.text().nullable(), + source_repo: p.text().nullable(), + source_commit: p.text().nullable(), + sha256: p.text().nullable(), + crash_count: p.integer().default(0), + last_error: p.text().nullable(), + reviewed_at: p.text().nullable(), + sort_order: p.integer().default(0), + installed_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + enabled: p.integer().default(0), + capabilities: p.text().default('{}'), + author_pubkey: p.text().nullable(), + dependencies: p.text().default('{}'), + operator_egress: p.integer().default(0), + update_block_code: p.text().nullable(), + update_block_detail: p.text().nullable(), + update_block_version: p.text().nullable(), + trek_range: p.text().nullable(), + update_hold: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/PushSubscriptions.entity.ts b/server/src/db/entities/PushSubscriptions.entity.ts new file mode 100644 index 000000000..e346ae97d --- /dev/null +++ b/server/src/db/entities/PushSubscriptions.entity.ts @@ -0,0 +1,37 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { PushSubscriptionsRepository } from '../repositories/PushSubscriptions.repository'; +import { Users } from './Users.entity'; + +export class PushSubscriptions { + [EntityRepositoryType]?: PushSubscriptionsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + endpoint!: string; + p256dh!: string; + auth!: string; + vapid_public_key!: string; + user_agent?: string | null; + created_at!: string & Opt; + last_success_at?: string | null; + failure_count: number & Opt = 0; +} + +export const PushSubscriptionsSchema = defineEntity({ + class: PushSubscriptions, + repository: () => PushSubscriptionsRepository, + uniques: [{ properties: ['endpoint'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_push_subscriptions_user'), + user_id: p.integer().persist(false).index('idx_push_subscriptions_user'), + endpoint: p.text(), + p256dh: p.text(), + auth: p.text(), + vapid_public_key: p.text(), + user_agent: p.text().nullable(), + created_at: p.text().defaultRaw(`CURRENT_TIMESTAMP`), + last_success_at: p.text().nullable(), + failure_count: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/ReservationDayPositions.entity.ts b/server/src/db/entities/ReservationDayPositions.entity.ts new file mode 100644 index 000000000..5dddf729e --- /dev/null +++ b/server/src/db/entities/ReservationDayPositions.entity.ts @@ -0,0 +1,27 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ReservationDayPositionsRepository } from '../repositories/ReservationDayPositions.repository'; +import { Days } from './Days.entity'; +import { Reservations } from './Reservations.entity'; + +export class ReservationDayPositions { + [EntityRepositoryType]?: ReservationDayPositionsRepository; + [PrimaryKeyProp]?: ['reservation', 'day']; + reservation!: Ref; + reservation_id!: number; + day!: Ref; + day_id!: number; + position!: number; +} + +export const ReservationDayPositionsSchema = defineEntity({ + class: ReservationDayPositions, + repository: () => ReservationDayPositionsRepository, + uniques: [{ properties: ['reservation', 'day'] }], + properties: { + reservation: () => p.manyToOne(Reservations).primary().ref().hidden(), + reservation_id: p.integer().persist(false), + day: () => p.manyToOne(Days).primary().ref().hidden(), + day_id: p.integer().persist(false), + position: p.double(), + }, +}); diff --git a/server/src/db/entities/ReservationEndpoints.entity.ts b/server/src/db/entities/ReservationEndpoints.entity.ts new file mode 100644 index 000000000..825307730 --- /dev/null +++ b/server/src/db/entities/ReservationEndpoints.entity.ts @@ -0,0 +1,41 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ReservationEndpointsRepository } from '../repositories/ReservationEndpoints.repository'; +import { DbTimestampType } from '../types'; +import { Reservations } from './Reservations.entity'; + +export class ReservationEndpoints { + [EntityRepositoryType]?: ReservationEndpointsRepository; + id!: number & Opt; + reservation!: Ref; + reservation_id!: number; + role!: string; + sequence: number & Opt = 0; + name!: string; + code?: string | null; + lat!: number; + lng!: number; + timezone?: string | null; + local_time?: string | null; + local_date?: string | null; + created_at?: string | null; +} + +export const ReservationEndpointsSchema = defineEntity({ + class: ReservationEndpoints, + repository: () => ReservationEndpointsRepository, + properties: { + id: p.integer().primary(), + reservation: () => p.manyToOne(Reservations).ref().deleteRule('cascade').hidden().index('idx_reservation_endpoints_reservation_id'), + reservation_id: p.integer().persist(false).index('idx_reservation_endpoints_reservation_id'), + role: p.text(), + sequence: p.integer().default(0), + name: p.text(), + code: p.text().nullable(), + lat: p.double(), + lng: p.double(), + timezone: p.text().nullable(), + local_time: p.text().nullable(), + local_date: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/ReservationTravelers.entity.ts b/server/src/db/entities/ReservationTravelers.entity.ts new file mode 100644 index 000000000..0ac8313e1 --- /dev/null +++ b/server/src/db/entities/ReservationTravelers.entity.ts @@ -0,0 +1,26 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ReservationTravelersRepository } from '../repositories/ReservationTravelers.repository'; +import { Reservations } from './Reservations.entity'; +import { Users } from './Users.entity'; + +export class ReservationTravelers { + [EntityRepositoryType]?: ReservationTravelersRepository; + id!: number & Opt; + reservation!: Ref; + reservation_id!: number; + user!: Ref; + user_id!: number; +} + +export const ReservationTravelersSchema = defineEntity({ + class: ReservationTravelers, + repository: () => ReservationTravelersRepository, + uniques: [{ properties: ['reservation', 'user'] }], + properties: { + id: p.integer().primary(), + reservation: () => p.manyToOne(Reservations).ref().deleteRule('cascade').hidden().index('idx_reservation_travelers_res'), + reservation_id: p.integer().persist(false).index('idx_reservation_travelers_res'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_reservation_travelers_user'), + user_id: p.integer().persist(false).index('idx_reservation_travelers_user'), + }, +}); diff --git a/server/src/db/entities/Reservations.entity.ts b/server/src/db/entities/Reservations.entity.ts new file mode 100644 index 000000000..8c946b908 --- /dev/null +++ b/server/src/db/entities/Reservations.entity.ts @@ -0,0 +1,106 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ReservationsRepository } from '../repositories/Reservations.repository'; +import { DbTimestampType } from '../types'; +import { BudgetItems } from './BudgetItems.entity'; +import { DayAssignments } from './DayAssignments.entity'; +import { Days } from './Days.entity'; +import { FileLinks } from './FileLinks.entity'; +import { Places } from './Places.entity'; +import { ReservationDayPositions } from './ReservationDayPositions.entity'; +import { ReservationEndpoints } from './ReservationEndpoints.entity'; +import { ReservationTravelers } from './ReservationTravelers.entity'; +import { TripFiles } from './TripFiles.entity'; +import { Trips } from './Trips.entity'; + +export class Reservations { + [EntityRepositoryType]?: ReservationsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + day?: Ref | null; + day_id?: number | null; + endDay?: Ref | null; + end_day_id?: number | null; + place?: Ref | null; + place_id?: number | null; + assignment?: Ref | null; + assignment_id?: number | null; + title!: string; + accommodation_id?: string | null; + reservation_time?: string | null; + reservation_end_time?: string | null; + location?: string | null; + confirmation_number?: string | null; + notes?: string | null; + status?: string | null = 'pending'; + type?: string | null = 'other'; + created_at?: string | null; + metadata?: string | null; + day_plan_position?: number | null; + needs_review: number & Opt = 0; + external_source?: string | null; + external_id?: string | null; + external_owner_user_id?: number | null; + external_synced_at?: string | null; + sync_enabled?: number | null = 1; + external_hash?: string | null; + url?: string | null; + ingest_state: string & Opt = 'live'; + budget_items_collection = new Collection(this); + file_links_collection = new Collection(this); + reservation_day_positions_collection = new Collection(this); + reservation_endpoints_collection = new Collection(this); + reservation_travelers_collection = new Collection(this); + trip_files_collection = new Collection(this); +} + +export const ReservationsSchema = defineEntity({ + class: Reservations, + repository: () => ReservationsRepository, + uniques: [ + { + name: 'idx_reservations_external', + properties: ['external_source', 'external_id', 'trip_id'], + }, + ], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_reservations_trip_id'), + trip_id: p.integer().persist(false).index('idx_reservations_trip_id'), + day: () => p.manyToOne(Days).ref().nullable().hidden().index('idx_reservations_day_id'), + day_id: p.integer().nullable().persist(false).index('idx_reservations_day_id'), + endDay: () => p.manyToOne(Days).ref().nullable().hidden(), + end_day_id: p.integer().nullable().persist(false), + place: () => p.manyToOne(Places).ref().nullable().hidden(), + place_id: p.integer().nullable().persist(false), + assignment: () => p.manyToOne(DayAssignments).ref().nullable().hidden(), + assignment_id: p.integer().nullable().persist(false), + title: p.text(), + accommodation_id: p.text().nullable(), + reservation_time: p.text().nullable(), + reservation_end_time: p.text().nullable(), + location: p.text().nullable(), + confirmation_number: p.text().nullable(), + notes: p.text().nullable(), + status: p.text().nullable(), + type: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + metadata: p.text().nullable(), + day_plan_position: p.double().nullable().defaultRaw(`NULL`), + needs_review: p.integer().default(0), + external_source: p.text().nullable(), + external_id: p.text().nullable(), + external_owner_user_id: p.integer().nullable(), + external_synced_at: p.text().nullable(), + sync_enabled: p.integer().nullable(), + external_hash: p.text().nullable(), + url: p.text().nullable(), + ingest_state: p.text().default('live'), + budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('reservation').hidden(), + file_links_collection: () => p.oneToMany(FileLinks).mappedBy('reservation').hidden(), + reservation_day_positions_collection: () => p.oneToMany(ReservationDayPositions).mappedBy('reservation').hidden(), + reservation_endpoints_collection: () => p.oneToMany(ReservationEndpoints).mappedBy('reservation').hidden(), + reservation_travelers_collection: () => p.oneToMany(ReservationTravelers).mappedBy('reservation').hidden(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('reservation').hidden(), + }, +}); diff --git a/server/src/db/entities/RoadtripDayBoundaries.entity.ts b/server/src/db/entities/RoadtripDayBoundaries.entity.ts new file mode 100644 index 000000000..1edce45ea --- /dev/null +++ b/server/src/db/entities/RoadtripDayBoundaries.entity.ts @@ -0,0 +1,43 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { RoadtripDayBoundariesRepository } from '../repositories/RoadtripDayBoundaries.repository'; +import { DayAssignments } from './DayAssignments.entity'; +import { Trips } from './Trips.entity'; + +export class RoadtripDayBoundaries { + [EntityRepositoryType]?: RoadtripDayBoundariesRepository; + [PrimaryKeyProp]?: ['trip', 'day_number']; + trip!: Ref; + trip_id!: number; + day_number!: number; + fromAssignment!: Ref; + from_assignment_id!: number; + toAssignment?: Ref | null; + to_assignment_id?: number | null; + fraction!: number; +} + +export const RoadtripDayBoundariesSchema = defineEntity({ + class: RoadtripDayBoundaries, + repository: () => RoadtripDayBoundariesRepository, + uniques: [{ properties: ['trip', 'day_number'] }], + checks: [ + { + name: 'roadtrip_day_boundaries_fraction_check', + expression: 'fraction BETWEEN 0 AND 1', + }, + { + name: 'roadtrip_day_boundaries_day_number_check', + expression: 'day_number >= 1', + }, + ], + properties: { + trip: () => p.manyToOne(Trips).primary().ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + day_number: p.integer().primary(), + fromAssignment: () => p.manyToOne(DayAssignments).ref().deleteRule('cascade').hidden(), + from_assignment_id: p.integer().persist(false), + toAssignment: () => p.manyToOne(DayAssignments).ref().deleteRule('cascade').nullable().hidden(), + to_assignment_id: p.integer().nullable().persist(false), + fraction: p.double(), + }, +}); diff --git a/server/src/db/entities/RoadtripDayTracks.entity.ts b/server/src/db/entities/RoadtripDayTracks.entity.ts new file mode 100644 index 000000000..7c0387499 --- /dev/null +++ b/server/src/db/entities/RoadtripDayTracks.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { RoadtripDayTracksRepository } from '../repositories/RoadtripDayTracks.repository'; +import { Days } from './Days.entity'; +import { Places } from './Places.entity'; + +export class RoadtripDayTracks { + [EntityRepositoryType]?: RoadtripDayTracksRepository; + [PrimaryKeyProp]?: 'day'; + day?: Ref | null; + day_id?: number | null; + place!: Ref; + place_id!: number; + stray_km?: number | null; + created_at?: string | null; +} + +export const RoadtripDayTracksSchema = defineEntity({ + class: RoadtripDayTracks, + repository: () => RoadtripDayTracksRepository, + properties: { + day: () => p.oneToOne(Days).primary().ref().deleteRule('cascade').nullable().hidden(), + day_id: p.integer().nullable().persist(false), + place: () => p.manyToOne(Places).ref().deleteRule('cascade').hidden().index('idx_roadtrip_day_tracks_place'), + place_id: p.integer().persist(false).index('idx_roadtrip_day_tracks_place'), + stray_km: p.double().nullable(), + created_at: p.text().nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/RoadtripPreferences.entity.ts b/server/src/db/entities/RoadtripPreferences.entity.ts new file mode 100644 index 000000000..d5879c332 --- /dev/null +++ b/server/src/db/entities/RoadtripPreferences.entity.ts @@ -0,0 +1,24 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { RoadtripPreferencesRepository } from '../repositories/RoadtripPreferences.repository'; +import { Trips } from './Trips.entity'; + +export class RoadtripPreferences { + [EntityRepositoryType]?: RoadtripPreferencesRepository; + [PrimaryKeyProp]?: ['trip', 'key']; + trip!: Ref; + trip_id!: number; + key!: string; + value!: string; +} + +export const RoadtripPreferencesSchema = defineEntity({ + class: RoadtripPreferences, + repository: () => RoadtripPreferencesRepository, + uniques: [{ properties: ['trip', 'key'] }], + properties: { + trip: () => p.manyToOne(Trips).primary().ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + key: p.text().primary(), + value: p.text(), + }, +}); diff --git a/server/src/db/entities/RoadtripVias.entity.ts b/server/src/db/entities/RoadtripVias.entity.ts new file mode 100644 index 000000000..477a07124 --- /dev/null +++ b/server/src/db/entities/RoadtripVias.entity.ts @@ -0,0 +1,36 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { RoadtripViasRepository } from '../repositories/RoadtripVias.repository'; +import { Days } from './Days.entity'; + +export class RoadtripVias { + [EntityRepositoryType]?: RoadtripViasRepository; + id!: number & Opt; + day!: Ref; + day_id!: number; + after_order_index!: number; + sequence: number & Opt = 0; + lat!: number; + lng!: number; + created_at?: string | null; +} + +export const RoadtripViasSchema = defineEntity({ + class: RoadtripVias, + repository: () => RoadtripViasRepository, + indexes: [ + { + name: 'idx_roadtrip_vias_day', + properties: ['day_id', 'after_order_index', 'sequence'], + }, + ], + properties: { + id: p.integer().primary(), + day: () => p.manyToOne(Days).ref().deleteRule('cascade').hidden(), + day_id: p.integer().persist(false), + after_order_index: p.integer(), + sequence: p.integer().default(0), + lat: p.double(), + lng: p.double(), + created_at: p.text().nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/RouteUsageDaily.entity.ts b/server/src/db/entities/RouteUsageDaily.entity.ts new file mode 100644 index 000000000..fea8f7edc --- /dev/null +++ b/server/src/db/entities/RouteUsageDaily.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { RouteUsageDailyRepository } from '../repositories/RouteUsageDaily.repository'; + +export class RouteUsageDaily { + [EntityRepositoryType]?: RouteUsageDailyRepository; + [PrimaryKeyProp]?: ['day', 'profile', 'surface', 'self_hosted']; + day!: string; + profile!: string; + surface!: string; + self_hosted!: number; + requests: number & Opt = 0; + waypoints: number & Opt = 0; + km!: number & Opt; + failed: number & Opt = 0; +} + +export const RouteUsageDailySchema = defineEntity({ + class: RouteUsageDaily, + repository: () => RouteUsageDailyRepository, + properties: { + day: p.text().primary().index('idx_route_usage_day'), + profile: p.text().primary(), + surface: p.text().primary(), + self_hosted: p.integer().primary(), + requests: p.integer().default(0), + waypoints: p.integer().default(0), + km: p.double().defaultRaw(`0`), + failed: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/SchemaVersion.entity.ts b/server/src/db/entities/SchemaVersion.entity.ts new file mode 100644 index 000000000..48e8e4cc7 --- /dev/null +++ b/server/src/db/entities/SchemaVersion.entity.ts @@ -0,0 +1,17 @@ +import { EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core'; +import { SchemaVersionRepository } from '../repositories/SchemaVersion.repository'; + +export class SchemaVersion { + [EntityRepositoryType]?: SchemaVersionRepository; + id!: number & Opt; + version!: number; +} + +export const SchemaVersionSchema = defineEntity({ + class: SchemaVersion, + repository: () => SchemaVersionRepository, + properties: { + id: p.integer().primary(), + version: p.integer(), + }, +}); diff --git a/server/src/db/entities/SchoolHolidayCountries.entity.ts b/server/src/db/entities/SchoolHolidayCountries.entity.ts new file mode 100644 index 000000000..73e041190 --- /dev/null +++ b/server/src/db/entities/SchoolHolidayCountries.entity.ts @@ -0,0 +1,21 @@ +import { Collection, EntityRepositoryType, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { SchoolHolidayCountriesRepository } from '../repositories/SchoolHolidayCountries.repository'; +import { SchoolHolidayRegions } from './SchoolHolidayRegions.entity'; + +export class SchoolHolidayCountries { + [EntityRepositoryType]?: SchoolHolidayCountriesRepository; + [PrimaryKeyProp]?: 'code'; + code?: string | null; + name!: string; + school_holiday_regions_collection = new Collection(this); +} + +export const SchoolHolidayCountriesSchema = defineEntity({ + class: SchoolHolidayCountries, + repository: () => SchoolHolidayCountriesRepository, + properties: { + code: p.text().primary().nullable(), + name: p.text(), + school_holiday_regions_collection: () => p.oneToMany(SchoolHolidayRegions).mappedBy('countryRef').hidden(), + }, +}); diff --git a/server/src/db/entities/SchoolHolidayPeriods.entity.ts b/server/src/db/entities/SchoolHolidayPeriods.entity.ts new file mode 100644 index 000000000..628ec39e7 --- /dev/null +++ b/server/src/db/entities/SchoolHolidayPeriods.entity.ts @@ -0,0 +1,32 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { SchoolHolidayPeriodsRepository } from '../repositories/SchoolHolidayPeriods.repository'; +import { SchoolHolidayRegions } from './SchoolHolidayRegions.entity'; + +export class SchoolHolidayPeriods { + [EntityRepositoryType]?: SchoolHolidayPeriodsRepository; + id!: number & Opt; + region!: Ref; + region_id!: number; + name!: string; + start_date!: string; + end_date!: string; +} + +export const SchoolHolidayPeriodsSchema = defineEntity({ + class: SchoolHolidayPeriods, + repository: () => SchoolHolidayPeriodsRepository, + checks: [ + { + name: 'school_holiday_periods_end_date_check', + expression: 'end_date >= start_date', + }, + ], + properties: { + id: p.integer().primary(), + region: () => p.manyToOne(SchoolHolidayRegions).ref().deleteRule('cascade').hidden().index('idx_school_holiday_periods_region'), + region_id: p.integer().persist(false).index('idx_school_holiday_periods_region'), + name: p.text(), + start_date: p.text(), + end_date: p.text(), + }, +}); diff --git a/server/src/db/entities/SchoolHolidayRegions.entity.ts b/server/src/db/entities/SchoolHolidayRegions.entity.ts new file mode 100644 index 000000000..154fbc607 --- /dev/null +++ b/server/src/db/entities/SchoolHolidayRegions.entity.ts @@ -0,0 +1,28 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { SchoolHolidayRegionsRepository } from '../repositories/SchoolHolidayRegions.repository'; +import { SchoolHolidayCountries } from './SchoolHolidayCountries.entity'; +import { SchoolHolidayPeriods } from './SchoolHolidayPeriods.entity'; + +export class SchoolHolidayRegions { + [EntityRepositoryType]?: SchoolHolidayRegionsRepository; + id!: number & Opt; + country!: string; + name!: string; + revision: number & Opt = 1; + countryRef!: Ref; + school_holiday_periods_collection = new Collection(this); +} + +export const SchoolHolidayRegionsSchema = defineEntity({ + class: SchoolHolidayRegions, + repository: () => SchoolHolidayRegionsRepository, + uniques: [{ properties: ['countryRef', 'name'] }], + properties: { + id: p.integer().primary(), + country: p.text().persist(false), + name: p.text().collation('NOCASE'), + revision: p.integer().default(1), + countryRef: () => p.manyToOne(SchoolHolidayCountries).ref().joinColumn('country').hidden(), + school_holiday_periods_collection: () => p.oneToMany(SchoolHolidayPeriods).mappedBy('region').hidden(), + }, +}); diff --git a/server/src/db/entities/Settings.entity.ts b/server/src/db/entities/Settings.entity.ts new file mode 100644 index 000000000..6de914911 --- /dev/null +++ b/server/src/db/entities/Settings.entity.ts @@ -0,0 +1,25 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { SettingsRepository } from '../repositories/Settings.repository'; +import { Users } from './Users.entity'; + +export class Settings { + [EntityRepositoryType]?: SettingsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + key!: string; + value?: string | null; +} + +export const SettingsSchema = defineEntity({ + class: Settings, + repository: () => SettingsRepository, + uniques: [{ properties: ['user', 'key'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + key: p.text(), + value: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/ShareTokens.entity.ts b/server/src/db/entities/ShareTokens.entity.ts new file mode 100644 index 000000000..2ac1b161e --- /dev/null +++ b/server/src/db/entities/ShareTokens.entity.ts @@ -0,0 +1,47 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ShareTokensRepository } from '../repositories/ShareTokens.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class ShareTokens { + [EntityRepositoryType]?: ShareTokensRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + token!: string; + created_by!: number; + share_map?: number | null = 1; + share_bookings?: number | null = 1; + share_packing?: number | null = 0; + share_budget?: number | null = 0; + share_collab?: number | null = 0; + created_at?: string | null; + expires_at?: string | null; + share_travel_only: number & Opt = 0; + share_hide_images: number & Opt = 0; + createdByRef!: Ref; +} + +export const ShareTokensSchema = defineEntity({ + class: ShareTokens, + repository: () => ShareTokensRepository, + uniques: [{ properties: ['token'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + token: p.text().index('idx_share_tokens_token'), + created_by: p.integer().persist(false), + share_map: p.integer().nullable(), + share_bookings: p.integer().nullable(), + share_packing: p.integer().nullable(), + share_budget: p.integer().nullable(), + share_collab: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + expires_at: p.text().nullable(), + share_travel_only: p.integer().default(0), + share_hide_images: p.integer().default(0), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').hidden(), + }, +}); diff --git a/server/src/db/entities/Tags.entity.ts b/server/src/db/entities/Tags.entity.ts new file mode 100644 index 000000000..7c58f8e7e --- /dev/null +++ b/server/src/db/entities/Tags.entity.ts @@ -0,0 +1,33 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TagsRepository } from '../repositories/Tags.repository'; +import { DbTimestampType } from '../types'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Places } from './Places.entity'; +import { Users } from './Users.entity'; + +export class Tags { + [EntityRepositoryType]?: TagsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + name!: string; + color?: string | null = '#10b981'; + created_at?: string | null; + collection_place_tags_inverse = new Collection(this); + place_tags_inverse = new Collection(this); +} + +export const TagsSchema = defineEntity({ + class: Tags, + repository: () => TagsRepository, + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + name: p.text(), + color: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + collection_place_tags_inverse: () => p.manyToMany(CollectionPlaces).mappedBy('collection_place_tags').hidden(), + place_tags_inverse: () => p.manyToMany(Places).mappedBy('place_tags').hidden(), + }, +}); diff --git a/server/src/db/entities/TodoCategoryAssignees.entity.ts b/server/src/db/entities/TodoCategoryAssignees.entity.ts new file mode 100644 index 000000000..76003274d --- /dev/null +++ b/server/src/db/entities/TodoCategoryAssignees.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TodoCategoryAssigneesRepository } from '../repositories/TodoCategoryAssignees.repository'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TodoCategoryAssignees { + [EntityRepositoryType]?: TodoCategoryAssigneesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + category_name!: string; + user!: Ref; + user_id!: number; +} + +export const TodoCategoryAssigneesSchema = defineEntity({ + class: TodoCategoryAssignees, + repository: () => TodoCategoryAssigneesRepository, + uniques: [{ properties: ['trip', 'category_name', 'user'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + category_name: p.text(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + }, +}); diff --git a/server/src/db/entities/TodoItems.entity.ts b/server/src/db/entities/TodoItems.entity.ts new file mode 100644 index 000000000..206b2331e --- /dev/null +++ b/server/src/db/entities/TodoItems.entity.ts @@ -0,0 +1,44 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TodoItemsRepository } from '../repositories/TodoItems.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TodoItems { + [EntityRepositoryType]?: TodoItemsRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + name!: string; + checked?: number | null = 0; + category?: string | null; + sort_order?: number | null = 0; + due_date?: string | null; + description?: string | null; + assignedUser?: Ref | null; + assigned_user_id?: number | null; + priority?: number | null = 0; + created_at?: string | null; + reminded_at?: string | null; +} + +export const TodoItemsSchema = defineEntity({ + class: TodoItems, + repository: () => TodoItemsRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_todo_items_trip_id'), + trip_id: p.integer().persist(false).index('idx_todo_items_trip_id'), + name: p.text(), + checked: p.integer().nullable(), + category: p.text().nullable(), + sort_order: p.integer().nullable(), + due_date: p.text().nullable(), + description: p.text().nullable(), + assignedUser: () => p.manyToOne(Users).ref().nullable().hidden(), + assigned_user_id: p.integer().nullable().persist(false), + priority: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + reminded_at: p.type(DbTimestampType).nullable(), + }, +}); diff --git a/server/src/db/entities/TourTypes.entity.ts b/server/src/db/entities/TourTypes.entity.ts new file mode 100644 index 000000000..abf6573b5 --- /dev/null +++ b/server/src/db/entities/TourTypes.entity.ts @@ -0,0 +1,33 @@ +import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { TourTypesRepository } from '../repositories/TourTypes.repository'; +import { Tours } from './Tours.entity'; + +export class TourTypes { + [EntityRepositoryType]?: TourTypesRepository; + [PrimaryKeyProp]?: 'key'; + key?: string | null; + label_key!: string; + icon!: string; + color!: string; + routing_profile?: string | null; + is_sport: number & Opt = 1; + enabled: number & Opt = 1; + sort_order: number & Opt = 0; + tours_collection = new Collection(this); +} + +export const TourTypesSchema = defineEntity({ + class: TourTypes, + repository: () => TourTypesRepository, + properties: { + key: p.text().primary().nullable(), + label_key: p.text(), + icon: p.text(), + color: p.text(), + routing_profile: p.text().nullable(), + is_sport: p.integer().default(1), + enabled: p.integer().default(1), + sort_order: p.integer().default(0), + tours_collection: () => p.oneToMany(Tours).mappedBy('tourTypeRef').hidden(), + }, +}); diff --git a/server/src/db/entities/TourWaypoints.entity.ts b/server/src/db/entities/TourWaypoints.entity.ts new file mode 100644 index 000000000..0f6a6ab80 --- /dev/null +++ b/server/src/db/entities/TourWaypoints.entity.ts @@ -0,0 +1,29 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TourWaypointsRepository } from '../repositories/TourWaypoints.repository'; +import { Tours } from './Tours.entity'; + +export class TourWaypoints { + [EntityRepositoryType]?: TourWaypointsRepository; + id!: number & Opt; + place!: Ref; + place_id!: number; + lat!: number; + lng!: number; + role!: string; + sequence!: number; +} + +export const TourWaypointsSchema = defineEntity({ + class: TourWaypoints, + repository: () => TourWaypointsRepository, + uniques: [{ properties: ['place', 'sequence'] }], + properties: { + id: p.integer().primary(), + place: () => p.manyToOne(Tours).ref().name('place_id').deleteRule('cascade').hidden(), + place_id: p.integer().persist(false), + lat: p.double(), + lng: p.double(), + role: p.text(), + sequence: p.integer(), + }, +}); diff --git a/server/src/db/entities/Tours.entity.ts b/server/src/db/entities/Tours.entity.ts new file mode 100644 index 000000000..beedbaad7 --- /dev/null +++ b/server/src/db/entities/Tours.entity.ts @@ -0,0 +1,47 @@ +import { Collection, EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { ToursRepository } from '../repositories/Tours.repository'; +import { Places } from './Places.entity'; +import { TourTypes } from './TourTypes.entity'; +import { TourWaypoints } from './TourWaypoints.entity'; + +export class Tours { + [EntityRepositoryType]?: ToursRepository; + [PrimaryKeyProp]?: 'place'; + place?: Ref | null; + place_id?: number | null; + tour_type!: string; + distance?: number | null; + elevation_gain?: number | null; + elevation_loss?: number | null; + duration?: number | null; + difficulty?: string | null; + wanderer_ref?: string | null; + match_confidence?: number | null; + tour_group_id?: number | null; + created_at?: string | null; + max_hiking_difficulty: number & Opt = 2; + tourTypeRef!: Ref; + tour_waypoints_collection = new Collection(this); +} + +export const ToursSchema = defineEntity({ + class: Tours, + repository: () => ToursRepository, + properties: { + place: () => p.oneToOne(Places).primary().ref().deleteRule('cascade').nullable().hidden(), + place_id: p.integer().nullable().persist(false), + tour_type: p.text().persist(false).index('idx_tours_tour_type'), + distance: p.double().nullable(), + elevation_gain: p.double().nullable(), + elevation_loss: p.double().nullable(), + duration: p.double().nullable(), + difficulty: p.text().nullable(), + wanderer_ref: p.text().nullable(), + match_confidence: p.double().nullable(), + tour_group_id: p.integer().nullable(), + created_at: p.text().nullable().defaultRaw(`CURRENT_TIMESTAMP`), + max_hiking_difficulty: p.integer().default(2), + tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'), + tour_waypoints_collection: () => p.oneToMany(TourWaypoints).mappedBy('place').hidden(), + }, +}); diff --git a/server/src/db/entities/TrekPhotoCacheMeta.entity.ts b/server/src/db/entities/TrekPhotoCacheMeta.entity.ts new file mode 100644 index 000000000..6907a3888 --- /dev/null +++ b/server/src/db/entities/TrekPhotoCacheMeta.entity.ts @@ -0,0 +1,20 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core'; +import { TrekPhotoCacheMetaRepository } from '../repositories/TrekPhotoCacheMeta.repository'; + +export class TrekPhotoCacheMeta { + [EntityRepositoryType]?: TrekPhotoCacheMetaRepository; + [PrimaryKeyProp]?: 'cache_key'; + cache_key!: string; + content_type: string & Opt = 'image/jpeg'; + fetched_at!: number; +} + +export const TrekPhotoCacheMetaSchema = defineEntity({ + class: TrekPhotoCacheMeta, + repository: () => TrekPhotoCacheMetaRepository, + properties: { + cache_key: p.text().primary(), + content_type: p.text().default('image/jpeg'), + fetched_at: p.integer().index('idx_trek_photo_cache_meta_fetched_at'), + }, +}); diff --git a/server/src/db/entities/TrekPhotos.entity.ts b/server/src/db/entities/TrekPhotos.entity.ts new file mode 100644 index 000000000..6c2c969de --- /dev/null +++ b/server/src/db/entities/TrekPhotos.entity.ts @@ -0,0 +1,67 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TrekPhotosRepository } from '../repositories/TrekPhotos.repository'; +import { DbTimestampType } from '../types'; +import { JourneyPhotos } from './JourneyPhotos.entity'; +import { TripPhotos } from './TripPhotos.entity'; +import { Users } from './Users.entity'; + +export class TrekPhotos { + [EntityRepositoryType]?: TrekPhotosRepository; + id!: number & Opt; + provider!: string; + asset_id?: string | null; + owner?: Ref | null; + owner_id?: number | null; + file_path?: string | null; + thumbnail_path?: string | null; + width?: number | null; + height?: number | null; + created_at?: string | null; + passphrase?: string | null; + media_type: string & Opt = 'image'; + duration_ms?: number | null; + taken_at?: string | null; + lat?: number | null; + lng?: number | null; + journey_photos_collection = new Collection(this); + trip_photos_collection = new Collection(this); +} + +export const TrekPhotosSchema = defineEntity({ + class: TrekPhotos, + repository: () => TrekPhotosRepository, + indexes: [ + { + name: 'idx_trek_photos_geo', + where: 'lat IS NOT NULL AND lng IS NOT NULL', + properties: ['lat', 'lng'], + }, + ], + uniques: [ + { + name: 'idx_trek_photos_provider_asset', + where: 'asset_id IS NOT NULL', + properties: ['provider', 'asset_id', 'owner_id'], + }, + ], + properties: { + id: p.integer().primary(), + provider: p.text(), + asset_id: p.text().nullable(), + owner: () => p.manyToOne(Users).ref().nullable().hidden().index('idx_trek_photos_owner'), + owner_id: p.integer().nullable().persist(false).index('idx_trek_photos_owner'), + file_path: p.text().nullable(), + thumbnail_path: p.text().nullable(), + width: p.integer().nullable(), + height: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + passphrase: p.text().nullable().defaultRaw(`NULL`), + media_type: p.text().default('image'), + duration_ms: p.integer().nullable(), + taken_at: p.text().nullable(), + lat: p.double().nullable(), + lng: p.double().nullable(), + journey_photos_collection: () => p.oneToMany(JourneyPhotos).mappedBy('photo').hidden(), + trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('photo').hidden(), + }, +}); diff --git a/server/src/db/entities/TripAlbumLinks.entity.ts b/server/src/db/entities/TripAlbumLinks.entity.ts new file mode 100644 index 000000000..df41280c2 --- /dev/null +++ b/server/src/db/entities/TripAlbumLinks.entity.ts @@ -0,0 +1,44 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripAlbumLinksRepository } from '../repositories/TripAlbumLinks.repository'; +import { DbTimestampType } from '../types'; +import { TripPhotos } from './TripPhotos.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripAlbumLinks { + [EntityRepositoryType]?: TripAlbumLinksRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + provider!: string; + album_id!: string; + album_name: string & Opt = ''; + sync_enabled: number & Opt = 1; + last_synced_at?: string | null; + created_at?: string | null; + passphrase?: string | null; + trip_photos_collection = new Collection(this); +} + +export const TripAlbumLinksSchema = defineEntity({ + class: TripAlbumLinks, + repository: () => TripAlbumLinksRepository, + uniques: [{ properties: ['trip', 'user', 'provider', 'album_id'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_album_links_trip'), + trip_id: p.integer().persist(false).index('idx_trip_album_links_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + provider: p.text(), + album_id: p.text(), + album_name: p.text().default(''), + sync_enabled: p.integer().default(1), + last_synced_at: p.type(DbTimestampType).nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + passphrase: p.text().nullable().defaultRaw(`NULL`), + trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('albumLink').hidden(), + }, +}); diff --git a/server/src/db/entities/TripDocumentLinks.entity.ts b/server/src/db/entities/TripDocumentLinks.entity.ts new file mode 100644 index 000000000..c366ad487 --- /dev/null +++ b/server/src/db/entities/TripDocumentLinks.entity.ts @@ -0,0 +1,88 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripDocumentLinksRepository } from '../repositories/TripDocumentLinks.repository'; +import { DbTimestampType } from '../types'; +import { DocumentConnections } from './DocumentConnections.entity'; +import { DocumentSyncItems } from './DocumentSyncItems.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripDocumentLinks { + [EntityRepositoryType]?: TripDocumentLinksRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + connection!: Ref; + connection_id!: number; + provider_id!: string; + remote_scope_key!: string; + remote_root_id?: string | null; + remote_root_path?: string | null; + remote_label: string & Opt = ''; + direction: string & Opt = 'both'; + delete_policy: string & Opt = 'unlink'; + conflict_policy: string & Opt = 'manual'; + sync_enabled: number & Opt = 1; + webhook_token?: string | null; + webhook_secret?: string | null; + webhook_subscription_id?: string | null; + remote_cursor?: string | null; + last_sync_at?: string | null; + last_sync_state: string & Opt = 'never'; + last_sync_error?: string | null; + failure_count: number & Opt = 0; + next_attempt_at?: string | null; + created_by?: number | null; + created_at?: string | null; + updated_at?: string | null; + createdByRef?: Ref | null; + document_sync_items_collection = new Collection(this); +} + +export const TripDocumentLinksSchema = defineEntity({ + class: TripDocumentLinks, + repository: () => TripDocumentLinksRepository, + indexes: [ + { + name: 'idx_trip_document_links_due', + properties: ['sync_enabled', 'next_attempt_at'], + }, + ], + uniques: [ + { + name: 'idx_trip_document_links_token', + where: 'webhook_token IS NOT NULL', + properties: ['webhook_token'], + }, + { properties: ['trip', 'connection', 'remote_scope_key'] }, + ], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_document_links_trip'), + trip_id: p.integer().persist(false).index('idx_trip_document_links_trip'), + connection: () => p.manyToOne(DocumentConnections).ref().deleteRule('cascade').hidden(), + connection_id: p.integer().persist(false), + provider_id: p.text(), + remote_scope_key: p.text(), + remote_root_id: p.text().nullable(), + remote_root_path: p.text().nullable(), + remote_label: p.text().default(''), + direction: p.text().default('both'), + delete_policy: p.text().default('unlink'), + conflict_policy: p.text().default('manual'), + sync_enabled: p.integer().default(1), + webhook_token: p.text().nullable(), + webhook_secret: p.text().nullable(), + webhook_subscription_id: p.text().nullable(), + remote_cursor: p.text().nullable(), + last_sync_at: p.text().nullable(), + last_sync_state: p.text().default('never'), + last_sync_error: p.text().nullable(), + failure_count: p.integer().default(0), + next_attempt_at: p.text().nullable(), + created_by: p.integer().nullable().persist(false), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').nullable().hidden(), + document_sync_items_collection: () => p.oneToMany(DocumentSyncItems).mappedBy('link').hidden(), + }, +}); diff --git a/server/src/db/entities/TripFiles.entity.ts b/server/src/db/entities/TripFiles.entity.ts new file mode 100644 index 000000000..2a8493ce3 --- /dev/null +++ b/server/src/db/entities/TripFiles.entity.ts @@ -0,0 +1,68 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripFilesRepository } from '../repositories/TripFiles.repository'; +import { DbTimestampType } from '../types'; +import { CollabMessages } from './CollabMessages.entity'; +import { CollabNotes } from './CollabNotes.entity'; +import { DocumentSyncItems } from './DocumentSyncItems.entity'; +import { FileLinks } from './FileLinks.entity'; +import { Places } from './Places.entity'; +import { Reservations } from './Reservations.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripFiles { + [EntityRepositoryType]?: TripFilesRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + place?: Ref | null; + place_id?: number | null; + reservation?: Ref | null; + reservation_id?: number | null; + filename!: string; + original_name!: string; + file_size?: number | null; + mime_type?: string | null; + description?: string | null; + created_at?: string | null; + note?: Ref | null; + note_id?: number | null; + uploaded_by?: number | null; + starred?: number | null = 0; + deleted_at?: string | null; + message?: Ref | null; + message_id?: number | null; + uploadedByRef?: Ref | null; + document_sync_items_collection = new Collection(this); + file_links_collection = new Collection(this); +} + +export const TripFilesSchema = defineEntity({ + class: TripFiles, + repository: () => TripFilesRepository, + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_files_trip_id'), + trip_id: p.integer().persist(false).index('idx_trip_files_trip_id'), + place: () => p.manyToOne(Places).ref().nullable().hidden(), + place_id: p.integer().nullable().persist(false), + reservation: () => p.manyToOne(Reservations).ref().nullable().hidden(), + reservation_id: p.integer().nullable().persist(false), + filename: p.text(), + original_name: p.text(), + file_size: p.integer().nullable(), + mime_type: p.text().nullable(), + description: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + note: () => p.manyToOne(CollabNotes).ref().nullable().hidden(), + note_id: p.integer().nullable().persist(false), + uploaded_by: p.integer().nullable().persist(false), + starred: p.integer().nullable(), + deleted_at: p.text().nullable(), + message: () => p.manyToOne(CollabMessages).ref().deleteRule('cascade').nullable().hidden().index('idx_trip_files_message_id'), + message_id: p.integer().nullable().persist(false).index('idx_trip_files_message_id'), + uploadedByRef: () => p.manyToOne(Users).ref().joinColumn('uploaded_by').nullable().hidden(), + document_sync_items_collection: () => p.oneToMany(DocumentSyncItems).mappedBy('file').hidden(), + file_links_collection: () => p.oneToMany(FileLinks).mappedBy('file').hidden(), + }, +}); diff --git a/server/src/db/entities/TripInviteTokens.entity.ts b/server/src/db/entities/TripInviteTokens.entity.ts new file mode 100644 index 000000000..b669c2514 --- /dev/null +++ b/server/src/db/entities/TripInviteTokens.entity.ts @@ -0,0 +1,33 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripInviteTokensRepository } from '../repositories/TripInviteTokens.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripInviteTokens { + [EntityRepositoryType]?: TripInviteTokensRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + token!: string; + created_by?: number | null; + expires_at?: string | null; + created_at?: string | null; + createdByRef?: Ref | null; +} + +export const TripInviteTokensSchema = defineEntity({ + class: TripInviteTokens, + repository: () => TripInviteTokensRepository, + uniques: [{ properties: ['token'] }, { properties: ['trip'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden(), + trip_id: p.integer().persist(false), + token: p.text().index('idx_trip_invite_tokens_token'), + created_by: p.integer().nullable().persist(false), + expires_at: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + createdByRef: () => p.manyToOne(Users).ref().joinColumn('created_by').nullable().hidden(), + }, +}); diff --git a/server/src/db/entities/TripMembers.entity.ts b/server/src/db/entities/TripMembers.entity.ts new file mode 100644 index 000000000..a08f68207 --- /dev/null +++ b/server/src/db/entities/TripMembers.entity.ts @@ -0,0 +1,33 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripMembersRepository } from '../repositories/TripMembers.repository'; +import { DbTimestampType } from '../types'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripMembers { + [EntityRepositoryType]?: TripMembersRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + invited_by?: number | null; + added_at?: string | null; + invitedByRef?: Ref | null; +} + +export const TripMembersSchema = defineEntity({ + class: TripMembers, + repository: () => TripMembersRepository, + uniques: [{ properties: ['trip', 'user'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_members_trip_id'), + trip_id: p.integer().persist(false).index('idx_trip_members_trip_id'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_trip_members_user_id'), + user_id: p.integer().persist(false).index('idx_trip_members_user_id'), + invited_by: p.integer().nullable().persist(false), + added_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + invitedByRef: () => p.manyToOne(Users).ref().joinColumn('invited_by').deleteRule('no action').nullable().hidden(), + }, +}); diff --git a/server/src/db/entities/TripPhotos.entity.ts b/server/src/db/entities/TripPhotos.entity.ts new file mode 100644 index 000000000..25e6384d9 --- /dev/null +++ b/server/src/db/entities/TripPhotos.entity.ts @@ -0,0 +1,41 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripPhotosRepository } from '../repositories/TripPhotos.repository'; +import { DbTimestampType } from '../types'; +import { TrekPhotos } from './TrekPhotos.entity'; +import { TripAlbumLinks } from './TripAlbumLinks.entity'; +import { Trips } from './Trips.entity'; +import { Users } from './Users.entity'; + +export class TripPhotos { + [EntityRepositoryType]?: TripPhotosRepository; + id!: number & Opt; + trip!: Ref; + trip_id!: number; + user!: Ref; + user_id!: number; + photo!: Ref; + photo_id!: number; + shared: number & Opt = 1; + albumLink?: Ref | null; + album_link_id?: number | null; + added_at?: string | null; +} + +export const TripPhotosSchema = defineEntity({ + class: TripPhotos, + repository: () => TripPhotosRepository, + uniques: [{ properties: ['trip', 'user', 'photo'] }], + properties: { + id: p.integer().primary(), + trip: () => p.manyToOne(Trips).ref().deleteRule('cascade').hidden().index('idx_trip_photos_trip'), + trip_id: p.integer().persist(false).index('idx_trip_photos_trip'), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + photo: () => p.manyToOne(TrekPhotos).ref().deleteRule('cascade').hidden().index('idx_trip_photos_photo'), + photo_id: p.integer().persist(false).index('idx_trip_photos_photo'), + shared: p.integer().default(1), + albumLink: () => p.manyToOne(TripAlbumLinks).ref().nullable().hidden(), + album_link_id: p.integer().nullable().persist(false), + added_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/Trips.entity.ts b/server/src/db/entities/Trips.entity.ts new file mode 100644 index 000000000..903e0931a --- /dev/null +++ b/server/src/db/entities/Trips.entity.ts @@ -0,0 +1,149 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TripsRepository } from '../repositories/Trips.repository'; +import { DbTimestampType } from '../types'; +import { BudgetCategoryOrder } from './BudgetCategoryOrder.entity'; +import { BudgetItems } from './BudgetItems.entity'; +import { BudgetSettlements } from './BudgetSettlements.entity'; +import { CollabLinks } from './CollabLinks.entity'; +import { CollabMessages } from './CollabMessages.entity'; +import { CollabNotes } from './CollabNotes.entity'; +import { CollabPolls } from './CollabPolls.entity'; +import { DawarichVisitSuggestions } from './DawarichVisitSuggestions.entity'; +import { DayAccommodations } from './DayAccommodations.entity'; +import { DayNotes } from './DayNotes.entity'; +import { Days } from './Days.entity'; +import { DocumentConnections } from './DocumentConnections.entity'; +import { DocumentSyncItems } from './DocumentSyncItems.entity'; +import { InviteTokens } from './InviteTokens.entity'; +import { JourneyEntries } from './JourneyEntries.entity'; +import { JourneyTrips } from './JourneyTrips.entity'; +import { PackingBags } from './PackingBags.entity'; +import { PackingCategoryAssignees } from './PackingCategoryAssignees.entity'; +import { PackingItems } from './PackingItems.entity'; +import { Photos } from './Photos.entity'; +import { Places } from './Places.entity'; +import { Reservations } from './Reservations.entity'; +import { RoadtripDayBoundaries } from './RoadtripDayBoundaries.entity'; +import { RoadtripPreferences } from './RoadtripPreferences.entity'; +import { ShareTokens } from './ShareTokens.entity'; +import { TodoCategoryAssignees } from './TodoCategoryAssignees.entity'; +import { TodoItems } from './TodoItems.entity'; +import { TripAlbumLinks } from './TripAlbumLinks.entity'; +import { TripDocumentLinks } from './TripDocumentLinks.entity'; +import { TripFiles } from './TripFiles.entity'; +import { TripInviteTokens } from './TripInviteTokens.entity'; +import { TripMembers } from './TripMembers.entity'; +import { TripPhotos } from './TripPhotos.entity'; +import { Users } from './Users.entity'; + +export class Trips { + [EntityRepositoryType]?: TripsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + title!: string; + description?: string | null; + start_date?: string | null; + end_date?: string | null; + currency?: string | null = 'EUR'; + cover_image?: string | null; + is_archived?: number | null = 0; + reminder_days?: number | null = 3; + feed_token?: string | null; + created_at?: string | null; + updated_at?: string | null; + budget_category_order_collection = new Collection(this); + budget_items_collection = new Collection(this); + budget_settlements_collection = new Collection(this); + collab_links_collection = new Collection(this); + collab_messages_collection = new Collection(this); + collab_notes_collection = new Collection(this); + collab_polls_collection = new Collection(this); + dawarich_visit_suggestions_collection = new Collection(this); + day_accommodations_collection = new Collection(this); + day_notes_collection = new Collection(this); + days_collection = new Collection(this); + document_connections_collection = new Collection(this); + document_sync_items_collection = new Collection(this); + invite_tokens_collection = new Collection(this); + journey_entries_collection = new Collection(this); + journey_trips_collection = new Collection(this); + packing_bags_collection = new Collection(this); + packing_category_assignees_collection = new Collection(this); + packing_items_collection = new Collection(this); + photos_collection = new Collection(this); + places_collection = new Collection(this); + reservations_collection = new Collection(this); + roadtrip_day_boundaries_collection = new Collection(this); + roadtrip_preferences_collection = new Collection(this); + share_tokens_collection = new Collection(this); + todo_category_assignees_collection = new Collection(this); + todo_items_collection = new Collection(this); + trip_album_links_collection = new Collection(this); + trip_document_links_collection = new Collection(this); + trip_files_collection = new Collection(this); + trip_invite_tokens_collection = new Collection(this); + trip_members_collection = new Collection(this); + trip_photos_collection = new Collection(this); +} + +export const TripsSchema = defineEntity({ + class: Trips, + repository: () => TripsRepository, + uniques: [ + { + name: 'idx_trips_feed_token', + where: 'feed_token IS NOT NULL', + properties: ['feed_token'], + }, + ], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_trips_user_id'), + user_id: p.integer().persist(false).index('idx_trips_user_id'), + title: p.text(), + description: p.text().nullable(), + start_date: p.text().nullable(), + end_date: p.text().nullable(), + currency: p.text().nullable(), + cover_image: p.text().nullable(), + is_archived: p.integer().nullable(), + reminder_days: p.integer().nullable(), + feed_token: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`).index('idx_trips_created_at'), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + budget_category_order_collection: () => p.oneToMany(BudgetCategoryOrder).mappedBy('trip').hidden(), + budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('trip').hidden(), + budget_settlements_collection: () => p.oneToMany(BudgetSettlements).mappedBy('trip').hidden(), + collab_links_collection: () => p.oneToMany(CollabLinks).mappedBy('trip').hidden(), + collab_messages_collection: () => p.oneToMany(CollabMessages).mappedBy('trip').hidden(), + collab_notes_collection: () => p.oneToMany(CollabNotes).mappedBy('trip').hidden(), + collab_polls_collection: () => p.oneToMany(CollabPolls).mappedBy('trip').hidden(), + dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('trip').hidden(), + day_accommodations_collection: () => p.oneToMany(DayAccommodations).mappedBy('trip').hidden(), + day_notes_collection: () => p.oneToMany(DayNotes).mappedBy('trip').hidden(), + days_collection: () => p.oneToMany(Days).mappedBy('trip').hidden(), + document_connections_collection: () => p.oneToMany(DocumentConnections).mappedBy('trip').hidden(), + document_sync_items_collection: () => p.oneToMany(DocumentSyncItems).mappedBy('trip').hidden(), + invite_tokens_collection: () => p.oneToMany(InviteTokens).mappedBy('trip').hidden(), + journey_entries_collection: () => p.oneToMany(JourneyEntries).mappedBy('sourceTrip').hidden(), + journey_trips_collection: () => p.oneToMany(JourneyTrips).mappedBy('trip').hidden(), + packing_bags_collection: () => p.oneToMany(PackingBags).mappedBy('trip').hidden(), + packing_category_assignees_collection: () => p.oneToMany(PackingCategoryAssignees).mappedBy('trip').hidden(), + packing_items_collection: () => p.oneToMany(PackingItems).mappedBy('trip').hidden(), + photos_collection: () => p.oneToMany(Photos).mappedBy('trip').hidden(), + places_collection: () => p.oneToMany(Places).mappedBy('trip').hidden(), + reservations_collection: () => p.oneToMany(Reservations).mappedBy('trip').hidden(), + roadtrip_day_boundaries_collection: () => p.oneToMany(RoadtripDayBoundaries).mappedBy('trip').hidden(), + roadtrip_preferences_collection: () => p.oneToMany(RoadtripPreferences).mappedBy('trip').hidden(), + share_tokens_collection: () => p.oneToMany(ShareTokens).mappedBy('trip').hidden(), + todo_category_assignees_collection: () => p.oneToMany(TodoCategoryAssignees).mappedBy('trip').hidden(), + todo_items_collection: () => p.oneToMany(TodoItems).mappedBy('trip').hidden(), + trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('trip').hidden(), + trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('trip').hidden(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('trip').hidden(), + trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('trip').hidden(), + trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('trip').hidden(), + trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('trip').hidden(), + }, +}); diff --git a/server/src/db/entities/UserNoticeDismissals.entity.ts b/server/src/db/entities/UserNoticeDismissals.entity.ts new file mode 100644 index 000000000..b70ee9d95 --- /dev/null +++ b/server/src/db/entities/UserNoticeDismissals.entity.ts @@ -0,0 +1,26 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { UserNoticeDismissalsRepository } from '../repositories/UserNoticeDismissals.repository'; +import { Users } from './Users.entity'; + +export class UserNoticeDismissals { + [EntityRepositoryType]?: UserNoticeDismissalsRepository; + [PrimaryKeyProp]?: ['user', 'notice_id']; + user!: Ref; + user_id!: number; + notice_id!: string; + dismissed_at!: number; + dismissed_app_version?: string | null; +} + +export const UserNoticeDismissalsSchema = defineEntity({ + class: UserNoticeDismissals, + repository: () => UserNoticeDismissalsRepository, + uniques: [{ properties: ['user', 'notice_id'] }], + properties: { + user: () => p.manyToOne(Users).primary().ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + notice_id: p.text().primary(), + dismissed_at: p.integer(), + dismissed_app_version: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/Users.entity.ts b/server/src/db/entities/Users.entity.ts new file mode 100644 index 000000000..572eacbd7 --- /dev/null +++ b/server/src/db/entities/Users.entity.ts @@ -0,0 +1,333 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { UsersRepository } from '../repositories/Users.repository'; +import { DbTimestampType } from '../types'; +import { AssignmentParticipants } from './AssignmentParticipants.entity'; +import { AuditLog } from './AuditLog.entity'; +import { BucketList } from './BucketList.entity'; +import { BudgetItemMembers } from './BudgetItemMembers.entity'; +import { BudgetItemPayers } from './BudgetItemPayers.entity'; +import { BudgetItems } from './BudgetItems.entity'; +import { BudgetSettlements } from './BudgetSettlements.entity'; +import { Categories } from './Categories.entity'; +import { CollabLinks } from './CollabLinks.entity'; +import { CollabMessageReactions } from './CollabMessageReactions.entity'; +import { CollabMessages } from './CollabMessages.entity'; +import { CollabNotes } from './CollabNotes.entity'; +import { CollabPollVotes } from './CollabPollVotes.entity'; +import { CollabPolls } from './CollabPolls.entity'; +import { CollectionMembers } from './CollectionMembers.entity'; +import { CollectionPlaceRatings } from './CollectionPlaceRatings.entity'; +import { CollectionPlaces } from './CollectionPlaces.entity'; +import { Collections } from './Collections.entity'; +import { DawarichConnections } from './DawarichConnections.entity'; +import { DawarichVisitSuggestions } from './DawarichVisitSuggestions.entity'; +import { DocumentConnections } from './DocumentConnections.entity'; +import { HiddenCountries } from './HiddenCountries.entity'; +import { HiddenRegions } from './HiddenRegions.entity'; +import { IdempotencyKeys } from './IdempotencyKeys.entity'; +import { InviteTokens } from './InviteTokens.entity'; +import { JourneyBooks } from './JourneyBooks.entity'; +import { JourneyContributors } from './JourneyContributors.entity'; +import { JourneyEntries } from './JourneyEntries.entity'; +import { JourneyShareTokens } from './JourneyShareTokens.entity'; +import { Journeys } from './Journeys.entity'; +import { McpTokens } from './McpTokens.entity'; +import { NotificationChannelPreferences } from './NotificationChannelPreferences.entity'; +import { Notifications } from './Notifications.entity'; +import { OauthClients } from './OauthClients.entity'; +import { OauthConsents } from './OauthConsents.entity'; +import { OauthTokens } from './OauthTokens.entity'; +import { PackingBags } from './PackingBags.entity'; +import { PackingCategoryAssignees } from './PackingCategoryAssignees.entity'; +import { PackingItemContributors } from './PackingItemContributors.entity'; +import { PackingItems } from './PackingItems.entity'; +import { PackingTemplates } from './PackingTemplates.entity'; +import { PasswordResetTokens } from './PasswordResetTokens.entity'; +import { PlaceRatings } from './PlaceRatings.entity'; +import { PushSubscriptions } from './PushSubscriptions.entity'; +import { ReservationTravelers } from './ReservationTravelers.entity'; +import { Settings } from './Settings.entity'; +import { ShareTokens } from './ShareTokens.entity'; +import { Tags } from './Tags.entity'; +import { TodoCategoryAssignees } from './TodoCategoryAssignees.entity'; +import { TodoItems } from './TodoItems.entity'; +import { TrekPhotos } from './TrekPhotos.entity'; +import { TripAlbumLinks } from './TripAlbumLinks.entity'; +import { TripDocumentLinks } from './TripDocumentLinks.entity'; +import { TripFiles } from './TripFiles.entity'; +import { TripInviteTokens } from './TripInviteTokens.entity'; +import { TripMembers } from './TripMembers.entity'; +import { TripPhotos } from './TripPhotos.entity'; +import { Trips } from './Trips.entity'; +import { UserNoticeDismissals } from './UserNoticeDismissals.entity'; +import { VacayEntries } from './VacayEntries.entity'; +import { VacayPlanMembers } from './VacayPlanMembers.entity'; +import { VacayPlans } from './VacayPlans.entity'; +import { VacayShares } from './VacayShares.entity'; +import { VacayUserColors } from './VacayUserColors.entity'; +import { VacayUserSettings } from './VacayUserSettings.entity'; +import { VacayUserYears } from './VacayUserYears.entity'; +import { VisitedCountries } from './VisitedCountries.entity'; +import { VisitedRegions } from './VisitedRegions.entity'; +import { WebauthnChallenges } from './WebauthnChallenges.entity'; +import { WebauthnCredentials } from './WebauthnCredentials.entity'; + +export class Users { + [EntityRepositoryType]?: UsersRepository; + id!: number & Opt; + username!: string; + email!: string; + password_hash!: string; + role: string & Opt = 'user'; + maps_api_key?: string | null; + unsplash_api_key?: string | null; + amap_api_key?: string | null; + openweather_api_key?: string | null; + avatar?: string | null; + oidc_sub?: string | null; + oidc_issuer?: string | null; + last_login?: string | null; + mfa_enabled?: number | null = 0; + mfa_secret?: string | null; + mfa_backup_codes?: string | null; + immich_url?: string | null; + immich_access_token?: string | null; + synology_url?: string | null; + synology_username?: string | null; + synology_password?: string | null; + synology_sid?: string | null; + must_change_password?: number | null = 0; + password_version: number & Opt = 0; + feed_token?: string | null; + is_guest: number & Opt = 0; + created_at?: string | null; + updated_at?: string | null; + immich_api_key?: string | null; + synology_skip_ssl: number & Opt = 0; + synology_did?: string | null; + first_seen_version: string & Opt = '0.0.0'; + login_count: number & Opt = 0; + immich_auto_upload: number & Opt = 0; + airtrail_url?: string | null; + airtrail_api_key?: string | null; + airtrail_allow_insecure_tls?: number | null = 0; + airtrail_write_enabled?: number | null = 0; + display_name?: string | null; + immich_allow_insecure_tls: number & Opt = 0; + assignment_participants_collection = new Collection(this); + audit_log_collection = new Collection(this); + bucket_list_collection = new Collection(this); + budget_item_members_collection = new Collection(this); + budget_item_payers_collection = new Collection(this); + budget_items_collection = new Collection(this); + budget_settlements_collection = new Collection(this); + budget_settlements_collection1 = new Collection(this); + budget_settlements_collection2 = new Collection(this); + categories_collection = new Collection(this); + collab_links_collection = new Collection(this); + collab_message_reactions_collection = new Collection(this); + collab_messages_collection = new Collection(this); + collab_notes_collection = new Collection(this); + collab_poll_votes_collection = new Collection(this); + collab_polls_collection = new Collection(this); + collection_members_collection = new Collection(this); + collection_place_ratings_collection = new Collection(this); + collection_places_collection = new Collection(this); + collection_places_collection1 = new Collection(this); + collections_collection = new Collection(this); + dawarich_connections: Ref | null = null; + dawarich_visit_suggestions_collection = new Collection(this); + document_connections_collection = new Collection(this); + hidden_countries_collection = new Collection(this); + hidden_regions_collection = new Collection(this); + idempotency_keys_collection = new Collection(this); + invite_tokens_collection = new Collection(this); + journey_books_collection = new Collection(this); + journey_books_collection1 = new Collection(this); + journey_contributors_collection = new Collection(this); + journey_entries_collection = new Collection(this); + journey_share_tokens_collection = new Collection(this); + journeys_collection = new Collection(this); + mcp_tokens_collection = new Collection(this); + notification_channel_preferences_collection = new Collection(this); + notifications_collection = new Collection(this); + notifications_collection1 = new Collection(this); + oauth_clients_collection = new Collection(this); + oauth_consents_collection = new Collection(this); + oauth_tokens_collection = new Collection(this); + packing_bags_collection = new Collection(this); + packing_bag_members_inverse = new Collection(this); + packing_category_assignees_collection = new Collection(this); + packing_item_contributors_collection = new Collection(this); + packing_items_collection = new Collection(this); + packing_item_contributors_inverse = new Collection(this); + packing_item_recipients_inverse = new Collection(this); + packing_templates_collection = new Collection(this); + password_reset_tokens_collection = new Collection(this); + place_ratings_collection = new Collection(this); + push_subscriptions_collection = new Collection(this); + reservation_travelers_collection = new Collection(this); + settings_collection = new Collection(this); + share_tokens_collection = new Collection(this); + tags_collection = new Collection(this); + todo_category_assignees_collection = new Collection(this); + todo_items_collection = new Collection(this); + trek_photos_collection = new Collection(this); + trip_album_links_collection = new Collection(this); + trip_document_links_collection = new Collection(this); + trip_files_collection = new Collection(this); + trip_invite_tokens_collection = new Collection(this); + trip_members_collection = new Collection(this); + trip_members_collection1 = new Collection(this); + trip_photos_collection = new Collection(this); + trips_collection = new Collection(this); + user_notice_dismissals_collection = new Collection(this); + vacay_entries_collection = new Collection(this); + vacay_plan_members_collection = new Collection(this); + vacay_plans_collection = new Collection(this); + vacay_shares_collection = new Collection(this); + vacay_shares_collection1 = new Collection(this); + vacay_user_colors_collection = new Collection(this); + vacay_user_settings: Ref | null = null; + vacay_user_years_collection = new Collection(this); + visited_countries_collection = new Collection(this); + visited_regions_collection = new Collection(this); + webauthn_challenges_collection = new Collection(this); + webauthn_credentials_collection = new Collection(this); +} + +export const UsersSchema = defineEntity({ + class: Users, + repository: () => UsersRepository, + uniques: [ + { + name: 'idx_users_feed_token', + where: 'feed_token IS NOT NULL', + properties: ['feed_token'], + }, + { properties: ['email'] }, + { properties: ['username'] }, + ], + properties: { + id: p.integer().primary(), + username: p.text(), + email: p.text().index('idx_users_email'), + password_hash: p.text(), + role: p.text().default('user'), + maps_api_key: p.text().nullable(), + unsplash_api_key: p.text().nullable(), + amap_api_key: p.text().nullable(), + openweather_api_key: p.text().nullable(), + avatar: p.text().nullable(), + oidc_sub: p.text().nullable(), + oidc_issuer: p.text().nullable(), + last_login: p.type(DbTimestampType).nullable(), + mfa_enabled: p.integer().nullable(), + mfa_secret: p.text().nullable(), + mfa_backup_codes: p.text().nullable(), + immich_url: p.text().nullable(), + immich_access_token: p.text().nullable(), + synology_url: p.text().nullable(), + synology_username: p.text().nullable(), + synology_password: p.text().nullable(), + synology_sid: p.text().nullable(), + must_change_password: p.integer().nullable(), + password_version: p.integer().default(0), + feed_token: p.text().nullable(), + is_guest: p.integer().default(0), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + immich_api_key: p.text().nullable(), + synology_skip_ssl: p.integer().default(0), + synology_did: p.text().nullable(), + first_seen_version: p.text().default('0.0.0'), + login_count: p.integer().default(0), + immich_auto_upload: p.integer().default(0), + airtrail_url: p.text().nullable(), + airtrail_api_key: p.text().nullable(), + airtrail_allow_insecure_tls: p.integer().nullable(), + airtrail_write_enabled: p.integer().nullable(), + display_name: p.text().nullable(), + immich_allow_insecure_tls: p.integer().default(0), + assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('user').hidden(), + audit_log_collection: () => p.oneToMany(AuditLog).mappedBy('user').hidden(), + bucket_list_collection: () => p.oneToMany(BucketList).mappedBy('user').hidden(), + budget_item_members_collection: () => p.oneToMany(BudgetItemMembers).mappedBy('user').hidden(), + budget_item_payers_collection: () => p.oneToMany(BudgetItemPayers).mappedBy('user').hidden(), + budget_items_collection: () => p.oneToMany(BudgetItems).mappedBy('paidByUser').hidden(), + budget_settlements_collection: () => p.oneToMany(BudgetSettlements).mappedBy('fromUser').hidden(), + budget_settlements_collection1: () => p.oneToMany(BudgetSettlements).mappedBy('toUser').hidden(), + budget_settlements_collection2: () => p.oneToMany(BudgetSettlements).mappedBy('createdByUser').hidden(), + categories_collection: () => p.oneToMany(Categories).mappedBy('user').hidden(), + collab_links_collection: () => p.oneToMany(CollabLinks).mappedBy('user').hidden(), + collab_message_reactions_collection: () => p.oneToMany(CollabMessageReactions).mappedBy('user').hidden(), + collab_messages_collection: () => p.oneToMany(CollabMessages).mappedBy('user').hidden(), + collab_notes_collection: () => p.oneToMany(CollabNotes).mappedBy('user').hidden(), + collab_poll_votes_collection: () => p.oneToMany(CollabPollVotes).mappedBy('user').hidden(), + collab_polls_collection: () => p.oneToMany(CollabPolls).mappedBy('user').hidden(), + collection_members_collection: () => p.oneToMany(CollectionMembers).mappedBy('user').hidden(), + collection_place_ratings_collection: () => p.oneToMany(CollectionPlaceRatings).mappedBy('user').hidden(), + collection_places_collection: () => p.oneToMany(CollectionPlaces).mappedBy('owner').hidden(), + collection_places_collection1: () => p.oneToMany(CollectionPlaces).mappedBy('savedByRef').hidden(), + collections_collection: () => p.oneToMany(Collections).mappedBy('owner').hidden(), + dawarich_connections: () => p.oneToOne(DawarichConnections).ref().mappedBy('user').hidden(), + dawarich_visit_suggestions_collection: () => p.oneToMany(DawarichVisitSuggestions).mappedBy('user').hidden(), + document_connections_collection: () => p.oneToMany(DocumentConnections).mappedBy('ownerUser').hidden(), + hidden_countries_collection: () => p.oneToMany(HiddenCountries).mappedBy('user').hidden(), + hidden_regions_collection: () => p.oneToMany(HiddenRegions).mappedBy('user').hidden(), + idempotency_keys_collection: () => p.oneToMany(IdempotencyKeys).mappedBy('user').hidden(), + invite_tokens_collection: () => p.oneToMany(InviteTokens).mappedBy('createdByRef').hidden(), + journey_books_collection: () => p.oneToMany(JourneyBooks).mappedBy('updatedByRef').hidden(), + journey_books_collection1: () => p.oneToMany(JourneyBooks).mappedBy('createdByRef').hidden(), + journey_contributors_collection: () => p.oneToMany(JourneyContributors).mappedBy('user').hidden(), + journey_entries_collection: () => p.oneToMany(JourneyEntries).mappedBy('author').hidden(), + journey_share_tokens_collection: () => p.oneToMany(JourneyShareTokens).mappedBy('createdByRef').hidden(), + journeys_collection: () => p.oneToMany(Journeys).mappedBy('user').hidden(), + mcp_tokens_collection: () => p.oneToMany(McpTokens).mappedBy('user').hidden(), + notification_channel_preferences_collection: () => p.oneToMany(NotificationChannelPreferences).mappedBy('user').hidden(), + notifications_collection: () => p.oneToMany(Notifications).mappedBy('sender').hidden(), + notifications_collection1: () => p.oneToMany(Notifications).mappedBy('recipient').hidden(), + oauth_clients_collection: () => p.oneToMany(OauthClients).mappedBy('user').hidden(), + oauth_consents_collection: () => p.oneToMany(OauthConsents).mappedBy('user').hidden(), + oauth_tokens_collection: () => p.oneToMany(OauthTokens).mappedBy('user').hidden(), + packing_bags_collection: () => p.oneToMany(PackingBags).mappedBy('user').hidden(), + packing_bag_members_inverse: () => p.manyToMany(PackingBags).mappedBy('packing_bag_members').hidden(), + packing_category_assignees_collection: () => p.oneToMany(PackingCategoryAssignees).mappedBy('user').hidden(), + packing_item_contributors_collection: () => p.oneToMany(PackingItemContributors).mappedBy('user').hidden(), + packing_items_collection: () => p.oneToMany(PackingItems).mappedBy('owner').hidden(), + packing_item_contributors_inverse: () => p.manyToMany(PackingItems).mappedBy('packing_item_contributors').hidden(), + packing_item_recipients_inverse: () => p.manyToMany(PackingItems).mappedBy('packing_item_recipients').hidden(), + packing_templates_collection: () => p.oneToMany(PackingTemplates).mappedBy('createdByRef').hidden(), + password_reset_tokens_collection: () => p.oneToMany(PasswordResetTokens).mappedBy('user').hidden(), + place_ratings_collection: () => p.oneToMany(PlaceRatings).mappedBy('user').hidden(), + push_subscriptions_collection: () => p.oneToMany(PushSubscriptions).mappedBy('user').hidden(), + reservation_travelers_collection: () => p.oneToMany(ReservationTravelers).mappedBy('user').hidden(), + settings_collection: () => p.oneToMany(Settings).mappedBy('user').hidden(), + share_tokens_collection: () => p.oneToMany(ShareTokens).mappedBy('createdByRef').hidden(), + tags_collection: () => p.oneToMany(Tags).mappedBy('user').hidden(), + todo_category_assignees_collection: () => p.oneToMany(TodoCategoryAssignees).mappedBy('user').hidden(), + todo_items_collection: () => p.oneToMany(TodoItems).mappedBy('assignedUser').hidden(), + trek_photos_collection: () => p.oneToMany(TrekPhotos).mappedBy('owner').hidden(), + trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('user').hidden(), + trip_document_links_collection: () => p.oneToMany(TripDocumentLinks).mappedBy('createdByRef').hidden(), + trip_files_collection: () => p.oneToMany(TripFiles).mappedBy('uploadedByRef').hidden(), + trip_invite_tokens_collection: () => p.oneToMany(TripInviteTokens).mappedBy('createdByRef').hidden(), + trip_members_collection: () => p.oneToMany(TripMembers).mappedBy('user').hidden(), + trip_members_collection1: () => p.oneToMany(TripMembers).mappedBy('invitedByRef').hidden(), + trip_photos_collection: () => p.oneToMany(TripPhotos).mappedBy('user').hidden(), + trips_collection: () => p.oneToMany(Trips).mappedBy('user').hidden(), + user_notice_dismissals_collection: () => p.oneToMany(UserNoticeDismissals).mappedBy('user').hidden(), + vacay_entries_collection: () => p.oneToMany(VacayEntries).mappedBy('user').hidden(), + vacay_plan_members_collection: () => p.oneToMany(VacayPlanMembers).mappedBy('user').hidden(), + vacay_plans_collection: () => p.oneToMany(VacayPlans).mappedBy('owner').hidden(), + vacay_shares_collection: () => p.oneToMany(VacayShares).mappedBy('owner').hidden(), + vacay_shares_collection1: () => p.oneToMany(VacayShares).mappedBy('user').hidden(), + vacay_user_colors_collection: () => p.oneToMany(VacayUserColors).mappedBy('user').hidden(), + vacay_user_settings: () => p.oneToOne(VacayUserSettings).ref().mappedBy('user').hidden(), + vacay_user_years_collection: () => p.oneToMany(VacayUserYears).mappedBy('user').hidden(), + visited_countries_collection: () => p.oneToMany(VisitedCountries).mappedBy('user').hidden(), + visited_regions_collection: () => p.oneToMany(VisitedRegions).mappedBy('user').hidden(), + webauthn_challenges_collection: () => p.oneToMany(WebauthnChallenges).mappedBy('user').hidden(), + webauthn_credentials_collection: () => p.oneToMany(WebauthnCredentials).mappedBy('user').hidden(), + }, +}); diff --git a/server/src/db/entities/VacayCompanyHolidays.entity.ts b/server/src/db/entities/VacayCompanyHolidays.entity.ts new file mode 100644 index 000000000..97b7f7716 --- /dev/null +++ b/server/src/db/entities/VacayCompanyHolidays.entity.ts @@ -0,0 +1,27 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayCompanyHolidaysRepository } from '../repositories/VacayCompanyHolidays.repository'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayCompanyHolidays { + [EntityRepositoryType]?: VacayCompanyHolidaysRepository; + id!: number & Opt; + plan!: Ref; + plan_id!: number; + date!: string; + note?: string | null = ''; + fraction!: number & Opt; +} + +export const VacayCompanyHolidaysSchema = defineEntity({ + class: VacayCompanyHolidays, + repository: () => VacayCompanyHolidaysRepository, + uniques: [{ properties: ['plan', 'date'] }], + properties: { + id: p.integer().primary(), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + date: p.text(), + note: p.text().nullable(), + fraction: p.double().defaultRaw(`1`), + }, +}); diff --git a/server/src/db/entities/VacayEntries.entity.ts b/server/src/db/entities/VacayEntries.entity.ts new file mode 100644 index 000000000..75260d407 --- /dev/null +++ b/server/src/db/entities/VacayEntries.entity.ts @@ -0,0 +1,34 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayEntriesRepository } from '../repositories/VacayEntries.repository'; +import { Users } from './Users.entity'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayEntries { + [EntityRepositoryType]?: VacayEntriesRepository; + id!: number & Opt; + plan!: Ref; + plan_id!: number; + user!: Ref; + user_id!: number; + date!: string; + note?: string | null = ''; + fraction!: number & Opt; + kind: string & Opt = 'vacation'; +} + +export const VacayEntriesSchema = defineEntity({ + class: VacayEntries, + repository: () => VacayEntriesRepository, + uniques: [{ properties: ['user', 'plan', 'date'] }], + properties: { + id: p.integer().primary(), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + date: p.text(), + note: p.text().nullable(), + fraction: p.double().defaultRaw(`1`), + kind: p.text().default('vacation'), + }, +}); diff --git a/server/src/db/entities/VacayHolidayCalendars.entity.ts b/server/src/db/entities/VacayHolidayCalendars.entity.ts new file mode 100644 index 000000000..67e9e2af4 --- /dev/null +++ b/server/src/db/entities/VacayHolidayCalendars.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayHolidayCalendarsRepository } from '../repositories/VacayHolidayCalendars.repository'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayHolidayCalendars { + [EntityRepositoryType]?: VacayHolidayCalendarsRepository; + id!: number & Opt; + plan!: Ref; + plan_id!: number; + type: string & Opt = 'public_holiday'; + region!: string; + label?: string | null; + color: string & Opt = '#fecaca'; + sort_order: number & Opt = 0; +} + +export const VacayHolidayCalendarsSchema = defineEntity({ + class: VacayHolidayCalendars, + repository: () => VacayHolidayCalendarsRepository, + properties: { + id: p.integer().primary(), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + type: p.text().default('public_holiday'), + region: p.text(), + label: p.text().nullable(), + color: p.text().default('#fecaca'), + sort_order: p.integer().default(0), + }, +}); diff --git a/server/src/db/entities/VacayPlanMembers.entity.ts b/server/src/db/entities/VacayPlanMembers.entity.ts new file mode 100644 index 000000000..f211ed439 --- /dev/null +++ b/server/src/db/entities/VacayPlanMembers.entity.ts @@ -0,0 +1,31 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayPlanMembersRepository } from '../repositories/VacayPlanMembers.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayPlanMembers { + [EntityRepositoryType]?: VacayPlanMembersRepository; + id!: number & Opt; + plan!: Ref; + plan_id!: number; + user!: Ref; + user_id!: number; + status?: string | null = 'pending'; + created_at?: string | null; +} + +export const VacayPlanMembersSchema = defineEntity({ + class: VacayPlanMembers, + repository: () => VacayPlanMembersRepository, + uniques: [{ properties: ['plan', 'user'] }], + properties: { + id: p.integer().primary(), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + status: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/VacayPlans.entity.ts b/server/src/db/entities/VacayPlans.entity.ts new file mode 100644 index 000000000..86e25320b --- /dev/null +++ b/server/src/db/entities/VacayPlans.entity.ts @@ -0,0 +1,61 @@ +import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayPlansRepository } from '../repositories/VacayPlans.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; +import { VacayCompanyHolidays } from './VacayCompanyHolidays.entity'; +import { VacayEntries } from './VacayEntries.entity'; +import { VacayHolidayCalendars } from './VacayHolidayCalendars.entity'; +import { VacayPlanMembers } from './VacayPlanMembers.entity'; +import { VacayUserColors } from './VacayUserColors.entity'; +import { VacayUserYears } from './VacayUserYears.entity'; +import { VacayYears } from './VacayYears.entity'; + +export class VacayPlans { + [EntityRepositoryType]?: VacayPlansRepository; + id!: number & Opt; + owner!: Ref; + owner_id!: number; + block_weekends?: number | null = 1; + holidays_enabled?: number | null = 0; + holidays_region?: string | null = ''; + school_holidays_enabled?: number | null = 0; + company_holidays_enabled?: number | null = 1; + carry_over_enabled?: number | null = 1; + created_at?: string | null; + weekend_days?: string | null = '0,6'; + week_start: number & Opt = 1; + vacay_company_holidays_collection = new Collection(this); + vacay_entries_collection = new Collection(this); + vacay_holiday_calendars_collection = new Collection(this); + vacay_plan_members_collection = new Collection(this); + vacay_user_colors_collection = new Collection(this); + vacay_user_years_collection = new Collection(this); + vacay_years_collection = new Collection(this); +} + +export const VacayPlansSchema = defineEntity({ + class: VacayPlans, + repository: () => VacayPlansRepository, + uniques: [{ properties: ['owner'] }], + properties: { + id: p.integer().primary(), + owner: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + owner_id: p.integer().persist(false), + block_weekends: p.integer().nullable(), + holidays_enabled: p.integer().nullable(), + holidays_region: p.text().nullable(), + school_holidays_enabled: p.integer().nullable(), + company_holidays_enabled: p.integer().nullable(), + carry_over_enabled: p.integer().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + weekend_days: p.text().nullable(), + week_start: p.integer().default(1), + vacay_company_holidays_collection: () => p.oneToMany(VacayCompanyHolidays).mappedBy('plan').hidden(), + vacay_entries_collection: () => p.oneToMany(VacayEntries).mappedBy('plan').hidden(), + vacay_holiday_calendars_collection: () => p.oneToMany(VacayHolidayCalendars).mappedBy('plan').hidden(), + vacay_plan_members_collection: () => p.oneToMany(VacayPlanMembers).mappedBy('plan').hidden(), + vacay_user_colors_collection: () => p.oneToMany(VacayUserColors).mappedBy('plan').hidden(), + vacay_user_years_collection: () => p.oneToMany(VacayUserYears).mappedBy('plan').hidden(), + vacay_years_collection: () => p.oneToMany(VacayYears).mappedBy('plan').hidden(), + }, +}); diff --git a/server/src/db/entities/VacayShares.entity.ts b/server/src/db/entities/VacayShares.entity.ts new file mode 100644 index 000000000..e12ae50f3 --- /dev/null +++ b/server/src/db/entities/VacayShares.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacaySharesRepository } from '../repositories/VacayShares.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class VacayShares { + [EntityRepositoryType]?: VacaySharesRepository; + id!: number & Opt; + owner!: Ref; + owner_id!: number; + user!: Ref; + user_id!: number; + hidden: number & Opt = 0; + created_at?: string | null; +} + +export const VacaySharesSchema = defineEntity({ + class: VacayShares, + repository: () => VacaySharesRepository, + uniques: [{ properties: ['owner', 'user'] }], + properties: { + id: p.integer().primary(), + owner: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + owner_id: p.integer().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_vacay_shares_user'), + user_id: p.integer().persist(false).index('idx_vacay_shares_user'), + hidden: p.integer().default(0), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/VacayUserColors.entity.ts b/server/src/db/entities/VacayUserColors.entity.ts new file mode 100644 index 000000000..f251f3b60 --- /dev/null +++ b/server/src/db/entities/VacayUserColors.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayUserColorsRepository } from '../repositories/VacayUserColors.repository'; +import { Users } from './Users.entity'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayUserColors { + [EntityRepositoryType]?: VacayUserColorsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + plan!: Ref; + plan_id!: number; + color?: string | null = '#6366f1'; +} + +export const VacayUserColorsSchema = defineEntity({ + class: VacayUserColors, + repository: () => VacayUserColorsRepository, + uniques: [{ properties: ['user', 'plan'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + color: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/VacayUserSettings.entity.ts b/server/src/db/entities/VacayUserSettings.entity.ts new file mode 100644 index 000000000..b5260e1a0 --- /dev/null +++ b/server/src/db/entities/VacayUserSettings.entity.ts @@ -0,0 +1,27 @@ +import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayUserSettingsRepository } from '../repositories/VacayUserSettings.repository'; +import { Users } from './Users.entity'; + +export class VacayUserSettings { + [EntityRepositoryType]?: VacayUserSettingsRepository; + [PrimaryKeyProp]?: 'user'; + user?: Ref | null; + user_id?: number | null; + year_type: string & Opt = 'calendar'; + year_start_month: number & Opt = 1; + year_start_day: number & Opt = 1; + hire_date?: string | null; +} + +export const VacayUserSettingsSchema = defineEntity({ + class: VacayUserSettings, + repository: () => VacayUserSettingsRepository, + properties: { + user: () => p.oneToOne(Users).primary().ref().deleteRule('cascade').nullable().hidden(), + user_id: p.integer().nullable().persist(false), + year_type: p.text().default('calendar'), + year_start_month: p.integer().default(1), + year_start_day: p.integer().default(1), + hire_date: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/VacayUserYears.entity.ts b/server/src/db/entities/VacayUserYears.entity.ts new file mode 100644 index 000000000..5bc9b4569 --- /dev/null +++ b/server/src/db/entities/VacayUserYears.entity.ts @@ -0,0 +1,32 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayUserYearsRepository } from '../repositories/VacayUserYears.repository'; +import { Users } from './Users.entity'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayUserYears { + [EntityRepositoryType]?: VacayUserYearsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + plan!: Ref; + plan_id!: number; + year!: number; + vacation_days?: number | null = 30; + carried_over?: number | null = 0; +} + +export const VacayUserYearsSchema = defineEntity({ + class: VacayUserYears, + repository: () => VacayUserYearsRepository, + uniques: [{ properties: ['user', 'plan', 'year'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + year: p.integer(), + vacation_days: p.integer().nullable(), + carried_over: p.integer().nullable(), + }, +}); diff --git a/server/src/db/entities/VacayYears.entity.ts b/server/src/db/entities/VacayYears.entity.ts new file mode 100644 index 000000000..d11c5873d --- /dev/null +++ b/server/src/db/entities/VacayYears.entity.ts @@ -0,0 +1,23 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VacayYearsRepository } from '../repositories/VacayYears.repository'; +import { VacayPlans } from './VacayPlans.entity'; + +export class VacayYears { + [EntityRepositoryType]?: VacayYearsRepository; + id!: number & Opt; + plan!: Ref; + plan_id!: number; + year!: number; +} + +export const VacayYearsSchema = defineEntity({ + class: VacayYears, + repository: () => VacayYearsRepository, + uniques: [{ properties: ['plan', 'year'] }], + properties: { + id: p.integer().primary(), + plan: () => p.manyToOne(VacayPlans).ref().deleteRule('cascade').hidden(), + plan_id: p.integer().persist(false), + year: p.integer(), + }, +}); diff --git a/server/src/db/entities/VisitedCountries.entity.ts b/server/src/db/entities/VisitedCountries.entity.ts new file mode 100644 index 000000000..e2426c81d --- /dev/null +++ b/server/src/db/entities/VisitedCountries.entity.ts @@ -0,0 +1,28 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VisitedCountriesRepository } from '../repositories/VisitedCountries.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class VisitedCountries { + [EntityRepositoryType]?: VisitedCountriesRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + country_code!: string; + created_at?: string | null; + source: string & Opt = 'manual'; +} + +export const VisitedCountriesSchema = defineEntity({ + class: VisitedCountries, + repository: () => VisitedCountriesRepository, + uniques: [{ properties: ['user', 'country_code'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + country_code: p.text(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + source: p.text().default('manual'), + }, +}); diff --git a/server/src/db/entities/VisitedRegions.entity.ts b/server/src/db/entities/VisitedRegions.entity.ts new file mode 100644 index 000000000..b311d0b1f --- /dev/null +++ b/server/src/db/entities/VisitedRegions.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { VisitedRegionsRepository } from '../repositories/VisitedRegions.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class VisitedRegions { + [EntityRepositoryType]?: VisitedRegionsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + region_code!: string; + region_name!: string; + country_code!: string; + created_at?: string | null; +} + +export const VisitedRegionsSchema = defineEntity({ + class: VisitedRegions, + repository: () => VisitedRegionsRepository, + uniques: [{ properties: ['user', 'region_code'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden(), + user_id: p.integer().persist(false), + region_code: p.text(), + region_name: p.text(), + country_code: p.text().index('idx_visited_regions_country'), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/WebauthnChallenges.entity.ts b/server/src/db/entities/WebauthnChallenges.entity.ts new file mode 100644 index 000000000..9b677277b --- /dev/null +++ b/server/src/db/entities/WebauthnChallenges.entity.ts @@ -0,0 +1,30 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { WebauthnChallengesRepository } from '../repositories/WebauthnChallenges.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class WebauthnChallenges { + [EntityRepositoryType]?: WebauthnChallengesRepository; + id!: number & Opt; + challenge!: string; + user?: Ref | null; + user_id?: number | null; + type!: string; + expires_at!: number; + created_at?: string | null; +} + +export const WebauthnChallengesSchema = defineEntity({ + class: WebauthnChallenges, + repository: () => WebauthnChallengesRepository, + uniques: [{ properties: ['challenge'] }], + properties: { + id: p.integer().primary(), + challenge: p.text(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').nullable().hidden(), + user_id: p.integer().nullable().persist(false), + type: p.text(), + expires_at: p.integer().index('idx_webauthn_challenges_expires'), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + }, +}); diff --git a/server/src/db/entities/WebauthnCredentials.entity.ts b/server/src/db/entities/WebauthnCredentials.entity.ts new file mode 100644 index 000000000..2a1fa27ab --- /dev/null +++ b/server/src/db/entities/WebauthnCredentials.entity.ts @@ -0,0 +1,42 @@ +import { EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { WebauthnCredentialsRepository } from '../repositories/WebauthnCredentials.repository'; +import { DbTimestampType } from '../types'; +import { Users } from './Users.entity'; + +export class WebauthnCredentials { + [EntityRepositoryType]?: WebauthnCredentialsRepository; + id!: number & Opt; + user!: Ref; + user_id!: number; + credential_id!: string; + public_key!: Buffer; + counter: number & Opt = 0; + transports?: string | null; + device_type?: string | null; + backed_up: number & Opt = 0; + name?: string | null; + aaguid?: string | null; + created_at?: string | null; + last_used_at?: string | null; +} + +export const WebauthnCredentialsSchema = defineEntity({ + class: WebauthnCredentials, + repository: () => WebauthnCredentialsRepository, + uniques: [{ properties: ['credential_id'] }], + properties: { + id: p.integer().primary(), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_webauthn_credentials_user'), + user_id: p.integer().persist(false).index('idx_webauthn_credentials_user'), + credential_id: p.text(), + public_key: p.blob(), + counter: p.integer().default(0), + transports: p.text().nullable(), + device_type: p.text().nullable(), + backed_up: p.integer().default(0), + name: p.text().nullable(), + aaguid: p.text().nullable(), + created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + last_used_at: p.type(DbTimestampType).nullable(), + }, +}); diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts new file mode 100644 index 000000000..60958f950 --- /dev/null +++ b/server/src/db/entities/index.ts @@ -0,0 +1,264 @@ +import type { EntitySchema } from '@mikro-orm/core'; +import { AddonsSchema } from './Addons.entity'; +import { AppSettingsSchema } from './AppSettings.entity'; +import { AssignmentParticipantsSchema } from './AssignmentParticipants.entity'; +import { AuditLogSchema } from './AuditLog.entity'; +import { BucketListSchema } from './BucketList.entity'; +import { BudgetCategoryOrderSchema } from './BudgetCategoryOrder.entity'; +import { BudgetItemMembersSchema } from './BudgetItemMembers.entity'; +import { BudgetItemPayersSchema } from './BudgetItemPayers.entity'; +import { BudgetItemsSchema } from './BudgetItems.entity'; +import { BudgetSettlementsSchema } from './BudgetSettlements.entity'; +import { CategoriesSchema } from './Categories.entity'; +import { CollabLinksSchema } from './CollabLinks.entity'; +import { CollabMessageReactionsSchema } from './CollabMessageReactions.entity'; +import { CollabMessagesSchema } from './CollabMessages.entity'; +import { CollabNotesSchema } from './CollabNotes.entity'; +import { CollabPollVotesSchema } from './CollabPollVotes.entity'; +import { CollabPollsSchema } from './CollabPolls.entity'; +import { CollectionLabelsSchema } from './CollectionLabels.entity'; +import { CollectionMembersSchema } from './CollectionMembers.entity'; +import { CollectionPlaceRatingsSchema } from './CollectionPlaceRatings.entity'; +import { CollectionPlacesSchema } from './CollectionPlaces.entity'; +import { CollectionsSchema } from './Collections.entity'; +import { DawarichConnectionsSchema } from './DawarichConnections.entity'; +import { DawarichVisitSuggestionsSchema } from './DawarichVisitSuggestions.entity'; +import { DayAccommodationsSchema } from './DayAccommodations.entity'; +import { DayAssignmentsSchema } from './DayAssignments.entity'; +import { DayNotesSchema } from './DayNotes.entity'; +import { DaysSchema } from './Days.entity'; +import { DocumentConnectionsSchema } from './DocumentConnections.entity'; +import { DocumentProviderFieldsSchema } from './DocumentProviderFields.entity'; +import { DocumentProvidersSchema } from './DocumentProviders.entity'; +import { DocumentSyncItemsSchema } from './DocumentSyncItems.entity'; +import { FileLinksSchema } from './FileLinks.entity'; +import { GoogleApiUsageSchema } from './GoogleApiUsage.entity'; +import { GooglePlacePhotoMetaSchema } from './GooglePlacePhotoMeta.entity'; +import { HiddenCountriesSchema } from './HiddenCountries.entity'; +import { HiddenRegionsSchema } from './HiddenRegions.entity'; +import { IdempotencyKeysSchema } from './IdempotencyKeys.entity'; +import { InviteTokensSchema } from './InviteTokens.entity'; +import { JourneyBooksSchema } from './JourneyBooks.entity'; +import { JourneyContributorsSchema } from './JourneyContributors.entity'; +import { JourneyEntriesSchema } from './JourneyEntries.entity'; +import { JourneyEntryPhotosSchema } from './JourneyEntryPhotos.entity'; +import { JourneyPhotosSchema } from './JourneyPhotos.entity'; +import { JourneyShareTokensSchema } from './JourneyShareTokens.entity'; +import { JourneyTripsSchema } from './JourneyTrips.entity'; +import { JourneysSchema } from './Journeys.entity'; +import { McpTokensSchema } from './McpTokens.entity'; +import { MigrationsSchema } from './Migrations.entity'; +import { NotificationChannelPreferencesSchema } from './NotificationChannelPreferences.entity'; +import { NotificationsSchema } from './Notifications.entity'; +import { OauthClientsSchema } from './OauthClients.entity'; +import { OauthConsentsSchema } from './OauthConsents.entity'; +import { OauthTokensSchema } from './OauthTokens.entity'; +import { PackingBagsSchema } from './PackingBags.entity'; +import { PackingCategoryAssigneesSchema } from './PackingCategoryAssignees.entity'; +import { PackingItemContributorsSchema } from './PackingItemContributors.entity'; +import { PackingItemsSchema } from './PackingItems.entity'; +import { PackingTemplateCategoriesSchema } from './PackingTemplateCategories.entity'; +import { PackingTemplateItemsSchema } from './PackingTemplateItems.entity'; +import { PackingTemplatesSchema } from './PackingTemplates.entity'; +import { PasswordResetTokensSchema } from './PasswordResetTokens.entity'; +import { PhotoProviderFieldsSchema } from './PhotoProviderFields.entity'; +import { PhotoProvidersSchema } from './PhotoProviders.entity'; +import { PhotosSchema } from './Photos.entity'; +import { PlaceDetailsCacheSchema } from './PlaceDetailsCache.entity'; +import { PlaceRatingsSchema } from './PlaceRatings.entity'; +import { PlaceRegionsSchema } from './PlaceRegions.entity'; +import { PlaceShadowPicksSchema } from './PlaceShadowPicks.entity'; +import { PlacesSchema } from './Places.entity'; +import { PluginActionsSchema } from './PluginActions.entity'; +import { PluginCapabilityAuditSchema } from './PluginCapabilityAudit.entity'; +import { PluginEgressHostsSchema } from './PluginEgressHosts.entity'; +import { PluginEntityMetadataSchema } from './PluginEntityMetadata.entity'; +import { PluginErrorLogSchema } from './PluginErrorLog.entity'; +import { PluginMetaMigrationsSchema } from './PluginMetaMigrations.entity'; +import { PluginOauthStateSchema } from './PluginOauthState.entity'; +import { PluginOauthTokensSchema } from './PluginOauthTokens.entity'; +import { PluginScheduledTasksSchema } from './PluginScheduledTasks.entity'; +import { PluginSettingsFieldsSchema } from './PluginSettingsFields.entity'; +import { PluginUserConfigSchema } from './PluginUserConfig.entity'; +import { PluginUserErasureQueueSchema } from './PluginUserErasureQueue.entity'; +import { PluginsSchema } from './Plugins.entity'; +import { PushSubscriptionsSchema } from './PushSubscriptions.entity'; +import { ReservationDayPositionsSchema } from './ReservationDayPositions.entity'; +import { ReservationEndpointsSchema } from './ReservationEndpoints.entity'; +import { ReservationTravelersSchema } from './ReservationTravelers.entity'; +import { ReservationsSchema } from './Reservations.entity'; +import { RoadtripDayBoundariesSchema } from './RoadtripDayBoundaries.entity'; +import { RoadtripDayTracksSchema } from './RoadtripDayTracks.entity'; +import { RoadtripPreferencesSchema } from './RoadtripPreferences.entity'; +import { RoadtripViasSchema } from './RoadtripVias.entity'; +import { RouteUsageDailySchema } from './RouteUsageDaily.entity'; +import { SchemaVersionSchema } from './SchemaVersion.entity'; +import { SchoolHolidayCountriesSchema } from './SchoolHolidayCountries.entity'; +import { SchoolHolidayPeriodsSchema } from './SchoolHolidayPeriods.entity'; +import { SchoolHolidayRegionsSchema } from './SchoolHolidayRegions.entity'; +import { SettingsSchema } from './Settings.entity'; +import { ShareTokensSchema } from './ShareTokens.entity'; +import { TagsSchema } from './Tags.entity'; +import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity'; +import { TodoItemsSchema } from './TodoItems.entity'; +import { TourTypesSchema } from './TourTypes.entity'; +import { TourWaypointsSchema } from './TourWaypoints.entity'; +import { ToursSchema } from './Tours.entity'; +import { TrekPhotoCacheMetaSchema } from './TrekPhotoCacheMeta.entity'; +import { TrekPhotosSchema } from './TrekPhotos.entity'; +import { TripAlbumLinksSchema } from './TripAlbumLinks.entity'; +import { TripDocumentLinksSchema } from './TripDocumentLinks.entity'; +import { TripFilesSchema } from './TripFiles.entity'; +import { TripInviteTokensSchema } from './TripInviteTokens.entity'; +import { TripMembersSchema } from './TripMembers.entity'; +import { TripPhotosSchema } from './TripPhotos.entity'; +import { TripsSchema } from './Trips.entity'; +import { UserNoticeDismissalsSchema } from './UserNoticeDismissals.entity'; +import { UsersSchema } from './Users.entity'; +import { VacayCompanyHolidaysSchema } from './VacayCompanyHolidays.entity'; +import { VacayEntriesSchema } from './VacayEntries.entity'; +import { VacayHolidayCalendarsSchema } from './VacayHolidayCalendars.entity'; +import { VacayPlanMembersSchema } from './VacayPlanMembers.entity'; +import { VacayPlansSchema } from './VacayPlans.entity'; +import { VacaySharesSchema } from './VacayShares.entity'; +import { VacayUserColorsSchema } from './VacayUserColors.entity'; +import { VacayUserSettingsSchema } from './VacayUserSettings.entity'; +import { VacayUserYearsSchema } from './VacayUserYears.entity'; +import { VacayYearsSchema } from './VacayYears.entity'; +import { VisitedCountriesSchema } from './VisitedCountries.entity'; +import { VisitedRegionsSchema } from './VisitedRegions.entity'; +import { WebauthnChallengesSchema } from './WebauthnChallenges.entity'; +import { WebauthnCredentialsSchema } from './WebauthnCredentials.entity'; + +export const ALL_ENTITIES: readonly EntitySchema[] = [ + AddonsSchema, + AppSettingsSchema, + AssignmentParticipantsSchema, + AuditLogSchema, + BucketListSchema, + BudgetCategoryOrderSchema, + BudgetItemMembersSchema, + BudgetItemPayersSchema, + BudgetItemsSchema, + BudgetSettlementsSchema, + CategoriesSchema, + CollabLinksSchema, + CollabMessageReactionsSchema, + CollabMessagesSchema, + CollabNotesSchema, + CollabPollVotesSchema, + CollabPollsSchema, + CollectionLabelsSchema, + CollectionMembersSchema, + CollectionPlaceRatingsSchema, + CollectionPlacesSchema, + CollectionsSchema, + DawarichConnectionsSchema, + DawarichVisitSuggestionsSchema, + DayAccommodationsSchema, + DayAssignmentsSchema, + DayNotesSchema, + DaysSchema, + DocumentConnectionsSchema, + DocumentProviderFieldsSchema, + DocumentProvidersSchema, + DocumentSyncItemsSchema, + FileLinksSchema, + GoogleApiUsageSchema, + GooglePlacePhotoMetaSchema, + HiddenCountriesSchema, + HiddenRegionsSchema, + IdempotencyKeysSchema, + InviteTokensSchema, + JourneyBooksSchema, + JourneyContributorsSchema, + JourneyEntriesSchema, + JourneyEntryPhotosSchema, + JourneyPhotosSchema, + JourneyShareTokensSchema, + JourneyTripsSchema, + JourneysSchema, + McpTokensSchema, + MigrationsSchema, + NotificationChannelPreferencesSchema, + NotificationsSchema, + OauthClientsSchema, + OauthConsentsSchema, + OauthTokensSchema, + PackingBagsSchema, + PackingCategoryAssigneesSchema, + PackingItemContributorsSchema, + PackingItemsSchema, + PackingTemplateCategoriesSchema, + PackingTemplateItemsSchema, + PackingTemplatesSchema, + PasswordResetTokensSchema, + PhotoProviderFieldsSchema, + PhotoProvidersSchema, + PhotosSchema, + PlaceDetailsCacheSchema, + PlaceRatingsSchema, + PlaceRegionsSchema, + PlaceShadowPicksSchema, + PlacesSchema, + PluginActionsSchema, + PluginCapabilityAuditSchema, + PluginEgressHostsSchema, + PluginEntityMetadataSchema, + PluginErrorLogSchema, + PluginMetaMigrationsSchema, + PluginOauthStateSchema, + PluginOauthTokensSchema, + PluginScheduledTasksSchema, + PluginSettingsFieldsSchema, + PluginUserConfigSchema, + PluginUserErasureQueueSchema, + PluginsSchema, + PushSubscriptionsSchema, + ReservationDayPositionsSchema, + ReservationEndpointsSchema, + ReservationTravelersSchema, + ReservationsSchema, + RoadtripDayBoundariesSchema, + RoadtripDayTracksSchema, + RoadtripPreferencesSchema, + RoadtripViasSchema, + RouteUsageDailySchema, + SchemaVersionSchema, + SchoolHolidayCountriesSchema, + SchoolHolidayPeriodsSchema, + SchoolHolidayRegionsSchema, + SettingsSchema, + ShareTokensSchema, + TagsSchema, + TodoCategoryAssigneesSchema, + TodoItemsSchema, + TourTypesSchema, + TourWaypointsSchema, + ToursSchema, + TrekPhotoCacheMetaSchema, + TrekPhotosSchema, + TripAlbumLinksSchema, + TripDocumentLinksSchema, + TripFilesSchema, + TripInviteTokensSchema, + TripMembersSchema, + TripPhotosSchema, + TripsSchema, + UserNoticeDismissalsSchema, + UsersSchema, + VacayCompanyHolidaysSchema, + VacayEntriesSchema, + VacayHolidayCalendarsSchema, + VacayPlanMembersSchema, + VacayPlansSchema, + VacaySharesSchema, + VacayUserColorsSchema, + VacayUserSettingsSchema, + VacayUserYearsSchema, + VacayYearsSchema, + VisitedCountriesSchema, + VisitedRegionsSchema, + WebauthnChallengesSchema, + WebauthnCredentialsSchema, +]; diff --git a/server/src/db/legacy-baseline.ts b/server/src/db/legacy-baseline.ts new file mode 100644 index 000000000..b5930838b --- /dev/null +++ b/server/src/db/legacy-baseline.ts @@ -0,0 +1,182 @@ +import type { Connection, MigrationInfo } from '@mikro-orm/core'; +import type { Migrator } from '@mikro-orm/migrations'; + +import fs from 'node:fs'; + +/** + * Upgrading an install the retired hand-written runner migrated. + * + * Before MikroORM owned the schema, `db/migrations.ts` applied a positional + * array of steps and recorded how far it got as the single `schema_version` + * row. Such a database has no `mikro_orm_migrations` rows, so on its first boot + * here the migrator would see every migration as pending and replay the whole + * history over a schema that already has it. Eight of those migrations are not + * replay-safe and refuse the boot; the ones that are replay-safe are worse — + * step 26 overwrites every user-set `day_assignments.assignment_time`. So the + * replay must never happen. + * + * Instead, a legacy database is baselined once: every migration whose legacy + * step the database already applied is recorded as executed, and the ordinary + * migrator run then applies only the rest. The baseline itself (`createTables()`) + * is deliberately NOT recorded: the legacy runner ran the running release's + * `createTables()` on every boot before its pending steps, and about thirty + * tables exist only there (no numbered step creates them), so an install older + * than those tables gets them the same way — the baseline's `IF NOT EXISTS` + * DDL runs first, then steps N+1 onwards. + * + * The rows are recorded inside the same transaction as the migrator run, so a + * first boot that fails leaves nothing behind and the next boot baselines again. + * Anything this cannot map exactly refuses the boot rather than guessing — a + * refused boot leaves the file untouched, a wrong guess does not. + */ + +/** + * Each migration ported from the positional runner says which step it was in + * its own docstring. That text is the single source of the mapping — there is + * no second, hand-kept list to drift from it. + */ +const STEP_MARKER = /Legacy migration step (\d+)\b/g; + +const REFUSAL = '[DB] Refusing to boot:'; + +export interface LegacyStepMap { + /** The migrations standing for `createTables()`: everything that sorts ahead of step 1. */ + baseline: string[]; + /** Legacy step number → the migration that carries it. */ + steps: Map; + /** The last step the positional runner ever had. */ + finalStep: number; +} + +type ReadSource = (path: string) => string; + +const readFromDisk: ReadSource = (path) => fs.readFileSync(path, 'utf8'); + +function stepsNamedIn(migration: MigrationInfo, readSource: ReadSource): number[] { + if (!migration.path) { + throw new Error(`${REFUSAL} migration ${migration.name} has no source file to read its legacy step from`); + } + const found = new Set(); + for (const match of readSource(migration.path).matchAll(STEP_MARKER)) found.add(Number(match[1])); + return [...found]; +} + +/** + * Derives the legacy step → migration map from the migrations the migrator + * discovered, in its own application order. + * + * Throws unless the mapping is exact: one step per migration at most, no step + * claimed twice, steps 1..N all present, and applied in step order — the order + * the positional runner used, which is the only order that reproduces what an + * install at step N already has. + */ +export function buildLegacyStepMap( + migrations: readonly MigrationInfo[], + readSource: ReadSource = readFromDisk, +): LegacyStepMap { + const steps = new Map(); + let previousStep = 0; + let firstNumbered = -1; + migrations.forEach((migration, index) => { + const named = stepsNamedIn(migration, readSource); + if (named.length === 0) return; + if (named.length > 1) { + throw new Error(`${REFUSAL} ${migration.name} names more than one legacy step (${named.join(', ')})`); + } + const [step] = named as [number]; + const owner = steps.get(step); + if (owner) throw new Error(`${REFUSAL} legacy step ${step} is claimed by both ${owner} and ${migration.name}`); + if (step < previousStep) { + throw new Error(`${REFUSAL} ${migration.name} (legacy step ${step}) sorts after legacy step ${previousStep}`); + } + if (firstNumbered < 0) firstNumbered = index; + previousStep = step; + steps.set(step, migration.name); + }); + + const finalStep = steps.size; + if (finalStep === 0) throw new Error(`${REFUSAL} no migration names a legacy step`); + for (let step = 1; step <= finalStep; step++) { + if (!steps.has(step)) throw new Error(`${REFUSAL} no migration carries legacy step ${step}`); + } + const baseline = migrations.slice(0, firstNumbered).map((migration) => migration.name); + if (baseline.length === 0) throw new Error(`${REFUSAL} no baseline migration sorts ahead of legacy step 1`); + return { baseline, steps, finalStep }; +} + +/** + * The step a positional-runner database stopped at, or null when this is not + * one: a fresh database has no `schema_version`, and one the migrator has + * already recorded anything in is past the hand-over. + */ +async function legacyVersion(connection: Connection, migrator: Migrator): Promise { + if ((await migrator.getExecuted()).length > 0) return null; + const table: unknown[] = await connection.execute( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_version'`, + ); + if (table.length === 0) return null; + + const rows: Array<{ version: unknown }> = await connection.execute('SELECT version FROM schema_version'); + if (rows.length !== 1) { + throw new Error(`${REFUSAL} schema_version holds ${rows.length} rows; the legacy runner always kept exactly one`); + } + const [{ version }] = rows as [{ version: unknown }]; + if (typeof version !== 'number' || !Number.isInteger(version) || version < 1) { + throw new Error(`${REFUSAL} schema_version ${String(version)} is not a legacy step this release can map`); + } + return version; +} + +/** + * The migrations a positional-runner database already has — steps 1..N, never + * the baseline (see the file header) — or `[]` for every database that is not + * one. Writes nothing; {@link migrateToHead} records them. + */ +export async function planLegacyBaseline( + connection: Connection, + migrator: Migrator, + readSource: ReadSource = readFromDisk, +): Promise { + const version = await legacyVersion(connection, migrator); + if (version === null) return []; + + const map = buildLegacyStepMap(await migrator.getPending(), readSource); + if (version > map.finalStep) { + throw new Error( + `${REFUSAL} schema_version ${version} is newer than the last legacy step this release knows (${map.finalStep})`, + ); + } + + const executed: string[] = []; + for (const [step, name] of map.steps) if (step <= version) executed.push(name); + console.log(`[DB] Legacy install at schema_version ${version} — baselining ${executed.length} migration(s)`); + return executed; +} + +/** + * Runs every pending migration. On a positional-runner database it first + * records steps 1..N as executed, in the SAME transaction as the run, so the + * two commit or roll back together. + */ +export async function migrateToHead( + connection: Connection, + migrator: Migrator, + readSource: ReadSource = readFromDisk, +): Promise { + const baselined = await planLegacyBaseline(connection, migrator, readSource); + const already = new Set(baselined); + const pending = (await migrator.getPending()).filter((migration) => !already.has(migration.name)); + if (pending.length === 0 && baselined.length === 0) return; + console.log(`[DB] Applying ${pending.length} pending migration(s)`); + if (baselined.length === 0) { + await migrator.up(); + return; + } + + const storage = migrator.getStorage(); + await storage.ensureTable(); + await connection.transactional(async (trx) => { + for (const name of baselined) await storage.logMigration({ name }, trx); + await migrator.up({ transaction: trx }); + }); +} diff --git a/server/src/db/migration-utils.ts b/server/src/db/migration-utils.ts new file mode 100644 index 000000000..d5a625e56 --- /dev/null +++ b/server/src/db/migration-utils.ts @@ -0,0 +1,161 @@ +import type { Migration } from '@mikro-orm/migrations'; + +/** + * Helpers shared by the migrations that replay the pre-MikroORM history. + * + * They live outside `db/migrations/` on purpose: the migrator globs that + * directory with `!(*.d).{js,ts,cjs}` and would try to load a helper module as + * a migration class. + */ + +/** A single `pragma_table_info` row — only the column name is ever read. */ +interface ColumnRow { + name: string; +} + +/** + * Adds a column only when the table does not already have it. + * + * The legacy bootstrap ran `schema.ts::createTables()` before the numbered + * migrations, and that file was a *moving* snapshot of the schema rather than a + * frozen v0 — so a database created late already had columns that early steps + * add here. The old runner absorbed the clash by catching "duplicate column + * name"; this checks first instead, so a genuine failure still propagates. + * + * `definition` is the full column spec, e.g. `status TEXT DEFAULT 'pending'`. + */ +export async function addColumnIfMissing( + migration: Migration, + table: string, + column: string, + definition: string, +): Promise { + const existing = (await migration.execute('select name from pragma_table_info(?) where name = ?', [ + table, + column, + ])) as ColumnRow[]; + if (existing.length > 0) return; + await migration.execute(`alter table \`${table}\` add column ${definition}`); +} + +/** True when `table` exists in the current database. */ +export async function tableExists(migration: Migration, table: string): Promise { + const rows = await migration.execute(`select name from sqlite_master where type = 'table' and name = ?`, [table]); + return rows.length > 0; +} + +/** The `CREATE TABLE` DDL SQLite stored for `table`, or null when it does not exist. */ +export async function tableSql(migration: Migration, table: string): Promise { + const rows = (await migration.execute(`select sql from sqlite_master where name = ?`, [table])) as { + sql: string | null; + }[]; + return rows[0]?.sql ?? null; +} + +/** + * Runs a statement whose failure the legacy step logged and swallowed. + * + * Kept as its own helper so "this step is allowed to fail" stays visible in the + * generated migrations instead of hiding behind a bare try/catch. + */ +export async function execBestEffort(migration: Migration, sql: string, params?: unknown[]): Promise { + try { + await migration.execute(sql, params); + } catch (err: unknown) { + console.warn('[migrations] non-fatal step failed:', err instanceof Error ? err.message : err); + } +} + +/** The column names of `table`, or an empty set when the table does not exist. */ +export async function columnNames(migration: Migration, table: string): Promise> { + const rows = (await migration.execute('select name from pragma_table_info(?)', [table])) as ColumnRow[]; + return new Set(rows.map((r) => r.name)); +} + +/** + * Runs a statement that the legacy step allowed to fail *only* because the table + * it touches may not exist yet on that database. Any other error still throws. + */ +export async function execUnlessTableMissing(migration: Migration, sql: string, params?: unknown[]): Promise { + try { + await migration.execute(sql, params); + } catch (err: unknown) { + if (!(err instanceof Error) || !err.message.includes('no such table')) throw err; + } +} + +/** + * Runs a group of statements the legacy step wrapped in a single swallowing + * try/catch. A failure part-way through leaves the earlier statements applied, + * which is exactly what the original did. + */ +export async function bestEffort(run: () => Promise): Promise { + try { + await run(); + } catch (err: unknown) { + console.warn('[migrations] non-fatal step failed:', err instanceof Error ? err.message : err); + } +} + +/** A booked stay whose check-in day has no matching stop on the plan. */ +interface UnplacedStay { + id: number; + place_id: number; + start_day_id: number; +} + +/** + * Gives every booked stay the day stop it would get today, and returns how many + * were placed. + * + * Road-trip mode builds its drive out of `day_assignments` alone, so a hotel + * booked in Days mode was invisible there. Two legacy steps run this same sweep: + * once at the release, and once more afterwards for the bookings written in the + * seconds while the old container was still answering. The `NOT EXISTS` decides + * both times, so repeating it is safe — a stay that already has its stop is + * passed over, and a place the traveller planned by hand keeps its row unclaimed. + */ +export async function attachStayStopsToCheckInDay(migration: Migration): Promise { + const stays = (await migration.execute(` + SELECT a.id, a.place_id, a.start_day_id + FROM day_accommodations a + WHERE a.place_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM day_assignments da + WHERE da.day_id = a.start_day_id AND da.place_id = a.place_id + ) + ORDER BY a.id + `)) as UnplacedStay[]; + + for (const stay of stays) { + // Arriving somewhere is what the day was for, so the stop goes last — the + // same position a stay booked today lands in. + await migration.execute( + `INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) + VALUES (?, ?, COALESCE((SELECT MAX(order_index) + 1 FROM day_assignments WHERE day_id = ?), 0), ?)`, + [stay.start_day_id, stay.place_id, stay.start_day_id, stay.id], + ); + await migration.execute( + `UPDATE places SET stop_type = 'hotel' WHERE id = ? AND (stop_type IS NULL OR stop_type = '')`, + [stay.place_id], + ); + } + + return stays.length; +} + +/** + * Keeps place_regions in step with the place it was resolved from (#2527): a + * change to lat, lng or address drops the cached row. Legacy steps 244 and 246 + * both create it, see there. + */ +export async function createPlaceRegionsFollowPlaceTrigger(migration: Migration): Promise { + await migration.execute(` + CREATE TRIGGER IF NOT EXISTS trg_place_regions_follow_place + AFTER UPDATE OF lat, lng, address ON places + WHEN OLD.lat IS NOT NEW.lat OR OLD.lng IS NOT NEW.lng OR OLD.address IS NOT NEW.address + BEGIN + DELETE FROM place_regions WHERE place_id = NEW.id; + END + `); +} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts deleted file mode 100644 index 8d8082da7..000000000 --- a/server/src/db/migrations.ts +++ /dev/null @@ -1,5660 +0,0 @@ -import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared'; -import { readEnv } from '../app-config'; -import { encrypt_api_key } from '../nest/common/crypto/apiKeyCrypto'; -import { seedDocumentProviders } from './document-provider-seed'; -import { reseatBookedNights } from './reseat-booked-nights'; - -import Database from 'better-sqlite3'; -import fs from 'fs'; -import path from 'path'; -import zlib from 'zlib'; - -/** Returns true if any collision was encountered (renamed row). */ -export function trimUserWhitespace(db: Database.Database): boolean { - type DirtyRow = { id: number; username?: string; email?: string }; - let hadCollision = false; - - const dirtyUsernames = db - .prepare(`SELECT id, username FROM users WHERE username != TRIM(username)`) - .all() as DirtyRow[]; - - for (const row of dirtyUsernames) { - const trimmed = row.username!.trim(); - const collision = db - .prepare(`SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?`) - .get(trimmed, row.id) as { id: number } | undefined; - - const final = collision ? `${trimmed}__migrated_${row.id}` : trimmed; - if (collision) { - hadCollision = true; - console.warn( - `[migration] WHITESPACE COLLISION username: user id=${row.id} ` + - `original=${JSON.stringify(row.username)} trimmed="${trimmed}" ` + - `collides with user id=${collision.id}. Renamed to "${final}". ` + - `Manual review required.`, - ); - } else { - console.warn( - `[migration] Trimmed username for user id=${row.id}: ` + `${JSON.stringify(row.username)} → "${final}"`, - ); - } - db.prepare(`UPDATE users SET username = ? WHERE id = ?`).run(final, row.id); - } - - const dirtyEmails = db.prepare(`SELECT id, email FROM users WHERE email != TRIM(email)`).all() as DirtyRow[]; - - for (const row of dirtyEmails) { - const trimmed = row.email!.trim(); - const collision = db - .prepare(`SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?`) - .get(trimmed, row.id) as { id: number } | undefined; - - let final = trimmed; - if (collision) { - hadCollision = true; - const at = trimmed.lastIndexOf('@'); - final = - at > 0 ? `${trimmed.slice(0, at)}__migrated_${row.id}${trimmed.slice(at)}` : `${trimmed}__migrated_${row.id}`; - console.warn( - `[migration] WHITESPACE COLLISION email: user id=${row.id} ` + - `original=${JSON.stringify(row.email)} trimmed="${trimmed}" ` + - `collides with user id=${collision.id}. Renamed to "${final}". ` + - `User cannot sign in with this email until manually corrected.`, - ); - } else { - console.warn(`[migration] Trimmed email for user id=${row.id}: ` + `${JSON.stringify(row.email)} → "${final}"`); - } - db.prepare(`UPDATE users SET email = ? WHERE id = ?`).run(final, row.id); - } - - return hadCollision; -} - -/** - * Keeps place_regions in step with the place it was resolved from (#2527): a - * change to lat, lng or address drops the cached row. Migrations 244 and 246 - * both create it, see there. - */ -function createPlaceRegionsFollowPlaceTrigger(db: Database.Database): void { - db.exec(` - CREATE TRIGGER IF NOT EXISTS trg_place_regions_follow_place - AFTER UPDATE OF lat, lng, address ON places - WHEN OLD.lat IS NOT NEW.lat OR OLD.lng IS NOT NEW.lng OR OLD.address IS NOT NEW.address - BEGIN - DELETE FROM place_regions WHERE place_id = NEW.id; - END - `); -} - -function schemaObjectSql(db: Database.Database, name: string): string | undefined { - return (db.prepare('SELECT sql FROM sqlite_master WHERE name = ?').get(name) as { sql: string } | undefined)?.sql; -} - -function hasSchemaColumn(db: Database.Database, table: string, column: string): boolean { - return !!db.prepare('SELECT 1 FROM pragma_table_info(?) WHERE name = ?').get(table, column); -} - -function normalizedSchemaSql(sql: string): string { - return sql.replace(/IF NOT EXISTS/gi, '').replace(/\s+/g, '').replace(/;$/, ''); -} - -function matchesSchemaObject(db: Database.Database, name: string, sql: string | undefined): boolean { - const actual = schemaObjectSql(db, name); - return sql === undefined ? actual === undefined : actual !== undefined - && normalizedSchemaSql(actual) === normalizedSchemaSql(sql); -} - -function matchesToursSchema(db: Database.Database, stage: number): boolean { - const expected: Record = { - tour_types: `CREATE TABLE tour_types ( - key TEXT PRIMARY KEY, label_key TEXT NOT NULL, icon TEXT NOT NULL, color TEXT NOT NULL, - routing_profile TEXT, is_sport INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, - sort_order INTEGER NOT NULL DEFAULT 0 - )`, - tours: stage >= 2 ? `CREATE TABLE tours ( - place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, - tour_type TEXT NOT NULL REFERENCES tour_types(key), distance REAL, elevation_gain REAL, - elevation_loss REAL, duration REAL, difficulty TEXT, wanderer_ref TEXT, match_confidence REAL, - tour_group_id INTEGER, created_at TEXT DEFAULT CURRENT_TIMESTAMP - ${stage === 4 ? ', max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)' : ''} - )` : undefined, - tour_waypoints: stage >= 3 ? `CREATE TABLE tour_waypoints ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE, - lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90), - lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180), - role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')), - sequence INTEGER NOT NULL CHECK(sequence >= 0), UNIQUE(place_id, sequence) - )` : undefined, - idx_tours_tour_type: stage >= 2 ? 'CREATE INDEX idx_tours_tour_type ON tours(tour_type)' : undefined, - idx_tour_waypoints_place: stage >= 3 - ? 'CREATE INDEX idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)' : undefined, - }; - return Object.entries(expected).every(([name, sql]) => matchesSchemaObject(db, name, sql)); -} - -function toursLineageRewind(db: Database.Database, version: number): number | undefined { - const legacy = version >= 216 && version <= 218; - const frozen = version >= 243 && version <= 246; - if (!legacy && !frozen) return undefined; - const tourTables = ['tour_types', 'tours', 'tour_waypoints'].map(name => !!schemaObjectSql(db, name)); - const upstreamMarkers = { - immich: hasSchemaColumn(db, 'users', 'immich_allow_insecure_tls'), - atlasTrigger: !!schemaObjectSql(db, 'trg_place_regions_follow_place'), - push: !!schemaObjectSql(db, 'push_subscriptions'), - routeExcluded: hasSchemaColumn(db, 'day_assignments', 'route_excluded'), - packedQuantity: hasSchemaColumn(db, 'packing_items', 'packed_quantity'), - bucketRegion: hasSchemaColumn(db, 'bucket_list', 'region_code'), - holidayFraction: hasSchemaColumn(db, 'vacay_company_holidays', 'fraction'), - entryDraft: hasSchemaColumn(db, 'journey_entries', 'is_draft'), - travelShare: hasSchemaColumn(db, 'share_tokens', 'share_travel_only'), - hiddenImages: hasSchemaColumn(db, 'share_tokens', 'share_hide_images'), - settlementNote: hasSchemaColumn(db, 'budget_settlements', 'note'), - googleUsage: !!schemaObjectSql(db, 'google_api_usage'), - templateWeight: hasSchemaColumn(db, 'packing_template_items', 'weight_grams'), - templateQuantity: hasSchemaColumn(db, 'packing_template_items', 'quantity'), - templateBag: hasSchemaColumn(db, 'packing_template_items', 'bag_name'), - journeyStatus: hasSchemaColumn(db, 'journeys', 'status_override'), - placeEmail: hasSchemaColumn(db, 'places', 'email'), - placeHours: hasSchemaColumn(db, 'places', 'opening_hours'), - photoLocation: hasSchemaColumn(db, 'journeys', 'photo_location'), - }; - const legacyMarkers = { - countries: !!schemaObjectSql(db, 'school_holiday_countries'), - regions: !!schemaObjectSql(db, 'school_holiday_regions'), - periods: !!schemaObjectSql(db, 'school_holiday_periods'), - endDay: hasSchemaColumn(db, 'day_assignments', 'end_day'), - boundaries: !!schemaObjectSql(db, 'roadtrip_day_boundaries'), - }; - const unsafe = (): never => { - throw new Error(`[migrations] Unsafe Tours migration lineage at schema version ${version}: ` - + JSON.stringify({ tourTables, upstreamMarkers, legacyMarkers }) - + '. Cursor and data were not changed; inspect this database before retrying.'); - }; - if (!tourTables.some(Boolean)) { - const laterUpstreamMarkers = Object.entries(upstreamMarkers) - .filter(([name]) => !['immich', 'atlasTrigger', 'push'].includes(name)); - if (frozen && laterUpstreamMarkers.some(([, present]) => present)) unsafe(); - if (frozen && (!upstreamMarkers.immich - || (version === 243 && (upstreamMarkers.atlasTrigger || upstreamMarkers.push)) - || (version === 244 && !upstreamMarkers.atlasTrigger && !upstreamMarkers.push) - || (version === 245 && !upstreamMarkers.push) - || (version === 246 && (!upstreamMarkers.atlasTrigger || !upstreamMarkers.push)))) unsafe(); - if (legacy && (Object.values(upstreamMarkers).some(Boolean) - || !legacyMarkers.countries || !legacyMarkers.regions || !legacyMarkers.periods - || (version === 216 && (legacyMarkers.endDay || legacyMarkers.boundaries)) - || (version === 217 && legacyMarkers.boundaries) - || (version >= 217 && !legacyMarkers.endDay) - || (version >= 218 && !legacyMarkers.boundaries))) unsafe(); - return undefined; - } - if (!matchesToursSchema(db, version - (legacy ? 215 : 242)) - || Object.values(upstreamMarkers).some(Boolean) - || (legacy && Object.values(legacyMarkers).some(Boolean)) - || (frozen && !Object.values(legacyMarkers).every(Boolean))) unsafe(); - return legacy ? 215 : 242; -} - -function runMigrations(db: Database.Database): void { - db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)'); - const versionRow = db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined; - let currentVersion = versionRow?.version ?? 0; - - if (currentVersion === 0) { - const hasUnsplash = db.prepare("SELECT 1 FROM pragma_table_info('users') WHERE name = 'unsplash_api_key'").get(); - if (hasUnsplash) { - currentVersion = 19; - db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(currentVersion); - console.log('[DB] Schema already up-to-date, setting version to', currentVersion); - } else { - db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(0); - } - } - - type Migration = (() => void) | { raw: () => void }; - const migrations: Migration[] = [ - () => db.exec('ALTER TABLE users ADD COLUMN unsplash_api_key TEXT'), - () => db.exec('ALTER TABLE users ADD COLUMN openweather_api_key TEXT'), - () => db.exec('ALTER TABLE places ADD COLUMN duration_minutes INTEGER DEFAULT 60'), - () => db.exec('ALTER TABLE places ADD COLUMN notes TEXT'), - () => db.exec('ALTER TABLE places ADD COLUMN image_url TEXT'), - () => db.exec("ALTER TABLE places ADD COLUMN transport_mode TEXT DEFAULT 'walking'"), - () => db.exec('ALTER TABLE days ADD COLUMN title TEXT'), - () => db.exec("ALTER TABLE reservations ADD COLUMN status TEXT DEFAULT 'pending'"), - () => - db.exec( - 'ALTER TABLE trip_files ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL', - ), - () => db.exec("ALTER TABLE reservations ADD COLUMN type TEXT DEFAULT 'other'"), - () => db.exec('ALTER TABLE trips ADD COLUMN cover_image TEXT'), - () => db.exec("ALTER TABLE day_notes ADD COLUMN icon TEXT DEFAULT '📝'"), - () => db.exec('ALTER TABLE trips ADD COLUMN is_archived INTEGER DEFAULT 0'), - () => db.exec('ALTER TABLE categories ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'), - () => db.exec('ALTER TABLE users ADD COLUMN avatar TEXT'), - () => db.exec('ALTER TABLE users ADD COLUMN oidc_sub TEXT'), - () => db.exec('ALTER TABLE users ADD COLUMN oidc_issuer TEXT'), - () => db.exec('ALTER TABLE users ADD COLUMN last_login DATETIME'), - () => { - const schema = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'budget_items'").get() as - | { sql: string } - | undefined; - if (schema?.sql?.includes('NOT NULL DEFAULT 1')) { - db.exec(` - CREATE TABLE budget_items_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - category TEXT NOT NULL DEFAULT 'Other', - name TEXT NOT NULL, - total_price REAL NOT NULL DEFAULT 0, - persons INTEGER DEFAULT NULL, - days INTEGER DEFAULT NULL, - note TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - INSERT INTO budget_items_new SELECT * FROM budget_items; - DROP TABLE budget_items; - ALTER TABLE budget_items_new RENAME TO budget_items; - `); - } - }, - () => { - try { - db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE day_accommodations ADD COLUMN check_out TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE day_accommodations ADD COLUMN confirmation TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE places ADD COLUMN end_time TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec("ALTER TABLE day_assignments ADD COLUMN reservation_status TEXT DEFAULT 'none'"); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_notes TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN reservation_datetime TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec(` - UPDATE day_assignments SET - reservation_status = (SELECT reservation_status FROM places WHERE places.id = day_assignments.place_id), - reservation_notes = (SELECT reservation_notes FROM places WHERE places.id = day_assignments.place_id), - reservation_datetime = (SELECT reservation_datetime FROM places WHERE places.id = day_assignments.place_id) - WHERE place_id IN (SELECT id FROM places WHERE reservation_status IS NOT NULL AND reservation_status != 'none') - `); - console.log('[DB] Migrated reservation data from places to day_assignments'); - } catch (e: unknown) { - console.error('[DB] Migration 22 data copy error:', e instanceof Error ? e.message : e); - } - }, - () => { - try { - db.exec( - 'ALTER TABLE reservations ADD COLUMN assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL', - ); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS assignment_participants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(assignment_id, user_id) - ) - `); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS collab_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - category TEXT DEFAULT 'General', - title TEXT NOT NULL, - content TEXT, - color TEXT DEFAULT '#6366f1', - pinned INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE IF NOT EXISTS collab_polls ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - question TEXT NOT NULL, - options TEXT NOT NULL, - multiple INTEGER DEFAULT 0, - closed INTEGER DEFAULT 0, - deadline TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE IF NOT EXISTS collab_poll_votes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - option_index INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(poll_id, user_id, option_index) - ); - CREATE TABLE IF NOT EXISTS collab_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - text TEXT NOT NULL, - reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id); - CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id); - CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id); - `); - try { - db.prepare( - "INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)", - ).run(); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_time TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN assignment_end_time TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec(` - UPDATE day_assignments SET - assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id), - assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id) - `); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS budget_item_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - paid INTEGER NOT NULL DEFAULT 0, - UNIQUE(budget_item_id, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_budget_item_members_item ON budget_item_members(budget_item_id); - CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id); - `); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS collab_message_reactions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - message_id INTEGER NOT NULL REFERENCES collab_messages(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - emoji TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(message_id, user_id, emoji) - ); - CREATE INDEX IF NOT EXISTS idx_collab_reactions_msg ON collab_message_reactions(message_id); - `); - }, - () => { - try { - db.exec('ALTER TABLE collab_messages ADD COLUMN deleted INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE trip_files ADD COLUMN note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE collab_notes ADD COLUMN website TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE reservations ADD COLUMN reservation_end_time TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE places ADD COLUMN osm_id TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE trip_files ADD COLUMN uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE trip_files ADD COLUMN starred INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE trip_files ADD COLUMN deleted_at TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec( - 'ALTER TABLE reservations ADD COLUMN accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL', - ); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN metadata TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS invite_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - token TEXT UNIQUE NOT NULL, - max_uses INTEGER NOT NULL DEFAULT 1, - used_count INTEGER NOT NULL DEFAULT 0, - expires_at TEXT, - created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - }, - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN mfa_enabled INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN mfa_secret TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS packing_category_assignees ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - category_name TEXT NOT NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(trip_id, category_name, user_id) - )`); - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS packing_templates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - db.exec(`CREATE TABLE IF NOT EXISTS packing_template_categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - template_id INTEGER NOT NULL REFERENCES packing_templates(id) ON DELETE CASCADE, - name TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0 - )`); - // Recreate items table with category_id FK (replaces old template_id-based schema) - try { - db.exec('DROP TABLE IF EXISTS packing_template_items'); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - db.exec(`CREATE TABLE packing_template_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE, - name TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0 - )`); - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS packing_bags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - name TEXT NOT NULL, - color TEXT NOT NULL DEFAULT '#6366f1', - weight_limit_grams INTEGER, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - try { - db.exec('ALTER TABLE packing_items ADD COLUMN weight_grams INTEGER'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE packing_items ADD COLUMN bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS visited_countries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - country_code TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, country_code) - )`); - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS bucket_list ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - lat REAL, - lng REAL, - country_code TEXT, - notes TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - }, - () => { - // Configurable weekend days - try { - db.exec("ALTER TABLE vacay_plans ADD COLUMN weekend_days TEXT DEFAULT '0,6'"); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Immich integration - try { - db.exec('ALTER TABLE users ADD COLUMN immich_url TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN immich_api_key TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec(`CREATE TABLE IF NOT EXISTS trip_photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - immich_asset_id TEXT NOT NULL, - shared INTEGER NOT NULL DEFAULT 1, - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, immich_asset_id) - )`); - // Add memories addon - try { - db.prepare('INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)').run( - 'memories', - 'Photos', - 'trip', - 'Image', - 0, - 7, - ); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - // Allow files to be linked to multiple reservations/assignments - db.exec(`CREATE TABLE IF NOT EXISTS file_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE, - reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE, - assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, - place_id INTEGER REFERENCES places(id) ON DELETE CASCADE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(file_id, reservation_id), - UNIQUE(file_id, assignment_id), - UNIQUE(file_id, place_id) - )`); - }, - () => { - // Add day_plan_position to reservations for persistent transport ordering in day timeline - try { - db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Add paid_by_user_id to budget_items for expense tracking / settlement - try { - db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Add target_date to bucket_list for optional visit planning - try { - db.exec('ALTER TABLE bucket_list ADD COLUMN target_date TEXT DEFAULT NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Notification preferences per user - db.exec(`CREATE TABLE IF NOT EXISTS notification_preferences ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - notify_trip_invite INTEGER DEFAULT 1, - notify_booking_change INTEGER DEFAULT 1, - notify_trip_reminder INTEGER DEFAULT 1, - notify_vacay_invite INTEGER DEFAULT 1, - notify_photos_shared INTEGER DEFAULT 1, - notify_collab_message INTEGER DEFAULT 1, - notify_packing_tagged INTEGER DEFAULT 1, - notify_webhook INTEGER DEFAULT 0, - UNIQUE(user_id) - )`); - }, - () => { - // Add missing notification preference columns for existing tables - try { - db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_vacay_invite INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_photos_shared INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_collab_message INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE notification_preferences ADD COLUMN notify_packing_tagged INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Public share links for read-only trip access - db.exec(`CREATE TABLE IF NOT EXISTS share_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - token TEXT NOT NULL UNIQUE, - created_by INTEGER NOT NULL REFERENCES users(id), - share_map INTEGER DEFAULT 1, - share_bookings INTEGER DEFAULT 1, - share_packing INTEGER DEFAULT 0, - share_budget INTEGER DEFAULT 0, - share_collab INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - }, - () => { - // Add permission columns to share_tokens - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN share_map INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN share_bookings INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN share_packing INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN share_budget INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN share_collab INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Audit log - db.exec(` - CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - action TEXT NOT NULL, - resource TEXT, - details TEXT, - ip TEXT - ); - CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); - `); - }, - () => { - // MFA backup/recovery codes - try { - db.exec('ALTER TABLE users ADD COLUMN mfa_backup_codes TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // MCP long-lived API tokens - () => - db.exec(` - CREATE TABLE IF NOT EXISTS mcp_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - token_hash TEXT NOT NULL, - token_prefix TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_used_at DATETIME - ) - `), - // MCP addon entry - () => { - try { - db.prepare( - 'INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', - ).run('mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Index on mcp_tokens.token_hash - () => - db.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash) - `), - // Ensure MCP addon type is 'integration' - () => { - try { - db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'mcp'").run(); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - try { - db.exec('ALTER TABLE places ADD COLUMN route_geometry TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE trips ADD COLUMN reminder_days INTEGER DEFAULT 3'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Encrypt any plaintext oidc_client_secret left in app_settings - () => { - const row = db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_secret'").get() as - | { value: string } - | undefined; - if (row?.value && !row.value.startsWith('enc:v1:')) { - db.prepare("UPDATE app_settings SET value = ? WHERE key = 'oidc_client_secret'").run( - encrypt_api_key(row.value), - ); - } - }, - // Encrypt any plaintext smtp_pass left in app_settings - () => { - const row = db.prepare("SELECT value FROM app_settings WHERE key = 'smtp_pass'").get() as - | { value: string } - | undefined; - if (row?.value && !row.value.startsWith('enc:v1:')) { - db.prepare("UPDATE app_settings SET value = ? WHERE key = 'smtp_pass'").run(encrypt_api_key(row.value)); - } - }, - // Encrypt any plaintext immich_api_key values in the users table - () => { - const rows = db - .prepare( - "SELECT id, immich_api_key FROM users WHERE immich_api_key IS NOT NULL AND immich_api_key != '' AND immich_api_key NOT LIKE 'enc:v1:%'", - ) - .all() as { id: number; immich_api_key: string }[]; - for (const row of rows) { - db.prepare('UPDATE users SET immich_api_key = ? WHERE id = ?').run(encrypt_api_key(row.immich_api_key), row.id); - } - }, - () => { - try { - db.exec('ALTER TABLE budget_items ADD COLUMN expense_date TEXT DEFAULT NULL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS trip_album_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - immich_album_id TEXT NOT NULL, - album_name TEXT NOT NULL DEFAULT '', - sync_enabled INTEGER NOT NULL DEFAULT 1, - last_synced_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, immich_album_id) - ); - CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); - `); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS notifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), - scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), - target INTEGER NOT NULL, - sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title_key TEXT NOT NULL, - title_params TEXT DEFAULT '{}', - text_key TEXT NOT NULL, - text_params TEXT DEFAULT '{}', - positive_text_key TEXT, - negative_text_key TEXT, - positive_callback TEXT, - negative_callback TEXT, - response TEXT CHECK(response IN ('positive', 'negative')), - navigate_text_key TEXT, - navigate_target TEXT, - is_read INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC); - `); - }, - () => { - // Normalize trip_photos to provider-based schema used by current routes - const tripPhotosExists = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'") - .get(); - if (!tripPhotosExists) { - db.exec(` - CREATE TABLE trip_photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - asset_id TEXT NOT NULL, - provider TEXT NOT NULL DEFAULT 'immich', - shared INTEGER NOT NULL DEFAULT 1, - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, asset_id, provider) - ); - CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id); - `); - } else { - const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; - const names = new Set(columns.map((c) => c.name)); - const assetSource = names.has('asset_id') - ? 'asset_id' - : names.has('immich_asset_id') - ? 'immich_asset_id' - : null; - if (assetSource) { - const providerExpr = names.has('provider') - ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" - : "'immich'"; - const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1'; - const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; - - db.exec(` - CREATE TABLE trip_photos_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - asset_id TEXT NOT NULL, - provider TEXT NOT NULL DEFAULT 'immich', - shared INTEGER NOT NULL DEFAULT 1, - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, asset_id, provider) - ); - `); - - db.exec(` - INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at) - SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr} - FROM trip_photos - WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != '' - `); - - db.exec('DROP TABLE trip_photos'); - db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos'); - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)'); - } - } - }, - () => { - // Normalize trip_album_links to provider + album_id schema used by current routes - const linksExists = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_album_links'") - .get(); - if (!linksExists) { - db.exec(` - CREATE TABLE trip_album_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider TEXT NOT NULL, - album_id TEXT NOT NULL, - album_name TEXT NOT NULL DEFAULT '', - sync_enabled INTEGER NOT NULL DEFAULT 1, - last_synced_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, provider, album_id) - ); - CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id); - `); - } else { - const columns = db.prepare("PRAGMA table_info('trip_album_links')").all() as Array<{ name: string }>; - const names = new Set(columns.map((c) => c.name)); - const albumIdSource = names.has('album_id') - ? 'album_id' - : names.has('immich_album_id') - ? 'immich_album_id' - : null; - if (albumIdSource) { - const providerExpr = names.has('provider') - ? "CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END" - : "'immich'"; - const albumNameExpr = names.has('album_name') ? "COALESCE(album_name, '')" : "''"; - const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1'; - const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL'; - const createdAtExpr = names.has('created_at') - ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' - : 'CURRENT_TIMESTAMP'; - - db.exec(` - CREATE TABLE trip_album_links_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - provider TEXT NOT NULL, - album_id TEXT NOT NULL, - album_name TEXT NOT NULL DEFAULT '', - sync_enabled INTEGER NOT NULL DEFAULT 1, - last_synced_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, provider, album_id) - ); - `); - - db.exec(` - INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at) - SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr} - FROM trip_album_links - WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != '' - `); - - db.exec('DROP TABLE trip_album_links'); - db.exec('ALTER TABLE trip_album_links_new RENAME TO trip_album_links'); - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)'); - } - } - }, - () => { - // Add Synology credential columns for existing databases - try { - db.exec('ALTER TABLE users ADD COLUMN synology_url TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN synology_username TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN synology_password TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN synology_sid TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // Seed Synology Photos provider and fields in existing databases - try { - db.prepare( - ` - INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - name = excluded.name, - description = excluded.description, - icon = excluded.icon, - enabled = excluded.enabled, - sort_order = excluded.sort_order - `, - ).run( - 'synologyphotos', - 'Synology Photos', - 'Synology Photos integration with separate account settings', - 'Image', - 0, - 1, - ); - } catch (err: any) { - if (!err.message?.includes('no such table')) throw err; - } - try { - const insertField = db.prepare(` - INSERT INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(provider_id, field_key) DO UPDATE SET - label = excluded.label, - input_type = excluded.input_type, - placeholder = excluded.placeholder, - required = excluded.required, - secret = excluded.secret, - settings_key = excluded.settings_key, - payload_key = excluded.payload_key, - sort_order = excluded.sort_order - `); - insertField.run( - 'synologyphotos', - 'synology_url', - 'providerUrl', - 'url', - 'https://synology.example.com', - 1, - 0, - 'synology_url', - 'synology_url', - 0, - ); - insertField.run( - 'synologyphotos', - 'synology_username', - 'providerUsername', - 'text', - 'Username', - 1, - 0, - 'synology_username', - 'synology_username', - 1, - ); - insertField.run( - 'synologyphotos', - 'synology_password', - 'providerPassword', - 'password', - 'Password', - 1, - 1, - null, - 'synology_password', - 2, - ); - } catch (err: any) { - if (!err.message?.includes('no such table')) throw err; - } - }, - () => { - // Remove the stored config column from photo_providers now that it is generated from provider id. - const columns = db.prepare("PRAGMA table_info('photo_providers')").all() as Array<{ name: string }>; - const names = new Set(columns.map((c) => c.name)); - if (!names.has('config')) return; - - db.exec('ALTER TABLE photo_providers DROP COLUMN config'); - }, - () => { - const columns = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; - const names = new Set(columns.map((c) => c.name)); - if (names.has('asset_id') && !names.has('immich_asset_id')) return; - db.exec('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id'); - db.exec('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); - db.exec('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); - db.exec('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id'); - }, - () => { - // Track which album link each photo was synced from - try { - db.exec( - 'ALTER TABLE trip_photos ADD COLUMN album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL', - ); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_album_link ON trip_photos(album_link_id)'); - }, - // Migration 68: Todo items - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS todo_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - name TEXT NOT NULL, - checked INTEGER DEFAULT 0, - category TEXT, - sort_order INTEGER DEFAULT 0, - due_date TEXT, - description TEXT, - assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - priority INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_todo_items_trip_id ON todo_items(trip_id); - - CREATE TABLE IF NOT EXISTS todo_category_assignees ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - category_name TEXT NOT NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(trip_id, category_name, user_id) - ); - `); - }, - () => { - try { - db.exec("UPDATE addons SET enabled = 0 WHERE id = 'memories'"); - } catch (err) { - // Non-fatal: the addons table may not exist yet on very old databases. - // Disabling the legacy memories addon is best-effort, but we no longer - // swallow the error silently. - console.warn("[migrations] Non-fatal: failed to disable legacy 'memories' addon:", err); - } - }, - // Migration 69: Place region cache for sub-national Atlas regions - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS place_regions ( - place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, - country_code TEXT NOT NULL, - region_code TEXT NOT NULL, - region_name TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code); - CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code); - `); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS visited_regions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - region_code TEXT NOT NULL, - region_name TEXT NOT NULL, - country_code TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, region_code) - ); - CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code); - `); - }, - // Migration 71: Normalized per-user per-channel notification preferences - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS notification_channel_preferences ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - channel TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (user_id, event_type, channel) - ); - CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id); - `); - - // Migrate data from old notification_preferences table (may not exist on fresh installs) - const tableExists = - (db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='notification_preferences'").get() as - | { name: string } - | undefined) != null; - const oldPrefs: Array> = tableExists - ? (db.prepare('SELECT * FROM notification_preferences').all() as Array>) - : []; - const eventCols: Record = { - trip_invite: 'notify_trip_invite', - booking_change: 'notify_booking_change', - trip_reminder: 'notify_trip_reminder', - vacay_invite: 'notify_vacay_invite', - photos_shared: 'notify_photos_shared', - collab_message: 'notify_collab_message', - packing_tagged: 'notify_packing_tagged', - }; - const insert = db.prepare( - 'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)', - ); - const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => { - for (const [userId, eventType, channel, enabled] of rows) { - insert.run(userId, eventType, channel, enabled); - } - }); - - for (const row of oldPrefs) { - const userId = row.user_id as number; - const webhookEnabled = (row.notify_webhook as number) ?? 0; - const rows: Array<[number, string, string, number]> = []; - for (const [eventType, col] of Object.entries(eventCols)) { - const emailEnabled = (row[col] as number) ?? 1; - // Only insert if disabled (no row = enabled is our default) - if (!emailEnabled) rows.push([userId, eventType, 'email', 0]); - if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]); - } - if (rows.length > 0) insertMany(rows); - } - - // Copy existing single-channel setting to new plural key - db.exec(` - INSERT OR IGNORE INTO app_settings (key, value) - SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel'; - `); - }, - // Migration 72: Drop the old notification_preferences table (data migrated to notification_channel_preferences in migration 71) - () => { - db.exec('DROP TABLE IF EXISTS notification_preferences;'); - }, - // Migration 73: Add reservation_id to budget_items for linking budget entries to reservations - () => { - try { - db.exec( - 'ALTER TABLE budget_items ADD COLUMN reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL', - ); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration 74: Add quantity to packing_items + user_id to packing_bags + bag_members table - () => { - try { - db.exec('ALTER TABLE packing_items ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec( - 'ALTER TABLE packing_bags ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL DEFAULT NULL', - ); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec(` - CREATE TABLE IF NOT EXISTS packing_bag_members ( - bag_id INTEGER NOT NULL REFERENCES packing_bags(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - PRIMARY KEY (bag_id, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_packing_bag_members_bag ON packing_bag_members(bag_id); - `); - // Migrate existing single user_id to bag_members - const bagsWithUser = db.prepare('SELECT id, user_id FROM packing_bags WHERE user_id IS NOT NULL').all() as { - id: number; - user_id: number; - }[]; - const ins = db.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)'); - for (const b of bagsWithUser) ins.run(b.id, b.user_id); - }, - // Migration: Per-day positions for multi-day reservations - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS reservation_day_positions ( - reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, - day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - position REAL NOT NULL, - PRIMARY KEY (reservation_id, day_id) - ); - `); - // Migrate existing global positions to per-day entries - const reservations = db - .prepare( - 'SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL', - ) - .all() as any[]; - const ins = db.prepare( - 'INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)', - ); - for (const r of reservations) { - const startDate = r.reservation_time?.split('T')[0]; - const endDate = r.reservation_end_time?.split('T')[0] || startDate; - if (!startDate) continue; - const matchingDays = db - .prepare('SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?') - .all(r.trip_id, startDate, endDate) as { id: number }[]; - for (const d of matchingDays) ins.run(r.id, d.id, r.day_plan_position); - } - }, - // Migration: Budget category ordering - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS budget_category_order ( - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - category TEXT NOT NULL, - sort_order INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (trip_id, category) - ); - `); - // Seed existing categories with alphabetical order - const rows = db - .prepare('SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category') - .all() as { trip_id: number; category: string }[]; - const ins = db.prepare( - 'INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)', - ); - let lastTripId = -1; - let idx = 0; - for (const r of rows) { - if (r.trip_id !== lastTripId) { - lastTripId = r.trip_id; - idx = 0; - } - ins.run(r.trip_id, r.category, idx++); - } - }, - // Migration: Naver list import addon (default off) - () => { - try { - db.prepare( - ` - INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, - ).run( - 'naver_list_import', - 'Naver List Import', - 'Import places from shared Naver Maps lists', - 'trip', - 'Link2', - 0, - 13, - ); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration: OAuth 2.1 clients, consents, and tokens for MCP - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS oauth_clients ( - id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - client_id TEXT UNIQUE NOT NULL, - client_secret_hash TEXT NOT NULL, - redirect_uris TEXT NOT NULL DEFAULT '[]', - allowed_scopes TEXT NOT NULL DEFAULT '[]', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id); - - CREATE TABLE IF NOT EXISTS oauth_consents ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - scopes TEXT NOT NULL DEFAULT '[]', - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(client_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS oauth_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - access_token_hash TEXT UNIQUE NOT NULL, - refresh_token_hash TEXT UNIQUE NOT NULL, - scopes TEXT NOT NULL DEFAULT '[]', - access_token_expires_at DATETIME NOT NULL, - refresh_token_expires_at DATETIME NOT NULL, - revoked_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id); - CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash); - CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash); - `); - }, - // Migration: Refresh-token rotation chain tracking for replay detection - () => { - db.exec(` - ALTER TABLE oauth_tokens ADD COLUMN parent_token_id INTEGER REFERENCES oauth_tokens(id); - CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id); - `); - }, - // Migration: Public client support for browser-initiated dynamic registration (DCR) - () => { - db.exec(` - ALTER TABLE oauth_clients ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0; - ALTER TABLE oauth_clients ADD COLUMN created_via TEXT NOT NULL DEFAULT 'settings_ui'; - `); - }, - // Migration: Make oauth_clients.user_id nullable to support anonymous RFC 7591 DCR clients - // (must run outside a transaction because PRAGMA foreign_keys cannot change mid-transaction) - { - raw: () => { - db.exec('PRAGMA foreign_keys = OFF'); - try { - db.transaction(() => { - db.exec(` - CREATE TABLE IF NOT EXISTS oauth_clients_new ( - id TEXT PRIMARY KEY, - user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - client_id TEXT UNIQUE NOT NULL, - client_secret_hash TEXT NOT NULL, - redirect_uris TEXT NOT NULL DEFAULT '[]', - allowed_scopes TEXT NOT NULL DEFAULT '[]', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - is_public INTEGER NOT NULL DEFAULT 0, - created_via TEXT NOT NULL DEFAULT 'settings_ui' - ) - `); - db.exec( - `INSERT INTO oauth_clients_new SELECT id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients`, - ); - db.exec(`DROP TABLE oauth_clients`); - db.exec(`ALTER TABLE oauth_clients_new RENAME TO oauth_clients`); - db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`); - db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`); - })(); - } finally { - db.exec('PRAGMA foreign_keys = ON'); - } - }, - }, - // Migration: Add OTP field, skip_ssl column, device_id (did) column, and hint column for Synology Photos - () => { - const cols = db.prepare('PRAGMA table_info(photo_provider_fields)').all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'hint')) { - db.exec(`ALTER TABLE photo_provider_fields ADD COLUMN hint TEXT`); - } - db.exec(` - INSERT OR IGNORE INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) - VALUES - ('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3) - `); - db.exec(`ALTER TABLE users ADD COLUMN synology_skip_ssl INTEGER NOT NULL DEFAULT 0`); - db.exec(`ALTER TABLE users ADD COLUMN synology_did TEXT`); - db.exec(` - INSERT OR IGNORE INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) - VALUES - ('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4) - `); - db.exec(` - UPDATE photo_provider_fields - SET hint = 'providerUrlHintSynology' - WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url' - `); - }, - // Migration 84: Journey addon — trip tracking & travel journal - () => { - // Register addon (disabled by default — opt-in) - db.prepare( - ` - INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) - VALUES ('journey', 'Journey', 'Trip tracking & travel journal — check-ins, photos, daily stories', 'global', 'Compass', 0, '{}', 35) - `, - ).run(); - - // Core journey table - db.exec(` - CREATE TABLE IF NOT EXISTS journeys ( - id TEXT PRIMARY KEY, - trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT, - cover_image TEXT, - status TEXT NOT NULL DEFAULT 'draft', - started_at TEXT, - ended_at TEXT, - is_public INTEGER NOT NULL DEFAULT 0, - public_token TEXT UNIQUE, - settings TEXT DEFAULT '{}', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) - ) - `); - - // Check-ins — visited locations - db.exec(` - CREATE TABLE IF NOT EXISTS journey_checkins ( - id TEXT PRIMARY KEY, - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - name TEXT NOT NULL, - lat REAL, - lng REAL, - address TEXT, - country_code TEXT, - notes TEXT, - checked_in_at TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'manual', - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) - ) - `); - - // Journal entries — daily stories - db.exec(` - CREATE TABLE IF NOT EXISTS journey_entries ( - id TEXT PRIMARY KEY, - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL, - entry_date TEXT NOT NULL, - title TEXT, - body TEXT, - mood TEXT, - weather TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), - updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) - ) - `); - - // Photos — local uploads + provider references (Immich/Synology) - db.exec(` - CREATE TABLE IF NOT EXISTS journey_photos ( - id TEXT PRIMARY KEY, - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL, - entry_id TEXT REFERENCES journey_entries(id) ON DELETE SET NULL, - storage_type TEXT NOT NULL DEFAULT 'local', - asset_id TEXT, - file_path TEXT, - thumbnail_path TEXT, - original_name TEXT, - mime_type TEXT, - size_bytes INTEGER, - caption TEXT, - taken_at TEXT, - lat REAL, - lng REAL, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) - ) - `); - - // GPS trail points (Dawarich integration) - db.exec(` - CREATE TABLE IF NOT EXISTS journey_location_trail ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - lat REAL NOT NULL, - lng REAL NOT NULL, - altitude REAL, - accuracy REAL, - recorded_at TEXT NOT NULL, - source TEXT NOT NULL DEFAULT 'dawarich' - ) - `); - - // Indexes - db.exec(` - CREATE INDEX IF NOT EXISTS idx_journeys_user ON journeys(user_id); - CREATE INDEX IF NOT EXISTS idx_journeys_trip ON journeys(trip_id); - CREATE INDEX IF NOT EXISTS idx_journeys_public_token ON journeys(public_token); - CREATE INDEX IF NOT EXISTS idx_journey_checkins_journey ON journey_checkins(journey_id, checked_in_at); - CREATE INDEX IF NOT EXISTS idx_journey_entries_journey_date ON journey_entries(journey_id, entry_date); - CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id); - CREATE INDEX IF NOT EXISTS idx_journey_photos_checkin ON journey_photos(checkin_id); - CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id); - CREATE INDEX IF NOT EXISTS idx_journey_trail_journey_time ON journey_location_trail(journey_id, recorded_at); - `); - }, - // Migration 85: Journal — richer entry fields for magazine-style design - () => { - // Highlight tags (JSON array), visibility control, hero photo, color accent - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN highlight_tags TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec("ALTER TABLE journey_entries ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'"); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN hero_photo_id TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN color_accent TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN place_name TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN lat REAL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN lng REAL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - - // Check-in: allow a single cover photo reference - try { - db.exec('ALTER TABLE journey_checkins ADD COLUMN photo_id TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - - // Photos: add caption edit timestamp for gallery ordering - try { - db.exec('ALTER TABLE journey_photos ADD COLUMN width INTEGER'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_photos ADD COLUMN height INTEGER'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration 86: Journey multi-trip support + sharing/collaboration - () => { - // Junction table: journey can include multiple trips - db.exec(` - CREATE TABLE IF NOT EXISTS journey_trips ( - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - sort_order INTEGER NOT NULL DEFAULT 0, - added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), - PRIMARY KEY (journey_id, trip_id) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_trips_journey ON journey_trips(journey_id)'); - - // Sharing: invite users to a journey - db.exec(` - CREATE TABLE IF NOT EXISTS journey_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role TEXT NOT NULL DEFAULT 'viewer', - invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), - UNIQUE(journey_id, user_id) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)'); - - // author tracking on entries and checkins - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_checkins ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration 87: Journey rebuild — new schema with trip sync - () => { - // Migrate existing data from old tables into backup, then rebuild - const hasOldJourneys = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='journeys'").get(); - - let oldJourneys: any[] = []; - let oldEntries: any[] = []; - let oldPhotos: any[] = []; - - if (hasOldJourneys) { - // Save existing data before dropping - try { - oldJourneys = db.prepare('SELECT * FROM journeys').all(); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - oldEntries = db.prepare('SELECT * FROM journey_entries').all(); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - oldPhotos = db.prepare('SELECT * FROM journey_photos').all(); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - - // Drop all old journey tables - db.exec('DROP TABLE IF EXISTS journey_location_trail'); - db.exec('DROP TABLE IF EXISTS journey_photos'); - db.exec('DROP TABLE IF EXISTS journey_entries'); - db.exec('DROP TABLE IF EXISTS journey_checkins'); - db.exec('DROP TABLE IF EXISTS journey_members'); - db.exec('DROP TABLE IF EXISTS journey_trips'); - db.exec('DROP TABLE IF EXISTS journeys'); - } - - // New schema - db.exec(` - CREATE TABLE journeys ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL, - title TEXT NOT NULL, - subtitle TEXT, - cover_gradient TEXT, - status TEXT DEFAULT 'draft', - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - - db.exec(` - CREATE TABLE journey_trips ( - journey_id INTEGER NOT NULL, - trip_id INTEGER NOT NULL, - added_at INTEGER NOT NULL, - PRIMARY KEY (journey_id, trip_id), - FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, - FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE - ) - `); - - db.exec(` - CREATE TABLE journey_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id INTEGER NOT NULL, - source_trip_id INTEGER, - source_place_id INTEGER, - author_id INTEGER NOT NULL, - type TEXT NOT NULL, - title TEXT, - story TEXT, - entry_date TEXT NOT NULL, - entry_time TEXT, - location_name TEXT, - location_lat REAL, - location_lng REAL, - mood TEXT, - weather TEXT, - tags TEXT, - visibility TEXT DEFAULT 'private', - sort_order INTEGER DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, - FOREIGN KEY (source_trip_id) REFERENCES trips(id) ON DELETE SET NULL, - FOREIGN KEY (source_place_id) REFERENCES places(id) ON DELETE SET NULL, - FOREIGN KEY (author_id) REFERENCES users(id) - ) - `); - - db.exec(` - CREATE TABLE journey_photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - entry_id INTEGER NOT NULL, - file_path TEXT NOT NULL, - thumbnail_path TEXT, - caption TEXT, - sort_order INTEGER DEFAULT 0, - width INTEGER, - height INTEGER, - created_at INTEGER NOT NULL, - FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE - ) - `); - - db.exec(` - CREATE TABLE journey_contributors ( - journey_id INTEGER NOT NULL, - user_id INTEGER NOT NULL, - role TEXT NOT NULL, - added_at INTEGER NOT NULL, - PRIMARY KEY (journey_id, user_id), - FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, - FOREIGN KEY (user_id) REFERENCES users(id) - ) - `); - - // Indexes - db.exec(` - CREATE INDEX idx_journeys_user ON journeys(user_id); - CREATE INDEX idx_journey_entries_journey ON journey_entries(journey_id, entry_date); - CREATE INDEX idx_journey_entries_source ON journey_entries(source_place_id); - CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id); - CREATE INDEX idx_journey_trips_journey ON journey_trips(journey_id); - CREATE INDEX idx_journey_contributors_user ON journey_contributors(user_id); - `); - - // Re-import old data if it existed - if (oldJourneys.length > 0) { - const ts = Date.now(); - const journeyIdMap = new Map(); // old TEXT id -> new INTEGER id - - for (const j of oldJourneys) { - const res = db - .prepare( - ` - INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - `, - ) - .run( - j.user_id, - j.title || 'Untitled Journey', - j.description || null, - j.status || 'draft', - j.created_at ? new Date(j.created_at).getTime() : ts, - j.updated_at ? new Date(j.updated_at).getTime() : ts, - ); - journeyIdMap.set(j.id, Number(res.lastInsertRowid)); - - // Add owner as contributor - db.prepare( - ` - INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at) - VALUES (?, ?, 'owner', ?) - `, - ).run(Number(res.lastInsertRowid), j.user_id, ts); - - // Link trip if old journey had one - if (j.trip_id) { - try { - db.prepare( - ` - INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) - VALUES (?, ?, ?) - `, - ).run(Number(res.lastInsertRowid), j.trip_id, ts); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - } - } - - // Migrate entries - const entryIdMap = new Map(); - for (const e of oldEntries) { - const newJourneyId = journeyIdMap.get(e.journey_id); - if (!newJourneyId) continue; - - const res = db - .prepare( - ` - INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, visibility, sort_order, created_at, updated_at) - VALUES (?, ?, 'entry', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ) - .run( - newJourneyId, - e.user_id || oldJourneys.find((j: any) => j.id === e.journey_id)?.user_id || 1, - e.title || null, - e.body || null, - e.entry_date || new Date().toISOString().split('T')[0], - e.place_name || null, - e.lat || null, - e.lng || null, - e.mood || null, - e.weather || null, - e.visibility || 'private', - e.sort_order || 0, - e.created_at ? new Date(e.created_at).getTime() : ts, - e.updated_at ? new Date(e.updated_at).getTime() : ts, - ); - entryIdMap.set(e.id, Number(res.lastInsertRowid)); - } - - // Migrate photos - for (const p of oldPhotos) { - const newEntryId = p.entry_id ? entryIdMap.get(p.entry_id) : null; - if (!newEntryId || !p.file_path) continue; - - db.prepare( - ` - INSERT INTO journey_photos (entry_id, file_path, thumbnail_path, caption, sort_order, width, height, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - newEntryId, - p.file_path, - p.thumbnail_path || null, - p.caption || null, - p.sort_order || 0, - p.width || null, - p.height || null, - p.created_at ? new Date(p.created_at).getTime() : ts, - ); - } - - console.log( - `[DB] Journey migration: imported ${journeyIdMap.size} journeys, ${entryIdMap.size} entries, photos migrated`, - ); - } - }, - // Migration 88: Journey photos — provider support (Immich/Synology) - () => { - try { - db.exec("ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'"); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_photos ADD COLUMN asset_id TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - db.exec('ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - // file_path was NOT NULL — recreate table to make it nullable - const hasProvider = db.prepare("SELECT 1 FROM pragma_table_info('journey_photos') WHERE name = 'provider'").get(); - if (hasProvider) { - // Already has the column, just ensure file_path is nullable by recreating - try { - db.exec(` - CREATE TABLE journey_photos_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - entry_id INTEGER NOT NULL, - provider TEXT NOT NULL DEFAULT 'local', - asset_id TEXT, - owner_id INTEGER REFERENCES users(id), - file_path TEXT, - thumbnail_path TEXT, - caption TEXT, - sort_order INTEGER DEFAULT 0, - width INTEGER, - height INTEGER, - shared INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE - ); - INSERT INTO journey_photos_new SELECT id, entry_id, provider, asset_id, owner_id, file_path, thumbnail_path, caption, sort_order, width, height, shared, created_at FROM journey_photos; - DROP TABLE journey_photos; - ALTER TABLE journey_photos_new RENAME TO journey_photos; - CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id); - `); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - } - }, - // Migration 89: Journey cover image - () => { - try { - db.exec('ALTER TABLE journeys ADD COLUMN cover_image TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration 90: Pros/Cons for journey entries - () => { - try { - db.exec('ALTER TABLE journey_entries ADD COLUMN pros_cons TEXT'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration 91: Journey share tokens - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS journey_share_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id INTEGER NOT NULL, - token TEXT NOT NULL UNIQUE, - created_by INTEGER NOT NULL, - share_timeline INTEGER DEFAULT 1, - share_gallery INTEGER DEFAULT 1, - share_map INTEGER DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, - FOREIGN KEY (created_by) REFERENCES users(id) - ) - `); - db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)'); - }, - // Migration: Vacay week_start setting (0=Sunday, 1=Monday default) - () => { - try { - db.exec('ALTER TABLE vacay_plans ADD COLUMN week_start INTEGER NOT NULL DEFAULT 1'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration: Unified Photo Provider Abstraction Layer (#584) - // Central trek_photos registry; trip_photos + journey_photos reference via photo_id - () => { - // 1. Create the central photo registry - db.exec(` - CREATE TABLE IF NOT EXISTS trek_photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider TEXT NOT NULL, - asset_id TEXT, - owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - file_path TEXT, - thumbnail_path TEXT, - width INTEGER, - height INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - db.exec( - 'CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL', - ); - db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)'); - - // 2. Migrate trip_photos → trek_photos + photo_id FK - const tripPhotosExists = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'trip_photos'") - .get(); - if (tripPhotosExists) { - // Detect schema variant: old (immich_asset_id) vs new (asset_id + provider) - const tpCols = db.prepare("PRAGMA table_info('trip_photos')").all() as Array<{ name: string }>; - const tpColNames = new Set(tpCols.map((c) => c.name)); - const hasProvider = tpColNames.has('provider'); - const assetCol = tpColNames.has('asset_id') - ? 'asset_id' - : tpColNames.has('immich_asset_id') - ? 'immich_asset_id' - : null; - const hasAlbumLink = tpColNames.has('album_link_id'); - - if (assetCol) { - const providerExpr = hasProvider ? 'provider' : "'immich'"; - // Qualified alias needed in JOIN context where both trip_photos and trek_photos have provider - const providerJoinExpr = hasProvider ? 'tp.provider' : "'immich'"; - const sharedExpr = tpColNames.has('shared') ? 'shared' : '1'; - const addedAtExpr = tpColNames.has('added_at') - ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' - : 'CURRENT_TIMESTAMP'; - const albumLinkExpr = hasAlbumLink ? 'album_link_id' : 'NULL'; - - // Insert existing trip photo references into trek_photos - db.exec(` - INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at) - SELECT DISTINCT ${providerExpr}, ${assetCol}, user_id, ${addedAtExpr} - FROM trip_photos - WHERE ${assetCol} IS NOT NULL AND TRIM(${assetCol}) != '' - `); - - // Recreate trip_photos with photo_id FK - db.exec(` - CREATE TABLE trip_photos_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, - shared INTEGER NOT NULL DEFAULT 1, - album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL, - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, photo_id) - ) - `); - db.exec(` - INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at) - SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr} - FROM trip_photos tp - JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id - `); - } else { - // No asset column at all — just recreate empty - db.exec(` - CREATE TABLE trip_photos_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, - shared INTEGER NOT NULL DEFAULT 1, - album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL, - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id, photo_id) - ) - `); - } - db.exec('DROP TABLE trip_photos'); - db.exec('ALTER TABLE trip_photos_new RENAME TO trip_photos'); - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)'); - } - - // 3. Migrate journey_photos → trek_photos + photo_id FK - const journeyPhotosExists = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'") - .get(); - if (journeyPhotosExists) { - // Insert provider-based journey photos into trek_photos - db.exec(` - INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at) - SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at - FROM journey_photos - WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != '' - `); - // Insert local journey photos into trek_photos (each is unique) - db.exec(` - INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at) - SELECT 'local', file_path, thumbnail_path, width, height, created_at - FROM journey_photos - WHERE provider = 'local' AND file_path IS NOT NULL - `); - - // Recreate journey_photos with photo_id FK - db.exec(` - CREATE TABLE journey_photos_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - entry_id INTEGER NOT NULL, - photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, - caption TEXT, - sort_order INTEGER DEFAULT 0, - shared INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL, - FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE - ) - `); - // Migrate provider photos - db.exec(` - INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) - SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at - FROM journey_photos jp - JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id - WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL - `); - // Migrate local photos (match by file_path) - db.exec(` - INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) - SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at - FROM journey_photos jp - JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path - WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL - `); - db.exec('DROP TABLE journey_photos'); - db.exec('ALTER TABLE journey_photos_new RENAME TO journey_photos'); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)'); - } - }, - // Migration 99: hide_skeletons per-user setting on journey_contributors - () => { - try { - db.exec('ALTER TABLE journey_contributors ADD COLUMN hide_skeletons INTEGER NOT NULL DEFAULT 0'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration 100: Idempotency keys for offline mutation replay - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS idempotency_keys ( - key TEXT NOT NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - method TEXT NOT NULL, - path TEXT NOT NULL, - status_code INTEGER NOT NULL, - response_body TEXT NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - PRIMARY KEY (key, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at); - `); - }, - - // Migration 101: Enable naver_list_import by default - () => { - db.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run(); - }, - - // Migration 102: Add check_in_end column for check-in time ranges - () => { - try { - db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in_end TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration 103: System notices — user tracking columns + dismissals table - () => { - db.exec(`ALTER TABLE users ADD COLUMN first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`); - db.exec(`ALTER TABLE users ADD COLUMN login_count INTEGER NOT NULL DEFAULT 0`); - db.exec(` - CREATE TABLE IF NOT EXISTS user_notice_dismissals ( - user_id INTEGER NOT NULL, - notice_id TEXT NOT NULL, - dismissed_at INTEGER NOT NULL, - PRIMARY KEY (user_id, notice_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) - `); - }, - // Migration 104: Passphrase support for Synology shared-album links (#689) - () => { - try { - db.exec('ALTER TABLE trip_album_links ADD COLUMN passphrase TEXT DEFAULT NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE trek_photos ADD COLUMN passphrase TEXT DEFAULT NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration 105: Persistent Google place photo disk cache registry - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS google_place_photo_meta ( - place_id TEXT PRIMARY KEY, - attribution TEXT, - fetched_at INTEGER NOT NULL, - error_at INTEGER - ) - `); - }, - // Migration 106: Persistent Place Details row cache - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS place_details_cache ( - place_id TEXT NOT NULL, - lang TEXT NOT NULL DEFAULT '', - expanded INTEGER NOT NULL DEFAULT 0, - payload_json TEXT NOT NULL, - fetched_at INTEGER NOT NULL, - PRIMARY KEY (place_id, lang, expanded) - ) - `); - }, - // Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs - { - raw: () => { - db.exec(` - UPDATE places - SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes', - updated_at = CURRENT_TIMESTAMP - WHERE google_place_id IS NOT NULL - AND image_url IS NOT NULL - AND image_url != '' - AND ( - (image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%') - OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%') - ) - `); - }, - }, - // Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology) - () => - db.exec(` - CREATE TABLE IF NOT EXISTS trek_photo_cache_meta ( - cache_key TEXT PRIMARY KEY, - content_type TEXT NOT NULL DEFAULT 'image/jpeg', - fetched_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at); - `), - // Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587 - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS reservation_endpoints ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, - role TEXT NOT NULL, - sequence INTEGER NOT NULL DEFAULT 0, - name TEXT NOT NULL, - code TEXT, - lat REAL NOT NULL, - lng REAL NOT NULL, - timezone TEXT, - local_time TEXT, - local_date TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - db.exec( - 'CREATE INDEX IF NOT EXISTS idx_reservation_endpoints_reservation_id ON reservation_endpoints(reservation_id)', - ); - try { - db.exec('ALTER TABLE reservations ADD COLUMN needs_review INTEGER NOT NULL DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration 110 — link transport reservations to days via day_id / end_day_id - () => { - try { - db.exec('ALTER TABLE reservations ADD COLUMN end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - - db.exec(` - UPDATE reservations - SET day_id = ( - SELECT d.id FROM days d - WHERE d.trip_id = reservations.trip_id - AND d.date = substr(reservations.reservation_time, 1, 10) - LIMIT 1 - ) - WHERE type IN ('flight','train','car','cruise','bus') - AND reservation_time IS NOT NULL - AND day_id IS NULL - `); - - db.exec(` - UPDATE reservations - SET end_day_id = ( - SELECT d.id FROM days d - WHERE d.trip_id = reservations.trip_id - AND d.date = substr(reservations.reservation_end_time, 1, 10) - LIMIT 1 - ) - WHERE type IN ('flight','train','car','cruise','bus') - AND reservation_end_time IS NOT NULL - AND end_day_id IS NULL - AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10) - `); - }, - // Migration 111: opt-in Immich auto-upload — users column only (#730) - // Default is off — uploading to Immich must be an explicit choice, not a - // side effect of having a writable API key. - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN immich_auto_upload INTEGER NOT NULL DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration 112: expose immich auto-upload toggle in the Settings UI (#730) - // Runs after Immich provider seeding so the FK to photo_providers holds. - () => { - try { - const hasTable = db - .prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('photo_providers', 'photo_provider_fields')", - ) - .all() as Array<{ name: string }>; - const hasProviders = hasTable.some((t) => t.name === 'photo_providers'); - const hasFields = hasTable.some((t) => t.name === 'photo_provider_fields'); - if (hasProviders && hasFields) { - const immichRow = db.prepare("SELECT 1 FROM photo_providers WHERE id = 'immich' LIMIT 1").get(); - if (immichRow) { - db.prepare( - ` - INSERT OR IGNORE INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) - VALUES - ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, 0, 0, 'auto_upload', 'auto_upload', 5) - `, - ).run(); - } - } - } catch (err: any) { - if (!err.message?.includes('no such table') && !err.message?.includes('FOREIGN KEY')) throw err; - } - }, - // Migration: RFC 8707 resource indicators — audience-bind OAuth tokens to /mcp - () => { - try { - db.exec('ALTER TABLE oauth_tokens ADD COLUMN audience TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration: password reset — add password_version for session - // invalidation, and a token table keyed by SHA-256 hash (raw tokens - // never hit the DB). - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN password_version INTEGER NOT NULL DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec(` - CREATE TABLE IF NOT EXISTS password_reset_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - consumed_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_ip TEXT - ); - CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id); - CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash); - `); - }, - // Migration: todo due-date reminders — track when we last sent a - // reminder for each todo so we don't spam the same notification - // every day the scheduler runs. - () => { - try { - db.exec('ALTER TABLE todo_items ADD COLUMN reminded_at DATETIME'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Migration: security audit batch 1 — columns + indexes required - // by several fixes bundled into one PR. - // - share_tokens.expires_at: public share links now get a 90-day - // TTL by default; existing rows stay NULL (= no expiry) to avoid - // silently breaking already-published links. - // - Missing indexes on high-cardinality query paths (see PERF-H1 - // in the audit): every listTrips() used to full-scan trips on - // user_id, and notifications/photos/reservations had similar - // gaps. - () => { - try { - db.exec('ALTER TABLE share_tokens ADD COLUMN expires_at TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec(` - CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id); - CREATE INDEX IF NOT EXISTS idx_trips_created_at ON trips(created_at DESC); - CREATE INDEX IF NOT EXISTS idx_photos_day_id ON photos(day_id); - CREATE INDEX IF NOT EXISTS idx_photos_place_id ON photos(place_id); - CREATE INDEX IF NOT EXISTS idx_reservations_day_id ON reservations(day_id); - CREATE INDEX IF NOT EXISTS idx_share_tokens_token ON share_tokens(token); - `); - try { - // day_accommodations may have either start_day_id/end_day_id or a - // single day_id depending on how far the schema has evolved; - // build whichever index makes sense for the live columns. - const cols = db.prepare("PRAGMA table_info('day_accommodations')").all() as Array<{ name: string }>; - const names = new Set(cols.map((c) => c.name)); - if (names.has('start_day_id')) - db.exec('CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)'); - if (names.has('end_day_id')) - db.exec('CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)'); - } catch (err) { - // Non-fatal: day_accommodations may not exist on very old installs. - console.warn('[migrations] Non-fatal migration step failed:', err); - } - try { - // notifications schema has varied; probe before indexing. - const cols = db.prepare("PRAGMA table_info('notifications')").all() as Array<{ name: string }>; - const names = new Set(cols.map((c) => c.name)); - if (names.has('target') && names.has('scope')) { - db.exec('CREATE INDEX IF NOT EXISTS idx_notifications_target_scope ON notifications(target, scope)'); - } - } catch (err) { - // Non-fatal: notifications table may not exist on very old installs. - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - // Migration: widen idempotency_keys primary key to (key, user_id, - // method, path). The middleware lookup was widened in the same audit - // batch so a reused X-Idempotency-Key against a different endpoint - // does not replay the cached body of an unrelated request. The old - // PK was only (key, user_id), so the `INSERT OR IGNORE` on the - // second endpoint silently skipped — the cache never stored request - // B's response and replays re-executed the handler. Rebuild the - // table with the widened PK, preserving existing rows (the old PK - // guarantees no conflicts in the new, strictly looser unique key). - () => { - const hasTable = db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'idempotency_keys'") - .get(); - if (!hasTable) return; - db.exec(` - CREATE TABLE idempotency_keys_new ( - key TEXT NOT NULL, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - method TEXT NOT NULL, - path TEXT NOT NULL, - status_code INTEGER NOT NULL, - response_body TEXT NOT NULL, - created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), - PRIMARY KEY (key, user_id, method, path) - ); - INSERT INTO idempotency_keys_new (key, user_id, method, path, status_code, response_body, created_at) - SELECT key, user_id, method, path, status_code, response_body, created_at FROM idempotency_keys; - DROP TABLE idempotency_keys; - ALTER TABLE idempotency_keys_new RENAME TO idempotency_keys; - CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at); - `); - }, - // SEC-H6: revoke all OAuth tokens issued before audience binding was - // enforced. mcp/index.ts now unconditionally checks audience; tokens - // with audience=null would be permanently rejected by the check, so - // removing them here avoids leaving dead rows and makes the intent clear. - () => { - const hasCol = db.prepare("SELECT name FROM pragma_table_info('oauth_tokens') WHERE name = 'audience'").get(); - if (!hasCol) return; - db.prepare('DELETE FROM oauth_tokens WHERE audience IS NULL').run(); - }, - // Remove NOT NULL constraint on day_accommodations.place_id so hotel - // reservations created from the Bookings tab without a linked place can - // still persist their date range. Change ON DELETE CASCADE → SET NULL so - // deleting a place orphans the accommodation row instead of cascading. - () => { - db.exec(` - CREATE TABLE day_accommodations_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - check_in TEXT, - check_in_end TEXT, - check_out TEXT, - confirmation TEXT, - notes TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - INSERT INTO day_accommodations_new - SELECT id, trip_id, place_id, start_day_id, end_day_id, - check_in, check_in_end, check_out, confirmation, notes, created_at - FROM day_accommodations; - DROP TABLE day_accommodations; - ALTER TABLE day_accommodations_new RENAME TO day_accommodations; - CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id); - CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id); - CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id); - `); - }, - // Migration: null out proxy image_url entries that have no backing disk cache. - // Migrations 107 and the migration below wrote /api/maps/place-photo//bytes - // into places.image_url without actually fetching/caching the photo bytes. The - // photoService short-circuits on that prefix and hits /bytes directly → 404. - // Rows with a confirmed disk cache entry in google_place_photo_meta are left alone; - // only stale proxy URLs (never actually fetched) are cleared so the normal - // fetch-and-cache flow can repopulate them. - () => { - db.exec(` - UPDATE places - SET image_url = NULL, updated_at = CURRENT_TIMESTAMP - WHERE image_url LIKE '/api/maps/place-photo/%/bytes' - AND google_place_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM google_place_photo_meta - WHERE place_id = places.google_place_id - AND error_at IS NULL - ) - `); - }, - // Migration: clear legacy Google photo URLs missed by Migration 107. - // Migration 107 matched /places/%/photos/% only; lh3.googleusercontent.com URLs use - // /place-photos/ or /places/ paths and were skipped. NULL those stale URLs - // so the normal fetch-and-cache flow repopulates image_url with a real proxy URL. - () => { - db.exec(` - UPDATE places - SET image_url = NULL, - updated_at = CURRENT_TIMESTAMP - WHERE image_url IS NOT NULL - AND image_url != '' - AND image_url NOT LIKE '/api/maps/place-photo/%' - AND ( - image_url LIKE 'http://%googleusercontent.com/%' - OR image_url LIKE 'https://%googleusercontent.com/%' - OR image_url LIKE 'http://%places.googleapis.com/%' - OR image_url LIKE 'https://%places.googleapis.com/%' - ) - `); - }, - // Migration 121: Journey gallery refactor — decouple photo ownership from - // entries. journey_photos becomes a per-journey gallery (one row per unique - // photo per journey). A new junction table journey_entry_photos links - // gallery photos to the entries that reference them, allowing the same - // photo to appear in multiple entries without duplication. Synthetic - // wrapper entries ('Gallery', '[Trip Photos]') created by the old model - // are removed — the gallery table replaces them. - () => { - const hasOld = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos'").get(); - const hasBackup = db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'journey_photos_old'") - .get(); - if (hasOld && !hasBackup) { - db.exec('ALTER TABLE journey_photos RENAME TO journey_photos_old'); - } - - db.exec(` - CREATE TABLE IF NOT EXISTS journey_photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, - caption TEXT, - shared INTEGER DEFAULT 0, - sort_order INTEGER DEFAULT 0, - provider TEXT, - asset_id TEXT, - owner_id INTEGER, - created_at INTEGER NOT NULL, - UNIQUE(journey_id, photo_id) - ) - `); - - db.exec(` - CREATE TABLE IF NOT EXISTS journey_entry_photos ( - entry_id INTEGER NOT NULL REFERENCES journey_entries(id) ON DELETE CASCADE, - journey_photo_id INTEGER NOT NULL REFERENCES journey_photos(id) ON DELETE CASCADE, - sort_order INTEGER DEFAULT 0, - created_at INTEGER NOT NULL, - PRIMARY KEY(entry_id, journey_photo_id) - ) - `); - - if (hasOld || hasBackup) { - // Backfill gallery: deduplicate by (journey_id, photo_id), keeping - // the earliest row (MIN(id) = earliest created_at on AUTOINCREMENT). - db.exec(` - INSERT OR IGNORE INTO journey_photos - (journey_id, photo_id, caption, shared, sort_order, created_at) - SELECT - je.journey_id, - jpo.photo_id, - jpo.caption, - jpo.shared, - jpo.sort_order, - jpo.created_at - FROM journey_photos_old jpo - JOIN journey_entries je ON je.id = jpo.entry_id - WHERE jpo.id IN ( - SELECT MIN(jpo2.id) - FROM journey_photos_old jpo2 - JOIN journey_entries je2 ON je2.id = jpo2.entry_id - GROUP BY je2.journey_id, jpo2.photo_id - ) - `); - - // Backfill junction: one row per (entry_id, photo_id), resolved to - // the new gallery ids. - db.exec(` - INSERT OR IGNORE INTO journey_entry_photos - (entry_id, journey_photo_id, sort_order, created_at) - SELECT - jpo.entry_id, - jp.id, - jpo.sort_order, - jpo.created_at - FROM journey_photos_old jpo - JOIN journey_entries je ON je.id = jpo.entry_id - JOIN journey_photos jp - ON jp.journey_id = je.journey_id - AND jp.photo_id = jpo.photo_id - `); - - db.exec('DROP TABLE journey_photos_old'); - } - - // Remove synthetic wrapper entries replaced by the gallery model. - // ON DELETE CASCADE on journey_entry_photos cleans up junction rows. - db.prepare("DELETE FROM journey_entries WHERE title IN ('Gallery', '[Trip Photos]')").run(); - - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)'); - }, - // Migration 122: Correct stale day_id / end_day_id on non-transport - // reservations. Migration 110 only backfilled transport types; tours, - // restaurants, events and "other" bookings kept a stale day_id from - // older code paths that often defaulted to the first day of the trip. - // Starting with v3.0.0 the planner renders reservations by day_id - // instead of reservation_time, so those stale rows show up on the - // wrong day. This migration nulls out day_id / end_day_id values that - // don't match the reservation's time and then backfills them from - // reservation_time / reservation_end_time. - () => { - db.exec(` - UPDATE reservations - SET day_id = NULL - WHERE reservation_time IS NOT NULL - AND day_id IS NOT NULL - AND type != 'hotel' - AND NOT EXISTS ( - SELECT 1 FROM days d - WHERE d.id = reservations.day_id - AND d.date = substr(reservations.reservation_time, 1, 10) - ) - `); - - db.exec(` - UPDATE reservations - SET end_day_id = NULL - WHERE reservation_end_time IS NOT NULL - AND end_day_id IS NOT NULL - AND type != 'hotel' - AND NOT EXISTS ( - SELECT 1 FROM days d - WHERE d.id = reservations.end_day_id - AND d.date = substr(reservations.reservation_end_time, 1, 10) - ) - `); - - db.exec(` - UPDATE reservations - SET day_id = ( - SELECT d.id FROM days d - WHERE d.trip_id = reservations.trip_id - AND d.date = substr(reservations.reservation_time, 1, 10) - LIMIT 1 - ) - WHERE type != 'hotel' - AND reservation_time IS NOT NULL - AND day_id IS NULL - `); - - db.exec(` - UPDATE reservations - SET end_day_id = ( - SELECT d.id FROM days d - WHERE d.trip_id = reservations.trip_id - AND d.date = substr(reservations.reservation_end_time, 1, 10) - LIMIT 1 - ) - WHERE type != 'hotel' - AND reservation_end_time IS NOT NULL - AND end_day_id IS NULL - AND substr(reservations.reservation_end_time, 1, 10) - != substr(reservations.reservation_time, 1, 10) - `); - }, - // #846: make sort_order authoritative within a day. Previous ORDER BY put - // entry_time before sort_order, silently ignoring reorder clicks when two - // same-date entries had different times. Backfill renumbers using the old - // effective key (entry_time ASC, id ASC) so existing journeys retain their - // current visual order. - () => { - db.exec(` - WITH ranked AS ( - SELECT id, - ROW_NUMBER() OVER ( - PARTITION BY journey_id, entry_date - ORDER BY entry_time ASC, id ASC - ) - 1 AS rn - FROM journey_entries - ) - UPDATE journey_entries - SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id) - `); - db.exec( - 'CREATE INDEX IF NOT EXISTS idx_journey_entries_order ' + - 'ON journey_entries(journey_id, entry_date, sort_order)', - ); - }, - // Swap inverted start_day_id/end_day_id pairs in day_accommodations caused - // by the old Math.min/Math.max picker bug (pre-8e05ba7) which used raw IDs - // instead of positional order on trips with non-monotonic day ID layouts. - () => { - db.exec(` - UPDATE day_accommodations - SET start_day_id = end_day_id, end_day_id = start_day_id - WHERE (SELECT day_number FROM days WHERE id = start_day_id) - > (SELECT day_number FROM days WHERE id = end_day_id) - `); - }, - // prepare migration to nest + typeorm - () => { - db.exec( - `CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL);`, - ); - db.exec(`INSERT INTO migrations (timestamp, name) VALUES (1777810195344, 'InitialSchema1777810195344');`); - db.prepare("INSERT INTO app_settings (key, value) VALUES ('app_version', ?)").run( - readEnv().app.appVersion || '3.0.14', - ); - }, - // trim leading/trailing whitespace from stored usernames and emails - () => { - const hadCollision = trimUserWhitespace(db); - if (hadCollision) { - db.prepare( - "INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')", - ).run(); - } - }, - () => { - db.exec( - `CREATE TABLE IF NOT EXISTS schema_version_new (id INTEGER PRIMARY KEY AUTOINCREMENT,version INTEGER NOT NULL)`, - ); - db.exec(`INSERT INTO schema_version_new (version) SELECT version FROM schema_version`); - db.exec(`DROP TABLE schema_version`); - db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`); - db.prepare("UPDATE app_settings SET value = ? WHERE key = 'app_version'").run( - readEnv().app.appVersion || '3.0.15', - ); - }, - // Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential - // clients to skip the browser consent flow entirely and obtain tokens directly - // via client_id + client_secret. Flag is immutable after creation so existing - // authorization-code clients are not silently upgraded. - () => { - try { - db.exec('ALTER TABLE oauth_clients ADD COLUMN allows_client_credentials INTEGER NOT NULL DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Drop stale atlas cache rows for territories that used to resolve to their - // surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy, - // etc.) before their own bounding boxes existed. The next atlas stats request - // re-resolves any place inside these boxes with the corrected country code. - () => { - const enclaveBoxes: [number, number, number, number][] = [ - [113.83, 22.15, 114.43, 22.56], // HK - [113.53, 22.1, 113.6, 22.21], // MO - [12.4, 43.89, 12.52, 43.99], // SM - [12.44, 41.9, 12.46, 41.91], // VA - [7.4, 43.72, 7.44, 43.75], // MC - [9.47, 47.05, 9.64, 47.27], // LI - [-5.36, 36.11, -5.33, 36.16], // GI - [-67.3, 17.88, -65.22, 18.53], // PR - ]; - try { - const del = db.prepare( - `DELETE FROM place_regions WHERE place_id IN ( - SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ? - )`, - ); - for (const [minLng, minLat, maxLng, maxLat] of enclaveBoxes) { - del.run(minLat, maxLat, minLng, maxLng); - } - } catch (err: any) { - if (!err.message?.includes('no such table')) throw err; - } - }, - // Costs rework (budget → "Costs", Tricount/Splitwise style). Adds, additively - // and without touching existing rows: - // - per-expense currency + exchange_rate, so an expense can be entered in a - // foreign currency and converted to the trip base currency (NULL currency = - // base currency; rate 1.0). Closes the multi-currency request (#551). - // - budget_item_payers: several people can each have paid part of one expense - // (amounts in the expense currency), replacing the single paid_by_user_id. - // - budget_settlements: persisted "X paid Y" transfers so the settle-up - // history (with undo) is shared across all trip members. - // The equal-split participants stay in budget_item_members. The single legacy - // payer is backfilled into budget_item_payers as one payer covering the total. - () => { - try { - db.exec('ALTER TABLE budget_items ADD COLUMN currency TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE budget_items ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - - db.exec(` - CREATE TABLE IF NOT EXISTS budget_item_payers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - amount REAL NOT NULL DEFAULT 0, - UNIQUE(budget_item_id, user_id) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_budget_item_payers_item ON budget_item_payers(budget_item_id)'); - - db.exec(` - CREATE TABLE IF NOT EXISTS budget_settlements ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - amount REAL NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_by_user_id INTEGER REFERENCES users(id) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_budget_settlements_trip ON budget_settlements(trip_id)'); - - // Backfill the legacy single payer: that person paid the full total of the - // expense, in the (base) currency the existing amount was already stored in. - try { - db.exec(` - INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) - SELECT id, paid_by_user_id, total_price - FROM budget_items - WHERE paid_by_user_id IS NOT NULL - `); - } catch (err: any) { - if (!err.message?.includes('no such column')) throw err; - } - }, - // Rename the "Budget Planner" addon to "Costs" in the admin add-on list. This - // is a display rename only — the addon id, tables, permissions and MCP tools - // all stay 'budget'. Scoped to the default name so a customised one is kept. - () => { - db.prepare( - "UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'", - ).run(); - }, - // WebAuthn / passkey support: per-user credentials + single-use login - // challenges. Additive (CREATE TABLE IF NOT EXISTS) so existing installs are - // untouched; both tables also live in schema.ts for fresh installs. - () => - db.exec(` - CREATE TABLE IF NOT EXISTS webauthn_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - credential_id TEXT NOT NULL UNIQUE, - public_key BLOB NOT NULL, - counter INTEGER NOT NULL DEFAULT 0, - transports TEXT, - device_type TEXT, - backed_up INTEGER NOT NULL DEFAULT 0, - name TEXT, - aaguid TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_used_at DATETIME - ); - CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); - CREATE TABLE IF NOT EXISTS webauthn_challenges ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - challenge TEXT NOT NULL UNIQUE, - user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - type TEXT NOT NULL, - expires_at INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at); - `), - // Atlas dropped Natural Earth for geoBoundaries. Manually-marked sub-national - // regions (`visited_regions`) stored the OLD Natural Earth ISO-3166-2 codes; some no - // longer match any polygon in the new bundle and would stop highlighting. Reconcile - // every row against the ACTUAL shipped admin-1 bundle so this covers *all* countries, - // not just one hand-listed reform: - // 1. code still present in the new bundle → leave it (already correct); - // 2. else a region in the same country shares → adopt that region's code+name - // the stored region_name (case-insensitive) (handles code re-spellings, e.g. - // ES-AN → ES_AND, names unchanged); - // 3. else a curated merge crosswalk maps it → adopt the merged region (handles - // (region absorbed into a *renamed* one) reforms where the name changed, - // which step 2 cannot catch); - // 4. else → leave as-is (cannot be resolved; the client's name fallback may still - // highlight it, and nothing is destroyed). - // Other Atlas tables need NO remap: `visited_countries` / `bucket_list` hold only - // ISO-3166-1 alpha-2 codes (invariant across the swap), `bucket_list.name` is free - // text we must not auto-rewrite, and `place_regions` is a re-derivable Nominatim cache. - () => { - type Row = { id: number; region_code: string; region_name: string; country_code: string }; - const rows = db.prepare('SELECT id, region_code, region_name, country_code FROM visited_regions').all() as Row[]; - if (rows.length === 0) return; // nothing marked → skip the bundle read entirely - - // Index the shipped admin-1 bundle: valid codes, name→code per country, code→name. - // __dirname resolves ../../assets under both dist (dist/db) and tests (src/db). - let features: { properties?: { iso_a2?: string; iso_3166_2?: string; name?: string } }[]; - try { - const file = path.join(__dirname, '..', '..', 'assets', 'atlas', 'admin1.geojson.gz'); - features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || []; - } catch { - features = []; // bundle missing → degrade to the curated crosswalk below - } - const validCodes = new Set(); - const nameToCode = new Map(); // `${A2}|${nameLower}` → code - const codeToName = new Map(); - for (const f of features) { - const a2 = (f.properties?.iso_a2 || '').toUpperCase(); - const code = f.properties?.iso_3166_2 || ''; - const name = f.properties?.name || ''; - if (!code) continue; - validCodes.add(code); - if (!codeToName.has(code)) codeToName.set(code, name); - if (a2 && name) nameToCode.set(`${a2}|${name.toLowerCase()}`, code); - } - - // Curated crosswalk for regions absorbed into a *renamed* successor (step 2 can't - // match these because the name changed). Norway's 2018/2020 reforms; extend as the - // pinned geoBoundaries dataset gains further reforms. - const MERGE_CROSSWALK: Record = { - 'NO-04': 'NO-34', - 'NO-05': 'NO-34', // Hedmark, Oppland → Innlandet - 'NO-12': 'NO-46', - 'NO-14': 'NO-46', // Hordaland, Sogn og Fjordane → Vestland - 'NO-09': 'NO-42', - 'NO-10': 'NO-42', // Aust-/Vest-Agder → Agder - 'NO-01': 'NO-30', - 'NO-02': 'NO-30', - 'NO-06': 'NO-30', // Østfold/Akershus/Buskerud → Viken - 'NO-07': 'NO-38', - 'NO-08': 'NO-38', // Vestfold, Telemark → Vestfold og Telemark - 'NO-19': 'NO-54', - 'NO-20': 'NO-54', // Troms, Finnmark → Troms og Finnmark - 'NO-16': 'NO-50', - 'NO-17': 'NO-50', // Sør-/Nord-Trøndelag → Trøndelag - }; - - const resolve = (row: Row): string | null => { - if (validCodes.has(row.region_code)) return null; // already valid - const a2 = (row.country_code || '').toUpperCase(); - const byName = nameToCode.get(`${a2}|${(row.region_name || '').toLowerCase()}`); - if (byName) return byName; - const merged = MERGE_CROSSWALK[row.region_code]; - // Only trust the crosswalk target if it actually exists in the bundle (or the - // bundle was unreadable, in which case we apply the curated map blindly). - if (merged && (validCodes.size === 0 || validCodes.has(merged))) return merged; - return null; - }; - - const update = db.prepare('UPDATE OR IGNORE visited_regions SET region_code = ?, region_name = ? WHERE id = ?'); - const del = db.prepare('DELETE FROM visited_regions WHERE id = ?'); - for (const row of rows) { - const newCode = resolve(row); - if (!newCode || newCode === row.region_code) continue; - const newName = codeToName.get(newCode) || row.region_name; - update.run(newCode, newName, row.id); - // UNIQUE(user_id, region_code): if the user already had the target code the - // UPDATE was IGNORED and this row still carries the old code → drop the duplicate. - const after = db.prepare('SELECT region_code FROM visited_regions WHERE id = ?').get(row.id) as - | { region_code: string } - | undefined; - if (after && after.region_code === row.region_code) del.run(row.id); - } - }, - () => { - // AirTrail integration addon — disabled by default (opt-in). Per-user connection - // lives in Settings → Integrations; this row is only the admin-level global toggle. - try { - db.prepare( - 'INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', - ).run( - 'airtrail', - 'AirTrail', - 'Sync flights from your self-hosted AirTrail instance', - 'integration', - 'Plane', - 0, - 14, - ); - } catch (err: any) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - () => { - // AirTrail per-user connection (mirrors the Immich integration columns). - try { - db.exec('ALTER TABLE users ADD COLUMN airtrail_url TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN airtrail_api_key TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN airtrail_allow_insecure_tls INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - // AirTrail flight linkage on reservations (#214) — lets a TREK transport - // remember its AirTrail origin so the two-way sync can match + update it. - // sync_enabled flips to 0 when the AirTrail flight is deleted (row kept). - try { - db.exec('ALTER TABLE reservations ADD COLUMN external_source TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN external_id TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN external_owner_user_id INTEGER'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN external_synced_at TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN sync_enabled INTEGER DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE reservations ADD COLUMN external_hash TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - // NULLs compare distinct in SQLite, so non-linked reservations don't collide. - db.exec( - 'CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)', - ); - }, - () => { - // Per-user opt-in for writing TREK edits back to AirTrail (#1240). Default - // off: AirTrail is the source of truth and TREK never writes unless asked. - try { - db.exec('ALTER TABLE users ADD COLUMN airtrail_write_enabled INTEGER DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Store Google Maps feature IDs separately from real Google Places API IDs. - () => { - try { - db.exec('ALTER TABLE places ADD COLUMN google_ftid TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Remember the app version a notice was dismissed at, so per-version recurring - // notices (e.g. the thank-you) re-appear on the next install/upgrade. - () => { - try { - db.exec('ALTER TABLE user_notice_dismissals ADD COLUMN dismissed_app_version TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - () => { - try { - db.exec('ALTER TABLE budget_item_members ADD COLUMN amount REAL'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Calendar feed tokens — subscribable ICS links for per-trip and all-trips feeds - () => { - try { - db.exec('ALTER TABLE trips ADD COLUMN feed_token TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE users ADD COLUMN feed_token TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_trips_feed_token ON trips(feed_token) WHERE feed_token IS NOT NULL'); - db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_users_feed_token ON users(feed_token) WHERE feed_token IS NOT NULL'); - }, - // Optimistic-concurrency token for offline conflict detection (#1135). - // packing_items had only created_at, so an offline edit could not be checked - // against a concurrent server change. SQLite forbids a non-constant DEFAULT on - // ALTER ADD COLUMN, so add it nullable and backfill from created_at; new rows - // set it explicitly (packingService). Additive: a request without the - // X-Base-Updated-At header keeps the old last-write-wins behaviour. - () => { - try { - db.exec('ALTER TABLE packing_items ADD COLUMN updated_at DATETIME'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - db.exec('UPDATE packing_items SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL'); - }, - // Video support (#823): the trek_photos registry held only images. media_type - // discriminates image vs video so the gallery, lightbox and provider proxy can - // branch; duration_ms is optional metadata for the player. Additive — existing - // rows default to 'image'. - () => { - for (const stmt of [ - "ALTER TABLE trek_photos ADD COLUMN media_type TEXT NOT NULL DEFAULT 'image'", - 'ALTER TABLE trek_photos ADD COLUMN duration_ms INTEGER', - ]) { - try { - db.exec(stmt); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - } - }, - // Dedicated booking URL (#935) — users previously stuffed links into notes. - // Additive nullable TEXT; existing rows default to NULL. - () => { - try { - db.exec('ALTER TABLE reservations ADD COLUMN url TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Private packing items (#858): an item can be hidden from other trip members. - // is_private toggles the visibility; owner_id records who it belongs to so the - // listing can show it only to them. owner_id is NULL on legacy rows (shared). - () => { - for (const stmt of [ - 'ALTER TABLE packing_items ADD COLUMN is_private INTEGER NOT NULL DEFAULT 0', - 'ALTER TABLE packing_items ADD COLUMN owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL', - ]) { - try { - db.exec(stmt); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - } - }, - // Guest members (#1362): people added to a trip without an account. A guest is a - // users row flagged is_guest=1 (no usable credentials) joined into trip_members, - // so it's assignable everywhere a member is — but must never authenticate or show - // up in the global user directory. The flag is the discriminator for those guards. - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Three-tier packing sharing (#858 follow-up): an item is Common (is_private=0, - // every existing item — non-breaking), Personal (is_private=1, owner only) or - // Shared-with-people (is_private=1 + recipient rows). owner_id is the "bringer". - // Contributors are extra people who said "I can bring that too" on a Common item - // (status 'pending' until the owner accepts). - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS packing_item_recipients ( - item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - PRIMARY KEY (item_id, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_packing_item_recipients_user ON packing_item_recipients(user_id); - CREATE TABLE IF NOT EXISTS packing_item_contributors ( - item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'accepted', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (item_id, user_id) - ); - `); - }, - // Migration 150: Collections addon — personal place library (#1081). - // Multi-list + per-place status (idea/want/visited) + fusion sharing. - // (150 = migrations.length of this array after appending — the runner uses - // migrations.length, not this comment; the label is cosmetic.) - () => { - db.prepare( - ` - INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) - VALUES ('collections', 'Collections', 'Personal place library — save places across trips into named lists, copy into any trip, share with others', 'global', 'Bookmark', 0, '{}', 16) - `, - ).run(); - - db.exec(` - CREATE TABLE IF NOT EXISTS collections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - color TEXT DEFAULT '#6366f1', - icon TEXT DEFAULT 'Bookmark', - cover_image TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collection_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'pending', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(collection_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS collection_places ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - name TEXT NOT NULL, - description TEXT, - lat REAL, - lng REAL, - address TEXT, - category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, - price REAL, - currency TEXT, - notes TEXT, - image_url TEXT, - google_place_id TEXT, - google_ftid TEXT, - osm_id TEXT, - website TEXT, - phone TEXT, - status TEXT NOT NULL DEFAULT 'idea', - source_trip_id INTEGER, - source_place_id INTEGER, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collection_place_tags ( - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (collection_place_id, tag_id) - ); - - CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id); - CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id); - `); - }, - - // Migration 151: user-added links on collections + saved places (JSON text) - () => { - try { db.exec('ALTER TABLE collections ADD COLUMN links TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - try { db.exec('ALTER TABLE collection_places ADD COLUMN links TEXT'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 152: per-member permission role on a shared list. Existing - // accepted members default to 'editor' so nothing regresses. - () => { - try { db.exec("ALTER TABLE collection_members ADD COLUMN role TEXT NOT NULL DEFAULT 'editor'"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 153: per-trip invite links (#1143). One rotating token per trip; - // a logged-in existing user who opens the link joins the trip as a member. - // Deleting the trip drops the token (CASCADE); the creator is nulled if their - // account is removed so the link keeps working. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS trip_invite_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE, - token TEXT UNIQUE NOT NULL, - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - expires_at TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_trip_invite_tokens_token ON trip_invite_tokens(token); - `); - }, - // Migration 154: optional trip binding on an admin invite link (#1402). A user - // who REGISTERS via the link is auto-added to the trip. Nullable for backward - // compatibility; ON DELETE SET NULL so removing the trip just unbinds the invite. - () => { - try { db.exec('ALTER TABLE invite_tokens ADD COLUMN trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL'); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 155: plugin system scaffold (#plugins). A plugin is a row here; - // its code lives on the /plugins volume and (once the runtime lands) runs in - // an isolated child process. This migration only lays down the registry - // tables — nothing executes yet. Own data lives in a per-plugin sqlite file - // under /plugins-data, never in these tables. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugins ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - type TEXT NOT NULL DEFAULT 'integration', - icon TEXT DEFAULT 'Blocks', - version TEXT, - api_version INTEGER DEFAULT 1, - min_trek_version TEXT, - permissions TEXT DEFAULT '[]', - granted_permissions TEXT DEFAULT '[]', - status TEXT NOT NULL DEFAULT 'inactive', - config TEXT DEFAULT '{}', - source_repo TEXT, - source_commit TEXT, - sha256 TEXT, - crash_count INTEGER NOT NULL DEFAULT 0, - last_error TEXT, - reviewed_at TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - installed_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE TABLE IF NOT EXISTS plugin_meta_migrations ( - plugin_id TEXT NOT NULL, - migration_id TEXT NOT NULL, - applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (plugin_id, migration_id) - ); - CREATE TABLE IF NOT EXISTS plugin_error_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - ts DATETIME DEFAULT CURRENT_TIMESTAMP, - level TEXT NOT NULL DEFAULT 'error', - message TEXT, - stack TEXT - ); - CREATE INDEX IF NOT EXISTS idx_plugin_error_log_plugin ON plugin_error_log(plugin_id, ts); - CREATE TABLE IF NOT EXISTS plugin_settings_fields ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - field_key TEXT NOT NULL, - label TEXT, - input_type TEXT NOT NULL DEFAULT 'text', - placeholder TEXT, - hint TEXT, - required INTEGER NOT NULL DEFAULT 0, - secret INTEGER NOT NULL DEFAULT 0, - scope TEXT NOT NULL DEFAULT 'instance', - options TEXT, - oauth_config TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - UNIQUE (plugin_id, field_key) - ); - `); - }, - // Migration 156: separate the admin's ON/OFF intent (`enabled`) from the - // runtime health (`status`). A crash used to flip status to 'error', which - // erased the "keep it on" intent, so the plugin never rebooted after a deploy. - // Boot now retries every enabled plugin regardless of last status. - () => { - try { - db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;"); - // Anything not explicitly deactivated was meant to be on ('inactive' is the - // only status deactivate() sets; a crash/shutdown could leave error/stopped/starting). - db.exec("UPDATE plugins SET enabled = 1 WHERE status != 'inactive';"); - } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 157: plugin capabilities (from trek-plugin.json) — the client - // needs them to place widgets (e.g. widget.slot 'hero' renders as an overlay - // on the boarding-pass bar instead of the dashboard sidebar). - () => { - try { db.exec("ALTER TABLE plugins ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}';"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 158: TOFU pin for a plugin's author signing key (#plugins, #4). - // Set on first install of a signed plugin; a later install whose registry key - // differs is a hard stop (author change / key rotation / attack) unless an - // admin re-trusts. NULL for unsigned plugins (signing is opt-in). - () => { - try { db.exec("ALTER TABLE plugins ADD COLUMN author_pubkey TEXT;"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 159: hash-chained capability audit log (#plugins, L1 hardening). - // Every host-mediated capability call the plugin makes is recorded at the RPC - // boundary (where the plugin provably can't reach) with the host-bound acting - // user and a per-plugin hash chain, so wide data grants stay attributable + - // tamper-evident + user-visible. - () => { - try { - db.exec(`CREATE TABLE IF NOT EXISTS plugin_capability_audit ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - acting_user_id INTEGER, - method TEXT NOT NULL, - resource TEXT, - code TEXT NOT NULL, - ts TEXT NOT NULL DEFAULT (datetime('now')), - prev_hash TEXT, - hash TEXT NOT NULL - );`); - db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_audit_plugin ON plugin_capability_audit (plugin_id, id);'); - } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } - }, - // Migration 160: per-collection custom labels (#collections). Each list owns - // its own label set (unlike the instance-wide `tags` table), and a place can - // carry several labels. Used for grouping + filtering places within a list. - () => { - db.exec(`CREATE TABLE IF NOT EXISTS collection_labels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - name TEXT NOT NULL, - color TEXT DEFAULT '#6366f1', - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - );`); - db.exec(`CREATE TABLE IF NOT EXISTS collection_place_labels ( - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE, - PRIMARY KEY (collection_place_id, label_id) - );`); - db.exec('CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id);'); - db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id);'); - db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id);'); - }, - // Migration 161: plugin-owned metadata on core entities (#1429). A namespaced - // key/value store so a plugin can attach data to a trip/place/day WITHOUT - // forking the core schema. One row per (plugin, entity, key); a plugin only - // ever sees its own rows. entity_type is polymorphic (no cross-table FK), so - // rows are purged by plugin_id on uninstall; entity deletes leave harmless - // orphans that the plugin's own reads never surface. - () => { - db.exec(`CREATE TABLE IF NOT EXISTS plugin_entity_metadata ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - entity_type TEXT NOT NULL, - entity_id INTEGER NOT NULL, - key TEXT NOT NULL, - value TEXT, - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (plugin_id, entity_type, entity_id, key) - );`); - db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_meta_entity ON plugin_entity_metadata (plugin_id, entity_type, entity_id);'); - }, - - // Freeze the FX rate on settle-up transfers too (#1445). budget_settlements - // stored only a bare `amount` in whatever display currency the payer was - // viewing, so a later live-rate drift re-opened an already-settled position - // with a few-cent residual. Capture the display `currency` and the rate frozen - // at settle time (units of that currency per 1 trip currency), mirroring the - // budget_items columns. Legacy rows keep currency = NULL / rate = 1 and stay on - // live rates until re-edited. - () => { - try { - db.exec('ALTER TABLE budget_settlements ADD COLUMN currency TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE budget_settlements ADD COLUMN exchange_rate REAL NOT NULL DEFAULT 1'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - - // #1446: guests are per-trip people, but their display name lived in the globally - // UNIQUE users.username, so a second "Jake" on another trip was auto-renamed to - // "Jake 2". Add a non-unique display_name; new guests store the human name here and - // get a uuid-based username that is never shown (the member views COALESCE to it). - () => { - try { - db.exec('ALTER TABLE users ADD COLUMN display_name TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - - // Plugin dependencies (#plugins): a plugin's trek-plugin.json can now declare - // `requiredAddons` (addon ids that must be enabled to activate) and - // `pluginDependencies` ({id, version-range} of other plugins that must be - // installed + satisfied). Stored as one JSON blob and populated by the - // discovery upsert on every install path. Legacy rows default to '{}' (no deps). - () => { - try { - db.exec("ALTER TABLE plugins ADD COLUMN dependencies TEXT NOT NULL DEFAULT '{}'"); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // Per-user plugin settings (#plugins). A plugin can declare `scope:'user'` - // settings fields (e.g. an API key or a personal preference); each USER stores - // their own values here, separate from the admin-owned instance `plugins.config`. - // Secrets are encrypted with the same apiKeyCrypto as instance secrets and are - // never echoed back to the client (masked). Runtime reads the acting user's row. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_user_config ( - plugin_id TEXT NOT NULL, - user_id INTEGER NOT NULL, - config TEXT NOT NULL DEFAULT '{}', - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (plugin_id, user_id) - ); - `); - }, - // Host-brokered outbound OAuth (#plugins). A plugin becomes an OAuth *client* of a - // third-party service; the HOST runs authorize->callback->token->refresh with - // PKCE+state and owns the tokens — the plugin never sees the refresh token. Tokens - // are per-user + encrypted at rest; the in-flight PKCE verifier/state is short-lived. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_oauth_tokens ( - plugin_id TEXT NOT NULL, - user_id INTEGER NOT NULL, - access_token TEXT, - refresh_token TEXT, - expires_at INTEGER, - scope TEXT, - updated_at TEXT NOT NULL DEFAULT (datetime('now')), - PRIMARY KEY (plugin_id, user_id) - ); - CREATE TABLE IF NOT EXISTS plugin_oauth_state ( - state TEXT PRIMARY KEY, - plugin_id TEXT NOT NULL, - user_id INTEGER NOT NULL, - verifier TEXT NOT NULL, - created_at INTEGER NOT NULL - ); - `); - }, - // Persistent plugin scheduler (#plugins). A plugin with the existing `jobs:run` - // grant can schedule a userless callback to fire at a future time (once, or - // recurring), surviving server restarts because the entry lives here. Same risk - // class as cron jobs (no user, no trip reads, own db + declared egress only) — - // so it rides on `jobs:run`, no new consent. UNIQUE(plugin_id, name) makes - // scheduler.set an upsert and cancel deterministic. Rows are purged on uninstall. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_scheduled_tasks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - name TEXT NOT NULL, - due_at INTEGER NOT NULL, - payload TEXT NOT NULL DEFAULT 'null', - every_ms INTEGER, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (plugin_id, name) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_sched_due ON plugin_scheduled_tasks (due_at);'); - }, - // Durable GDPR erasure queue (#plugins). When a TREK account is deleted, every - // installed plugin holding `hook:user-data` gets a pending row here so its own - // deleteUserData handler runs even if the plugin was offline at delete time — - // erasure must not be lost across a restart, so it is persisted (unlike the - // best-effort event buffer). The row is removed once the plugin acknowledges, - // and all of a plugin's rows are purged on uninstall. UNIQUE(plugin_id, user_id) - // makes re-enqueue idempotent. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_user_erasure_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - user_id INTEGER NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (plugin_id, user_id) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_erasure_plugin ON plugin_user_erasure_queue (plugin_id);'); - }, - - // Tombstones for Atlas countries the user has explicitly removed (#1490). - // Atlas derives visited countries from trip places and transport endpoints on every - // request, so those countries have no row to delete — "Remove" deleted from - // visited_countries (which never had the row), the client hid it optimistically, and - // the next getStats re-derived it. Recording the removal here lets getStats suppress - // a derived country. Re-marking a country deletes its tombstone. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS hidden_countries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - country_code TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, country_code) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_hidden_countries_user ON hidden_countries (user_id);'); - }, - - // Operator-supplied egress hosts for a plugin (#plugins). - // A plugin's egress allow-list is fixed in its manifest at publish time, but a plugin - // that talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the operator's - // hostname — so a community plugin could serve nobody. These rows let the ADMIN add - // hosts post-install; the runtime unions them into the child's allow-list at spawn. - // Consent stays with the admin (never the end user), exactly as for manifest egress. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_egress_hosts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plugin_id TEXT NOT NULL, - host TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE (plugin_id, host) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_plugin_egress_hosts_plugin ON plugin_egress_hosts (plugin_id);'); - // Whether the plugin DECLARED that it needs operator-supplied hosts. Only such a - // plugin may have hosts added — an admin can never widen egress for a plugin that - // never asked for it, so the install-time consent still bounds what's possible. - const columns = db.prepare("PRAGMA table_info('plugins')").all() as Array<{ name: string }>; - if (!columns.some((c) => c.name === 'operator_egress')) { - db.exec('ALTER TABLE plugins ADD COLUMN operator_egress INTEGER NOT NULL DEFAULT 0;'); - } - }, - - // Settings-page action buttons a plugin contributes ("Test connection", "Sync now"). - // Descriptors only — the handler lives in the plugin's code and is invoked host-side - // with the CLICKING user bound, so it can read that user's own settings. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS plugin_actions ( - plugin_id TEXT NOT NULL, - action_key TEXT NOT NULL, - label TEXT NOT NULL, - hint TEXT, - danger INTEGER NOT NULL DEFAULT 0, - sort_order INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (plugin_id, action_key) - ); - `); - }, - - // Why a plugin's update was REFUSED by the signature check (#plugins). A refused - // update leaves a working plugin pinned at its old version — previously the reason - // lived only in a transient toast, so the plugin quietly stopped updating and the - // admin had to re-attempt an update to rediscover why. Record it instead. - // - // `update_block_version` is the registry version that was refused: once the registry - // offers something NEWER, the block describes an artifact nobody is being offered - // anymore, so it reads as stale and the admin can just re-attempt (the next install - // re-verifies and either succeeds or re-blocks with fresh values). Deliberately no - // `status = 'error'` — the plugin still runs fine on its old code. - () => { - for (const col of ['update_block_code TEXT', 'update_block_detail TEXT', 'update_block_version TEXT']) { - try { - db.exec(`ALTER TABLE plugins ADD COLUMN ${col};`); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - } - }, - - // The semver RANGE of TREK versions a plugin declares it supports (its manifest's - // `trek`, e.g. ">=3.2.0 <4.0.0"). The existing `min_trek_version` only carries the - // lower bound, so it cannot express "stops working at 4.0" — which is precisely the - // case the activation gate has to catch after a TREK upgrade. Kept nullable: a plugin - // installed before this column existed has no range recorded, and the gate refuses to - // activate it rather than guessing (see TREK_VERSION_UNKNOWN). - () => { - try { - db.exec('ALTER TABLE plugins ADD COLUMN trek_range TEXT;'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - - // `place_regions` is a re-derivable Nominatim cache, only ever populated for a place ID - // that isn't already cached — so a wrong row, once written, was permanent. Region - // resolution now resolves a place's lat/lng directly against the bundled admin1 polygons - // (the same ones the client renders) instead of trusting Nominatim's address level, which - // could name a subdivision level the bundle doesn't carry (Barcelona's ES-B province vs - // the bundle's ES-CT autonomous community) and never highlight. That fix only helps - // places re-resolved after it, so clear the cache once and let every place re-resolve on - // the next Atlas load. The country_code stored alongside is cleared too, which also drops - // the old wrong-country rows a US-state-abbreviation address used to produce. - () => { - try { - db.exec('DELETE FROM place_regions'); - } catch (err) { - // place_regions is created by an earlier migration; tolerate its absence on an - // unusual partial DB rather than aborting startup. - if (!(err instanceof Error) || !err.message.includes('no such table')) throw err; - } - }, - - // Tombstones for Atlas regions the user has explicitly removed — the region-level - // counterpart to hidden_countries above (#1490). A visited region is normally derived - // fresh from place_regions/visited_regions on every request, so "removing" it has - // nothing to delete; recording it here lets getVisitedRegions suppress a derived region - // the same way getStats already suppresses a derived country. Unlike the country-level - // tombstone (originally only reachable for a manually-marked or zero-count country), - // this also covers a region derived from real place data — e.g. one that ended up on - // the wrong side of a border-simplification gap and the user just wants gone. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS hidden_regions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - region_code TEXT NOT NULL, - country_code TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE (user_id, region_code) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_hidden_regions_user ON hidden_regions (user_id);'); - }, - // Half vacation days (#552): a vacay entry can now count as a full day (1) or - // a half day (0.5) toward the entitlement. Existing entries default to a full - // day, so the entitlement maths are unchanged for everyone already using vacay. - // Guarded so re-running the migration tail (e.g. the crosswalk test) is a no-op. - () => { - const hasFraction = db.prepare("SELECT 1 FROM pragma_table_info('vacay_entries') WHERE name = 'fraction'").get(); - if (!hasFraction) db.exec('ALTER TABLE vacay_entries ADD COLUMN fraction REAL NOT NULL DEFAULT 1'); - }, - // Read-only vacay calendar sharing (#444/#667): a user can let other users - // view their vacation calendar without fusing plans. owner_id is the sharing - // user, user_id the viewer; hidden lets the viewer hide the overlay without - // removing the share. Follows the person, not the plan, so it survives - // fusion and dissolution. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS vacay_shares ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - hidden INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE (owner_id, user_id) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_vacay_shares_user ON vacay_shares (user_id);'); - }, - // Collaborative place ratings (#1435): every trip member can rate a trip - // place 1-5, every collection member a saved place; the displayed value is - // the average. One row per user and place, mirroring collab_poll_votes. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS place_ratings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - rating INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(place_id, user_id) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_place_ratings_place ON place_ratings (place_id);'); - db.exec(` - CREATE TABLE IF NOT EXISTS collection_place_ratings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - rating INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(collection_place_id, user_id) - ); - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_collection_place_ratings_place ON collection_place_ratings (collection_place_id);'); - }, - // Per-segment travel mode (#1281): the day-plan route can use a different - // transport mode for each leg. leg_transport_mode on an assignment is the mode - // of the leg LEAVING that stop (NULL = inherit the day default); days gains a - // persisted default_transport_mode so the whole-day choice survives a reload. - // Both nullable → existing itineraries keep today's single-mode behaviour. - () => { - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN leg_transport_mode TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - try { - db.exec('ALTER TABLE days ADD COLUMN default_transport_mode TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // School holidays are a visual Vacay calendar layer. Keep them separate from - // public holidays so applyHolidayCalendars never removes vacation entries for - // school-break dates. Appended LAST: this branch was cut before #1435/#1281 - // landed on dev, so an existing DB is already past their slots — only a - // trailing migration re-runs on upgrade and actually adds these columns. - () => { - const planCols = db.prepare("PRAGMA table_info('vacay_plans')").all() as Array<{ name: string }>; - if (!planCols.some(col => col.name === 'school_holidays_enabled')) { - db.exec('ALTER TABLE vacay_plans ADD COLUMN school_holidays_enabled INTEGER DEFAULT 0'); - } - const calendarCols = db.prepare("PRAGMA table_info('vacay_holiday_calendars')").all() as Array<{ name: string }>; - if (!calendarCols.some(col => col.name === 'type')) { - db.exec("ALTER TABLE vacay_holiday_calendars ADD COLUMN type TEXT NOT NULL DEFAULT 'public_holiday'"); - } - }, - // #1517 — assign trip members / named guests to a reservation (mirrors budget_item_members). - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS reservation_travelers ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(reservation_id, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_reservation_travelers_res ON reservation_travelers(reservation_id); - CREATE INDEX IF NOT EXISTS idx_reservation_travelers_user ON reservation_travelers(user_id); - `); - }, - // Comp/Flex days (#1074): a vacay entry is either a vacation day (counts toward - // the entitlement) or a comp/flex day (kind='comp', costs 0 — flextime/overtime - // offset). Orthogonal to fraction, so a half comp day is kind='comp' + fraction=0.5. - () => { - const hasKind = db.prepare("SELECT 1 FROM pragma_table_info('vacay_entries') WHERE name = 'kind'").get(); - if (!hasKind) db.exec("ALTER TABLE vacay_entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'vacation'"); - }, - // Configurable vacation year (#737): per-user leave-year window. 'calendar' keeps - // the Jan 1–Dec 31 default (unchanged for everyone); 'fiscal' starts on a fixed - // month/day; 'anniversary' starts on the month/day of the hire date. The year - // integer still names a period; the service resolves it to a [start,end) range. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS vacay_user_settings ( - user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - year_type TEXT NOT NULL DEFAULT 'calendar', - year_start_month INTEGER NOT NULL DEFAULT 1, - year_start_day INTEGER NOT NULL DEFAULT 1, - hire_date TEXT - ); - `); - }, - // Manual GPX track colour (#776): imported tracks all render in the same blue - // because the importer never assigns a category, so several walks in the same - // area are indistinguishable. NULL keeps the old behaviour (category colour, - // then the #3b82f6 fallback) for every existing row. - () => { - const hasRouteColor = db.prepare("SELECT 1 FROM pragma_table_info('places') WHERE name = 'route_color'").get(); - if (!hasRouteColor) db.exec('ALTER TABLE places ADD COLUMN route_color TEXT'); - }, - - // Backfill the three places feature toggles before their read flips from - // fail-open (`value !== 'false'`) to fail-closed (`value === 'true'`), so - // existing installs that never touched the admin switches keep the features - // they have today. A row that already says 'false' is left alone. - () => { - const insert = db.prepare("INSERT OR IGNORE INTO app_settings (key, value) VALUES (?, 'true')"); - for (const key of ['places_photos_enabled', 'places_autocomplete_enabled', 'places_details_enabled']) { - insert.run(key); - } - }, - - // Free the `note` column on budget items for its actual purpose (#1658). - // The costs UI stores an itemized receipt in it as `TICKETJSON:{...}`, which - // means an expense split by receipt can never carry a written note, and - // saving an expense any other way wipes whatever was typed in the budget - // table. The receipt moves to its own column and note becomes text again. - // GLOB, not LIKE: LIKE is case-insensitive in SQLite, so a hand-written note - // starting "ticketjson:" would be chopped up and its text dropped. - () => { - const hasTicket = db.prepare("SELECT 1 FROM pragma_table_info('budget_items') WHERE name = 'ticket_json'").get(); - if (!hasTicket) db.exec('ALTER TABLE budget_items ADD COLUMN ticket_json TEXT'); - db.exec(` - UPDATE budget_items - SET ticket_json = substr(note, 12), note = NULL - WHERE note GLOB 'TICKETJSON:*' - `); - }, - - // Note colours (#1629). A day note is a label as much as a reminder — "watch - // out", "must see", "already booked" — and a wall of identical grey cards - // makes that impossible to see at a glance. NULL keeps the neutral card - // every existing note has today. - () => { - const hasColor = db.prepare("SELECT 1 FROM pragma_table_info('day_notes') WHERE name = 'color'").get(); - if (!hasColor) db.exec('ALTER TABLE day_notes ADD COLUMN color TEXT'); - }, - - // Per-segment travel mode for boundary legs: a leg whose ORIGIN is not a place - // (booking arrival, morning hotel) stores its mode on the DESTINATION stop. - // NULL = inherit the day default. INERT whenever the previous timeline element - // is itself a place (that place's outgoing leg_transport_mode wins). - // Appended LAST: the array is index-addressed against schema_version, so a slot - // inserted anywhere above this line is simply skipped on every existing database. - () => { - try { - db.exec('ALTER TABLE day_assignments ADD COLUMN incoming_leg_transport_mode TEXT'); - } catch (err: any) { - if (!err.message?.includes('duplicate column name')) throw err; - } - }, - // #1298 — an expense can hang off a place, exactly as it already hangs off a - // reservation. Same nullable FK, same ON DELETE SET NULL: the delete paths - // take the linked expense with the place themselves, so the constraint is a - // backstop for anything that removes a place without going through them. - // Appended LAST: the array is index-addressed against schema_version, so a - // slot inserted above this line never runs on an existing database. - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('budget_items')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'place_id')) { - db.exec('ALTER TABLE budget_items ADD COLUMN place_id INTEGER REFERENCES places(id) ON DELETE SET NULL DEFAULT NULL'); - } - }, - // A reservation_day_positions row only makes sense when its reservation and - // its day are on the same trip. The table carries no trip_id and its two - // foreign keys only ask that the ids exist, so pairs that never belonged - // together could accumulate; the writer refuses them now, and this clears - // whatever an older build let through. This slot keeps the index it shipped - // with on dev — the array is index-addressed against schema_version. - () => { - db.exec(` - DELETE FROM reservation_day_positions - WHERE rowid IN ( - SELECT rdp.rowid - FROM reservation_day_positions rdp - JOIN reservations r ON r.id = rdp.reservation_id - JOIN days d ON d.id = rdp.day_id - WHERE d.trip_id <> r.trip_id - ) - `); - }, - // #1939 — the Google Places and Unsplash keys are instance configuration and - // now resolve out of app_settings (nest/settings/instance-api-keys.ts). This - // carries the value the old resolver would have handed out — the lowest-id - // admin who has one — into that row, so an install that upgrades keeps the - // key it was searching with instead of falling back to OpenStreetMap. - // Only where that key already was the whole install's, though. The resolver - // reads the instance row before the caller's own column, so promoting a - // personal key while a second row still holds one would move that member - // onto a stranger's key and a stranger's bill without anyone saying so. As - // soon as another row has a value for the same column nothing is written: - // everybody keeps resolving what they resolved before, and a member with no - // key of their own searches via OpenStreetMap until an admin saves the - // instance key once in the panel. Handing a key to the whole instance is the - // admin's call, not a migration's. - // The users columns are left alone: they are still the per-user fallback, - // and nothing here can lose a value. Stored blobs are copied verbatim; both - // sides use the same apiKeyCrypto format, legacy plaintext included. - // Appended LAST — the array is index-addressed against schema_version. - () => { - const upsert = db.prepare( - `INSERT INTO app_settings (key, value) VALUES (?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value` - ); - for (const column of ['maps_api_key', 'unsplash_api_key'] as const) { - const existing = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(column) as - | { value: string | null } - | undefined; - if (existing?.value) continue; - const row = db - .prepare( - `SELECT id, ${column} AS value FROM users - WHERE role = 'admin' AND ${column} IS NOT NULL AND ${column} != '' - ORDER BY id ASC LIMIT 1` - ) - .get() as { id: number; value: string } | undefined; - if (!row?.value) continue; - const otherHolder = db - .prepare( - `SELECT 1 FROM users - WHERE id != ? AND ${column} IS NOT NULL AND ${column} != '' LIMIT 1` - ) - .get(row.id); - if (otherHolder) continue; - upsert.run(column, row.value); - } - }, - // Capture metadata on the photo itself (#1614): when it was taken and where. - // Both are needed before photos can sit on the Journey map by their own - // coordinates, and before a gallery can be ordered by when a picture was - // taken rather than when it happened to be added. Nullable throughout — - // most providers answer with neither, and a photo without them is normal. - // Guarded so re-running the migration tail is a no-op. - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('trek_photos')").all() as Array<{ name: string }>; - const has = (name: string) => cols.some(c => c.name === name); - if (!has('taken_at')) db.exec('ALTER TABLE trek_photos ADD COLUMN taken_at TEXT'); - if (!has('lat')) db.exec('ALTER TABLE trek_photos ADD COLUMN lat REAL'); - if (!has('lng')) db.exec('ALTER TABLE trek_photos ADD COLUMN lng REAL'); - // Answering "which photos of this journey have coordinates" without a scan. - db.exec('CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL'); - }, - // A journey shared while the trip is still running reads like a blog, and a - // blog puts the newest entry first (#1614). The owner decides per share link, - // because it is a property of how the link is meant to be read, not of the - // journey. Off by default: an already-published link must not reorder itself - // under its readers. - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journey_share_tokens')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'newest_first')) { - db.exec('ALTER TABLE journey_share_tokens ADD COLUMN newest_first INTEGER NOT NULL DEFAULT 0'); - } - }, - /* - * TREK Studio books (#1973). - * - * One row per book, the document itself stored as JSON. A book is a - * document rather than a graph of records: the editor loads it whole, the - * renderer prints it whole, and nothing ever queries "which books contain a - * heart-shaped frame". Normalising spreads and elements into tables would - * buy queries nobody makes and cost a join on every open plus a schema - * migration for every new element kind. - * - * `updated_at` and `version` are what make concurrent editing possible: - * the version increments on every write, and a client that saves against a - * version it did not read gets told rather than silently overwriting. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS journey_books ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, - title TEXT NOT NULL DEFAULT '', - document TEXT NOT NULL, - version INTEGER NOT NULL DEFAULT 1, - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_journey_books_journey ON journey_books(journey_id); - `); - }, - /* - * Great Britain gained its counties and boroughs back (#1974). - * - * geoBoundaries' GBR ADM1 is the four constituent countries, so since 3.1.0 - * the atlas had no polygon below "England" and a London borough marked as - * visited had nothing to light up. The bundle now ships GB at ADM2, which is - * the granularity the rest of the world gets and the granularity the - * pre-3.1.0 Natural Earth layer had. - * - * Two things need saying to the data that already exists: - * - * `visited_regions` rows marked before 3.1.0 hold the old Natural Earth - * borough codes. They are reconciled by name within the same country, - * exactly as the 3.1.1 step above does for every other country — a row that - * still cannot be resolved is left alone rather than destroyed, and the - * client's name fallback may well still highlight it. - * - * `place_regions` is a re-derivable cache, and it is full of GB-ENG rows - * that were correct under the old bundle. Nothing re-derives a row that is - * already there, so without clearing them every English place would keep - * reporting "England" forever. - */ - () => { - db.exec(`DELETE FROM place_regions WHERE UPPER(COALESCE(country_code, '')) = 'GB'`); - - type Row = { id: number; region_code: string; region_name: string; country_code: string }; - const rows = db - .prepare(`SELECT id, region_code, region_name, country_code FROM visited_regions WHERE UPPER(COALESCE(country_code, '')) = 'GB'`) - .all() as Row[]; - if (rows.length === 0) return; - - let features: { properties?: { iso_a2?: string; iso_3166_2?: string; name?: string } }[]; - try { - const file = path.join(__dirname, '..', '..', 'assets', 'atlas', 'admin1.geojson.gz'); - features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || []; - } catch { - return; // bundle unreadable — leave the rows untouched rather than guessing - } - - const validCodes = new Set(); - const nameToCode = new Map(); - for (const f of features) { - const p = f.properties || {}; - if ((p.iso_a2 || '').toUpperCase() !== 'GB' || !p.iso_3166_2) continue; - validCodes.add(p.iso_3166_2); - if (p.name) nameToCode.set(p.name.toLowerCase(), p.iso_3166_2); - } - if (validCodes.size === 0) return; - - const update = db.prepare('UPDATE OR IGNORE visited_regions SET region_code = ? WHERE id = ?'); - for (const row of rows) { - if (validCodes.has(row.region_code)) continue; - const byName = nameToCode.get((row.region_name || '').toLowerCase()); - if (byName) update.run(byName, row.id); - } - }, - // Storage slice 2 — collab note attachments historically stored 'files/' - // in trip_files.filename while the file manager stored bare names in the same - // column; the storage layer addresses objects as category + bare name, so - // normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of - // 'files/'. The LIKE guard makes it a no-op on already-bare rows. - // - // Appended LAST again, and for the same reason as the note this replaces: - // the array is index-addressed against schema_version, so a slot that has - // shipped in dev keeps the index it shipped with and anything from this - // branch goes after it. A database that already ran this migration at its - // pre-merge index replays it harmlessly (the LIKE guard) but skips whatever - // now occupies that index. Acceptable only because this branch has never - // been published; never do this with a released slot. - () => { - db.exec("UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'"); - }, - // Give back the notes the TICKETJSON step chopped up (#1658). - // - // That step matched with LIKE, which is case-insensitive in SQLite, so a - // note somebody had typed starting "ticketjson:" was read as a receipt: the - // first eleven characters were dropped, the rest was moved into ticket_json - // and the note was set to NULL. The step now matches with GLOB, but that - // only helps a fresh install — the array is index-addressed against - // schema_version, so a repaired step never runs again on a database that - // already applied it. - // - // A receipt comes out of JSON.stringify and always parses; typed text does - // not. So a row moves back only when its ticket_json fails to parse AND the - // note is still empty — anything that parses, and any row someone has - // written a note on since, is left exactly as it is. The eleven marker - // characters themselves are gone for good: their case was the only thing - // separating a receipt from a note, and writing "TICKETJSON:" back would - // hand the row straight to the reader that reads that prefix as a receipt. - // Appended LAST — the array is index-addressed against schema_version. - () => { - const rows = db - .prepare( - `SELECT id, ticket_json FROM budget_items - WHERE ticket_json IS NOT NULL AND ticket_json != '' AND COALESCE(note, '') = ''` - ) - .all() as Array<{ id: number; ticket_json: string }>; - const restore = db.prepare('UPDATE budget_items SET note = ?, ticket_json = NULL WHERE id = ?'); - for (const row of rows) { - try { - JSON.parse(row.ticket_json); - } catch { - restore.run(row.ticket_json, row.id); - } - } - }, - - // A deliberate non-latest plugin install sets `update_hold`: the row leaves the - // update banner and "Update all" until the admin resumes updates, or until an - // install lands back on the newest compatible version. Only an EXPLICIT version - // pick ever sets it — dependency resolution pins versions too, but never - // deliberately. Appended LAST — the array is index-addressed against schema_version. - () => { - try { - db.exec('ALTER TABLE plugins ADD COLUMN update_hold INTEGER NOT NULL DEFAULT 0;'); - } catch (err) { - console.warn('[migrations] Non-fatal migration step failed:', err); - } - }, - - // Reservations an automated ingest parked for review are 'staged' and stay - // out of the two anonymous exports (ICS feed, shared trip) until a person - // confirms them. Nothing writes 'staged' yet; this is the gate the mail - // ingest writes through. - // - // 'live' as the default is what keeps this from being a breaking change: - // SQLite fills every existing row on the ALTER, and no current writer names - // the column, so every booking that is visible today stays visible. - // Appended LAST, the array is index-addressed against schema_version. - () => { - const hasColumn = db - .prepare("SELECT 1 FROM pragma_table_info('reservations') WHERE name = 'ingest_state'") - .get(); - if (!hasColumn) { - db.exec("ALTER TABLE reservations ADD COLUMN ingest_state TEXT NOT NULL DEFAULT 'live'"); - } - }, - - /* - * Separate an integration key from an MCP token (#2089). - * - * Both live in mcp_tokens and until now both opened everything a token can - * open. That was fine while /mcp was the only consumer; with a public REST - * surface it means a key somebody minted for a chat client also reads their - * trips over HTTP, and a key minted for an integration can drive every MCP - * tool. One credential, two very different blast radii. - * - * `kind` splits them, and every existing row becomes 'mcp' — that is what - * they were issued for, and silently widening a key that is already in - * somebody's config would be the opposite of what this migration is for. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('mcp_tokens')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'kind')) { - db.exec("ALTER TABLE mcp_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'mcp'"); - } - }, - /** - * `naver_list_import` was typed 'trip', but a trip addon is one that earns its own - * tab inside a trip — this one has no tab (it is not in tripTabs.ts) and no page. It - * calls an external service to pull places into the sidebar, which is exactly what - * 'integration' means here. - * - * The type is presentational: only `type === 'global'` is read anywhere - * (client navItems.ts), so this moves the tile between admin groups and changes - * nothing about how the import behaves. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.prepare("UPDATE addons SET type = 'integration' WHERE id = 'naver_list_import'").run(); - }, - /** - * Whether a journey's map draws the GPX tracks of the trips behind it (#2194). - * - * #1260 added those tracks unconditionally and with nothing to switch off, - * on the reasoning that a route imported into a linked trip is part of the - * journey's story. For a trip carrying a season of recorded drives it is - * instead a map nobody asked for, drawn from places that never became an - * entry — so it becomes a journey-level setting. - * - * DEFAULT 0, i.e. off: the tracks are opt-in from here on. That is a - * deliberate behaviour change rather than a preserved default — a journal - * should show what its author put in it, and #1260's set is everything the - * linked trips happen to contain. Owners who want them back have one - * switch in Journey Settings. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'show_trip_tracks')) { - db.exec('ALTER TABLE journeys ADD COLUMN show_trip_tracks INTEGER NOT NULL DEFAULT 0'); - } - }, - /** - * Settings-field defaults (#plugins, PR-87 feedback). A manifest `default` is the - * field's effective value when nothing is stored — the settings form pre-fills it and - * the runtime resolves it (settings-defaults.ts); it was previously accepted by the - * manifest and silently dropped here. JSON-encoded so string/number/boolean round-trip. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('plugin_settings_fields')").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'default_value')) { - db.exec('ALTER TABLE plugin_settings_fields ADD COLUMN default_value TEXT'); - } - }, - - // Settings-form actions gain a scope (#plugins): 'user' renders on the user Settings - // tab, 'instance' in the admin instance-settings dialog. Existing rows predate the - // column and were all user-tab buttons, so the default keeps them where they were. - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('plugin_actions')").all() as Array<{ name: string }>; - if (cols.length > 0 && !cols.some((c) => c.name === 'scope')) { - db.exec("ALTER TABLE plugin_actions ADD COLUMN scope TEXT NOT NULL DEFAULT 'user'"); - } - }, - /** - * A journal entry that is not a stop (discussion #2064). - * - * Studio draws its route and prints its distance from every entry that - * carries coordinates, and that is right until the journal starts at the - * home airport: the night before the flight, the stopover, the place the - * trip was planned from all become stops, and the distance counts the legs - * to and from them. The traveller knows which of those are the journey and - * which are the way there, so the switch sits on the entry. The entry stays - * in the journal; it is only left out of the arithmetic. - * - * DEFAULT 0: every existing entry keeps counting, which is what it did. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'stats_excluded')) { - db.exec('ALTER TABLE journey_entries ADD COLUMN stats_excluded INTEGER NOT NULL DEFAULT 0'); - } - }, - /** - /** - * Place shadow log: which search result a user actually picked. - * - * The corpus behind "would our own index have found that too". No user id, - * no trip, no session and no result list — the evaluation compares a query - * against a pick, and anything beyond that would be collecting for its own - * sake on an instance that promises not to. - * - * The table is created regardless of the switch: the switch decides whether - * rows are written, and a schema that appears only when a feature is - * enabled is a schema that differs between installs. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS place_shadow_picks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - query TEXT NOT NULL, - lang TEXT, - bias_lat REAL, - bias_lng REAL, - source TEXT NOT NULL, - live_rank INTEGER NOT NULL, - live_count INTEGER NOT NULL, - picked_name TEXT NOT NULL, - picked_lat REAL NOT NULL, - picked_lng REAL NOT NULL, - picked_place_id TEXT - ) - `); - // Retention deletes by age, the export pages by id. - db.exec('CREATE INDEX IF NOT EXISTS idx_place_shadow_created ON place_shadow_picks(created_at)'); - }, - /** - * What kind of stop a place is on a drive — fuel, charging, rest area, campsite. - * - * Deliberately NOT a `categories` row. Those are the traveller's own list, editable - * and instance-wide (`categories.service.ts` selects them without a user filter), so - * seeding four road-trip kinds there would push them into everyone's dropdown and hand - * their colour to whoever edits the list first. A refuelling stop is not a taste; it is - * a fact about the place, and the road-trip categories already own its icon and colour - * (`poiCategories.ts`). - * - * Free text rather than a CHECK constraint: the set grows with what the corridor - * search can look for, and SQLite cannot alter a constraint without rebuilding the - * table. NULL means an ordinary place, which is every row that exists today. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'stop_type')) { - db.exec('ALTER TABLE places ADD COLUMN stop_type TEXT'); - } - }, - /** - * Points a day's drive is made to pass through, without being stops (#1797). - * - * The difference is the whole point: a stop is somewhere you go, and it takes a - * number in the chain, a place row, an arrival time and a line in the itinerary. A - * via is none of that — it only bends the route, which is what "take the coast road - * instead" means. Storing one as a place was the alternative, and it would have put a - * numbered stop in the middle of the day for a spot nobody stops at. - * - * Anchored to `after_order_index` rather than to an assignment id: a stop added - * mid-day is written with a temporary negative id and swapped for the real one moments - * later, so a foreign key to it would dangle. The index is what the routing request is - * built from anyway. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS roadtrip_vias ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - after_order_index INTEGER NOT NULL, - sequence INTEGER NOT NULL DEFAULT 0, - lat REAL NOT NULL, - lng REAL NOT NULL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_vias_day ON roadtrip_vias(day_id, after_order_index, sequence)'); - }, - - /** - * Which imported track a day's drive was fitted to. - * - * The vias alone already make the day follow it, so this stores nothing the drive - * needs — it stores what the traveller needs: the name of the road they chose, still - * on the day after a reload, and something to re-fit against when the stops change. - * - * One row per day, hence `day_id` as the key: a day follows one road or none. The - * cascade on `place_id` is the point of the foreign key — delete the imported track - * and the label goes with it, rather than leaving a day claiming to follow a line - * nobody can see any more. The vias it laid down stay, because they are the drive. - * - * `stray_km` is how far the fitted route still ran from the track at its worst point, - * kept so the day can say how good a fit it is without routing again. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS roadtrip_day_tracks ( - day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE, - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, - stray_km REAL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_roadtrip_day_tracks_place ON roadtrip_day_tracks(place_id)'); - }, - /** - * Rename the misnamed Guangdong Province (shipped as "Guangzhou Province"). - * - * geoBoundaries labelled the whole province with the name of its capital, so - * every row a user collected under it carries the wrong code. All three - * tables that key on a region are moved over: the two per-user ones with an - * UPDATE OR IGNORE plus a DELETE, because a user who already holds the - * correct region would otherwise hit the unique index and keep a duplicate, - * and place_regions with a plain UPDATE, because place_id is its primary key - * and nothing there can collide. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.prepare( - `UPDATE OR IGNORE visited_regions - SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' - WHERE UPPER(country_code) = 'CN' AND (region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`, - ).run(); - db.prepare( - `DELETE FROM visited_regions - WHERE UPPER(country_code) = 'CN' AND (region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`, - ).run(); - db.prepare( - `UPDATE OR IGNORE place_regions - SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' - WHERE UPPER(country_code) = 'CN' AND (region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`, - ).run(); - // hidden_regions is the other direction: it remembers which derived region - // a user switched off. Left behind, the tombstone stops matching and the - // region a user deliberately hid comes back. - db.prepare( - `UPDATE OR IGNORE hidden_regions - SET region_code = 'CN-GUANGDONGPROVINCE' - WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE'`, - ).run(); - db.prepare( - `DELETE FROM hidden_regions - WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE'`, - ).run(); - }, - /** - * Let a file hang off an expense, so a receipt or an invoice can be attached - * to what it paid for. - * - * A column on `file_links` rather than a table of its own: the row already - * ties one file to one thing, and every other attachment kind is a column - * here too. The unique index stops the same receipt being linked twice; - * SQLite treats NULLs as distinct, so the rows that exist today, which all - * carry a NULL here, do not collide with each other. - * - * SET NULL rather than CASCADE, unlike the three columns beside it, because - * those predate the shared row: one row can carry a place link AND a receipt - * link for the same file, and a cascade would delete the whole row when the - * expense goes, silently detaching the file from the place as well. Deleting - * the expense drops the receipt link and nothing else; the budget service - * removes the row afterwards when it carries no other link. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const flCols = db.prepare("SELECT name FROM pragma_table_info('file_links')").all() as Array<{ name: string }>; - if (!flCols.some((c) => c.name === 'budget_item_id')) { - db.exec('ALTER TABLE file_links ADD COLUMN budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE SET NULL'); - } - db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)'); - }, - /** - * Which chat message an uploaded image belongs to. - * - * A column on `trip_files` rather than a link row, matching the two that - * predate it: a chat image is uploaded for exactly one message and dies with - * it, so the cascade is the whole relationship. Guarded through - * pragma_table_info like every other column add here, not through a caught - * "duplicate column name": that swallows the next error too. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('trip_files')").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'message_id')) { - db.exec('ALTER TABLE trip_files ADD COLUMN message_id INTEGER REFERENCES collab_messages(id) ON DELETE CASCADE'); - } - db.exec('CREATE INDEX IF NOT EXISTS idx_trip_files_message_id ON trip_files(message_id)'); - }, - /** - * Links somebody shared with the trip. - * - * Its own table rather than a note with a URL in it: a link is pinned, - * ordered and opened, and none of that is what a note does. `user_id` is who - * shared it, so the list can say so and so a member leaving takes their rows - * with them. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.exec(`CREATE TABLE IF NOT EXISTS collab_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL, - url TEXT NOT NULL, - pinned INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - )`); - db.exec('CREATE INDEX IF NOT EXISTS idx_collab_links_trip ON collab_links(trip_id)'); - }, - /** - * Route usage counters — how much routing this instance really does. - * - * Daily aggregates, not a log: one row per day, profile, surface and engine - * kind, carrying totals. No query, no coordinate, no route, no user, no trip. - * The question they answer is whether TREK could host a router itself, and - * that needs volume, not itineraries. - * - * The table is created regardless of the switch, like the shadow log above: - * the switch decides whether rows are written, and a schema that appears only - * when a feature is on is a schema that differs between installs. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS route_usage_daily ( - day TEXT NOT NULL, - profile TEXT NOT NULL, - surface TEXT NOT NULL, - self_hosted INTEGER NOT NULL, - requests INTEGER NOT NULL DEFAULT 0, - waypoints INTEGER NOT NULL DEFAULT 0, - km REAL NOT NULL DEFAULT 0, - failed INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (day, profile, surface, self_hosted) - ) - `); - // Retention deletes by day, and the summary reads the newest days first. - db.exec('CREATE INDEX IF NOT EXISTS idx_route_usage_day ON route_usage_daily(day)'); - }, - /** - * How full THIS stop fills the tank, 1 to 100 (#1797). - * - * Beside stop_type rather than in the traveller's settings, because it is a property - * of the stop and not of the person: a motorway rapid charger gets 80 % because the - * last fifth would cost as long again, while the one at the hotel gets 100 % because - * the car stands there all night. One figure for the whole trip cannot say both, and - * the difference between them is a leg. - * - * NULL means "whatever the traveller's own setting says", which is every row that - * exists today and every stop nobody has an opinion about. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'fill_percent')) { - db.exec('ALTER TABLE places ADD COLUMN fill_percent INTEGER'); - } - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS school_holiday_countries ( - code TEXT PRIMARY KEY, name TEXT NOT NULL - ); - CREATE TABLE IF NOT EXISTS school_holiday_regions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - country TEXT NOT NULL REFERENCES school_holiday_countries(code), - name TEXT NOT NULL COLLATE NOCASE, revision INTEGER NOT NULL DEFAULT 1, - UNIQUE(country, name) - ); - CREATE TABLE IF NOT EXISTS school_holiday_periods ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - region_id INTEGER NOT NULL REFERENCES school_holiday_regions(id) ON DELETE CASCADE, - name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL, - CHECK (end_date >= start_date) - ); - CREATE INDEX IF NOT EXISTS idx_school_holiday_periods_region ON school_holiday_periods(region_id); - `); - }, - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('day_assignments')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'end_day')) { - db.exec('ALTER TABLE day_assignments ADD COLUMN end_day INTEGER NOT NULL DEFAULT 0'); - } - }, - () => { - db.exec(`CREATE TABLE IF NOT EXISTS roadtrip_day_boundaries ( - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - day_number INTEGER NOT NULL CHECK (day_number BETWEEN 1 AND 366), - from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, - to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, - fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), - PRIMARY KEY (trip_id, day_number) - )`); - }, - () => { - db.exec('CREATE TABLE IF NOT EXISTS roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (trip_id, key))'); - const inherit = db.prepare('INSERT OR IGNORE INTO roadtrip_preferences (trip_id, key, value) SELECT t.id, s.key, s.value FROM trips t JOIN settings s ON s.user_id = t.user_id WHERE s.key = ? AND s.value IS NOT NULL'); - for (const key of ROADTRIP_PREFERENCE_KEYS) inherit.run(key); - }, - - // A settle-up payment's date was silently `created_at` (when it was recorded), - // not editable like a regular expense's `expense_date`. Add the same split: - // settled_at is the calendar day the transfer actually happened, independent - // of when someone got around to logging it. NULL on legacy rows and rows - // whose caller didn't set it; the read side falls back to created_at's date. - // - // Appended LAST: the array is index-addressed against schema_version. - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('budget_settlements')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'settled_at')) { - db.exec('ALTER TABLE budget_settlements ADD COLUMN settled_at TEXT'); - } - }, - - /** - * Amap (高德) as a keyed places provider. - * - * users.amap_api_key has the same shape and role as maps_api_key: encrypted - * with apiKeyCrypto, and the last step of the resolver after the operator - * env var and the instance-wide app_settings row. Nullable with no default, - * because "this install does not use Amap" is the correct state for almost - * everybody. Nothing is backfilled: a Google key is not an Amap key, and - * the two are chosen by the places_provider setting, not by which column - * happens to be populated. - * - * places.amap_poi_id is the provider id a place was found by, beside - * google_place_id and osm_id. Amap ids are bare strings shaped like Google - * ones, so the column holds them with the `amap:` prefix the maps domain - * uses everywhere, and a place keeps opening against Amap after the admin - * switches provider. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const userCols = db.prepare("SELECT name FROM pragma_table_info('users')").all() as Array<{ name: string }>; - if (!userCols.some(c => c.name === 'amap_api_key')) { - db.exec('ALTER TABLE users ADD COLUMN amap_api_key TEXT'); - } - const placeCols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>; - if (!placeCols.some(c => c.name === 'amap_poi_id')) { - db.exec('ALTER TABLE places ADD COLUMN amap_poi_id TEXT'); - } - }, - // Dawarich connection (#2279). Its own table rather than more `users` columns, - // which is where the AirTrail connection lives: that was the right call while - // AirTrail was the only integration of this shape, and this is the second. It - // also carries sync state (cursor, last error, probed capabilities) that has no - // business sitting on the identity row. Credentials for a personal location - // archive stay strictly apart from anything shared on a trip. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS dawarich_connections ( - user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - url TEXT, - api_key TEXT, - allow_insecure_tls INTEGER NOT NULL DEFAULT 0, - sync_enabled INTEGER NOT NULL DEFAULT 1, - last_sync_at TEXT, - last_sync_state TEXT NOT NULL DEFAULT 'never', - last_sync_error TEXT, - capabilities TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - `); - }, - // Reviewed visit suggestions (#2279). One row per (user, Dawarich visit id) — - // the UNIQUE is what makes a repeated sync idempotent instead of duplicating - // everything it already imported. - // - // `source_hash` exists because a Dawarich visit carries no `updated_at`: a - // rename or a re-detection is only visible as a different hash of the fields - // TREK shows. `source_missing_at` exists because deleting a visit removes it - // from the API rather than tombstoning it, so absence from a full re-read of - // the same window is the only signal — and it is recorded, not acted on, - // because an entry the user already accepted and edited is theirs now. - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS dawarich_visit_suggestions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - source_visit_id TEXT NOT NULL, - trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, - name TEXT NOT NULL, - lat REAL, - lng REAL, - started_at TEXT NOT NULL, - ended_at TEXT NOT NULL, - duration_minutes INTEGER NOT NULL DEFAULT 0, - local_date TEXT NOT NULL, - source_status TEXT NOT NULL DEFAULT 'suggested', - confidence REAL, - confidence_band TEXT, - country_code TEXT, - state TEXT NOT NULL DEFAULT 'new', - target TEXT, - accepted_place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - accepted_journal_entry_id INTEGER, - accepted_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, - matched_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, - source_hash TEXT NOT NULL, - accepted_hash TEXT, - source_missing_at TEXT, - first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, source_visit_id) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_user_state ON dawarich_visit_suggestions(user_id, state)'); - db.exec('CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_trip ON dawarich_visit_suggestions(trip_id)'); - }, - // Bucket-list entries can now be ticked off (#2279). `visited_source` records - // who decided — a hand-ticked wish and one confirmed from a recording read the - // same on the map otherwise, and re-running a scan must not touch the first. - () => { - const hasVisitedAt = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'visited_at'").get(); - if (!hasVisitedAt) db.exec('ALTER TABLE bucket_list ADD COLUMN visited_at TEXT'); - const hasVisitedSource = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'visited_source'").get(); - if (!hasVisitedSource) db.exec('ALTER TABLE bucket_list ADD COLUMN visited_source TEXT'); - }, - // Where an Atlas country came from (#2279). Everything that exists today was - // marked by hand, so 'manual' is the correct backfill rather than a guess; - // countries confirmed out of a recording are written as 'dawarich' and the - // Atlas can say so. Unmarking still deletes the row either way. - () => { - const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('visited_countries') WHERE name = 'source'").get(); - if (!hasSource) db.exec("ALTER TABLE visited_countries ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'"); - }, - // Per-key read scopes for /api/v1 (#2279). The `include` parameter picks which - // sections a *response* carries; it has never restricted what a key may read, - // and handing an integration a key that reads every trip because it wanted day - // notes is the thing that needed fixing. - // - // `scope_mode` is an explicit flag rather than "NULL means everything": a - // sentinel here would mean a bug that drops the scopes column silently grants - // full access. Every existing key is 'all', so nothing that works today stops - // working — the restriction is opt-in at mint time. - () => { - const hasMode = db.prepare("SELECT 1 FROM pragma_table_info('mcp_tokens') WHERE name = 'scope_mode'").get(); - if (!hasMode) db.exec("ALTER TABLE mcp_tokens ADD COLUMN scope_mode TEXT NOT NULL DEFAULT 'all'"); - const hasScopes = db.prepare("SELECT 1 FROM pragma_table_info('mcp_tokens') WHERE name = 'api_scopes'").get(); - if (!hasScopes) db.exec('ALTER TABLE mcp_tokens ADD COLUMN api_scopes TEXT'); - }, - // Where a place came from, when it did not come from a person typing it - // (#2279). Only 'dawarich' writes it today; NULL is every place anyone has - // ever added by hand, which is what it should stay. - // - // A column rather than a lookup through dawarich_visit_suggestions: the mark - // has to survive the suggestion being deleted, and a place list would - // otherwise join an integration's table to render a name. - () => { - const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('places') WHERE name = 'source'").get(); - if (!hasSource) db.exec('ALTER TABLE places ADD COLUMN source TEXT'); - }, - - // Provenance for a day stop that a lodging booking put there rather than the - // traveller: it carries the stay's id, so moving or deleting the booking can - // move or delete exactly that stop and never one somebody placed by hand. - // Every row that already exists stays NULL: those were planned by hand, and a - // booking must not start claiming ownership of them. The step below adds the - // missing stops instead, which is a different thing from claiming old ones. - () => { - const hasColumn = db.prepare("SELECT 1 FROM pragma_table_info('day_assignments') WHERE name = 'accommodation_id'").get(); - if (!hasColumn) db.exec('ALTER TABLE day_assignments ADD COLUMN accommodation_id INTEGER'); - }, - - /* - * Give every stay booked before this release the day stop it would get today. - * - * Road trip mode builds its drive out of day_assignments alone, so a hotel - * booked in Days mode was invisible there and the traveller had to add the - * same place a second time by hand. New bookings get the stop as they are - * written; without this step the fix would only ever apply to trips planned - * after the upgrade, and the trips people already have would stay broken. - * - * Skipped on purpose: a stay whose place is gone (place_id is ON DELETE SET - * NULL, and the booking form writes stays that never had one), and a place the - * traveller already planned for that day, whose row stays theirs and unmarked. - * Re-runnable: the same NOT EXISTS decides both times. - */ - () => { - const stays = db.prepare(` - SELECT a.id, a.place_id, a.start_day_id - FROM day_accommodations a - WHERE a.place_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM day_assignments da - WHERE da.day_id = a.start_day_id AND da.place_id = a.place_id - ) - ORDER BY a.id - `).all() as Array<{ id: number; place_id: number; start_day_id: number }>; - - const insert = db.prepare( - `INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) - VALUES (?, ?, COALESCE((SELECT MAX(order_index) + 1 FROM day_assignments WHERE day_id = ?), 0), ?)` - ); - // Arriving somewhere is what the day was for, so the stop goes last, the same - // position a stay booked today lands in. - const stamp = db.prepare("UPDATE places SET stop_type = 'hotel' WHERE id = ? AND (stop_type IS NULL OR stop_type = '')"); - for (const stay of stays) { - insert.run(stay.start_day_id, stay.place_id, stay.start_day_id, stay.id); - stamp.run(stay.place_id); - } - if (stays.length > 0) console.log(`[DB] Put ${stays.length} booked night(s) on their check-in day`); - }, - - /* - * The same sweep once more, for the nights the first one could not have seen. - * - * A migration runs while the old container is still answering: a booking written - * in those seconds is written by code that knows nothing about the day stop, and - * lands behind the sweep that would have given it one. One did, on the test - * instance, out of ten. There is nothing to be done about that window, but there - * is something to be done about what falls into it, and a booking that never got - * its stop is invisible to the drive with no way back short of saving it again. - * - * Safe to repeat: the same NOT EXISTS decides it, so a stay that already has its - * stop is passed over, and one whose place the traveller planned by hand keeps - * that row unclaimed. Every future release can carry the same step for the same - * reason. - */ - () => { - const stays = db.prepare(` - SELECT a.id, a.place_id, a.start_day_id - FROM day_accommodations a - WHERE a.place_id IS NOT NULL - AND NOT EXISTS ( - SELECT 1 FROM day_assignments da - WHERE da.day_id = a.start_day_id AND da.place_id = a.place_id - ) - ORDER BY a.id - `).all() as Array<{ id: number; place_id: number; start_day_id: number }>; - - const insert = db.prepare( - `INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) - VALUES (?, ?, COALESCE((SELECT MAX(order_index) + 1 FROM day_assignments WHERE day_id = ?), 0), ?)` - ); - const stamp = db.prepare("UPDATE places SET stop_type = 'hotel' WHERE id = ? AND (stop_type IS NULL OR stop_type = '')"); - for (const stay of stays) { - insert.run(stay.start_day_id, stay.place_id, stay.start_day_id, stay.id); - stamp.run(stay.place_id); - } - if (stays.length > 0) console.log(`[DB] Caught up ${stays.length} booked night(s) missed during the upgrade`); - }, - /** - * A suggestion the traveller has waved away. - * - * Skeletons are real rows, and the trip sync decides what to create by asking - * which source places already have one. Deleting a dismissed suggestion would - * therefore bring it straight back on the next sync. So it stays, marked, and - * drops out of every read instead — which also leaves a way back. - * - * Appended LAST: the array is index-addressed against schema_version. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'dismissed')) { - db.exec('ALTER TABLE journey_entries ADD COLUMN dismissed INTEGER NOT NULL DEFAULT 0'); - } - }, - /** - * The country an entry happened in, resolved once from its coordinates. - * - * For the flag on the timeline card. Resolved on write rather than on read - * because the answer never changes and the polygon test should not run on - * every render of every entry. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journey_entries')").all() as Array<{ name: string }>; - if (!cols.some((c) => c.name === 'country_code')) { - db.exec('ALTER TABLE journey_entries ADD COLUMN country_code TEXT'); - } - }, - /** - * Which of the optional entry fields a journey uses. - * - * Mood, weather and the pros/cons list are the three things that make the - * editor feel like a form. Not everybody journals that way, and a journey - * kept by one person for their family should be able to put them away - * without the fields being taken from everybody else (discussion #2299). - * - * DEFAULT 1: every existing journey keeps all three, which is what it had. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as Array<{ name: string }>; - for (const col of ['show_verdict', 'show_mood', 'show_weather']) { - if (!cols.some((c) => c.name === col)) { - db.exec(`ALTER TABLE journeys ADD COLUMN ${col} INTEGER NOT NULL DEFAULT 1`); - } - } - }, - - /* - * Let go of a booking the day stop can no longer reach. - * - * day_assignments.accommodation_id was added as a bare INTEGER, and the only - * code that ever clears it looks the stay up by id. A stay can also vanish - * without anybody asking: day_accommodations.end_day_id is ON DELETE CASCADE, - * so shortening a trip past a booking's last night deletes the booking while - * the stop on its first night survives, now pointing at nothing. Days hides any - * stop that carries an accommodation_id, so the hotel drops out of the day list - * on every surface while the route still drives to it, and the id is - * AUTOINCREMENT, so nothing will ever come along and free the row. - * - * A trigger rather than a column rebuild: day_assignments is referenced by two - * cascading tables of its own, and SQLite fires this even when the stay went - * down with a foreign-key cascade. - */ - () => { - db.exec(` - UPDATE day_assignments SET accommodation_id = NULL - WHERE accommodation_id IS NOT NULL - AND accommodation_id NOT IN (SELECT id FROM day_accommodations) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_day_assignments_accommodation_id ON day_assignments(accommodation_id)'); - db.exec(` - CREATE TRIGGER IF NOT EXISTS trg_release_stop_on_stay_delete - AFTER DELETE ON day_accommodations - BEGIN - UPDATE day_assignments SET accommodation_id = NULL WHERE accommodation_id = OLD.id; - END - `); - }, - - /* - * A journal skeleton belongs to a day, not just to a place (#2329). - * - * The sync engine keyed a skeleton by `source_place_id` alone, so the same - * place standing on two days — the city you land in at dusk and walk through - * the next morning, the hotel you sleep in three nights — produced one entry - * on the first of them and nothing on the rest. There was nowhere to put the - * second evening's photographs but the first evening's entry. - * - * The assignment row, not the day, is the missing half of that key: moving a - * stop to another day updates `day_assignments.day_id` in place, so keying on - * the assignment lets an entry follow the move the way it always has, while - * still telling two days of the same place apart. - * - * Deliberately no REFERENCES clause. Unassigning a stop has to leave the id - * stale rather than NULL, because reconciliation reads a key that no longer - * matches as "this stop left the plan" — which is what happened — and a NULL - * would be indistinguishable from a row this migration could not resolve. The - * id is safe to dangle: `day_assignments.id` is AUTOINCREMENT, so it is never - * handed out twice. - * - * Existing rows are backfilled to the place's earliest assignment, which is - * the one the old engine would have picked, so reconciliation matches what is - * already there instead of writing a duplicate beside it. - */ - () => { - const hasColumn = db - .prepare("SELECT 1 FROM pragma_table_info('journey_entries') WHERE name = 'source_assignment_id'") - .get(); - if (!hasColumn) db.exec('ALTER TABLE journey_entries ADD COLUMN source_assignment_id INTEGER'); - // `source_assignment_id IS NULL` keeps the replay from re-resolving a row that - // has since followed its stop to another day. - db.exec(` - UPDATE journey_entries - SET source_assignment_id = ( - SELECT da.id - FROM day_assignments da - JOIN days d ON d.id = da.day_id - WHERE da.place_id = journey_entries.source_place_id - ORDER BY d.day_number ASC, d.date ASC, da.order_index ASC, da.id ASC - LIMIT 1 - ) - WHERE source_place_id IS NOT NULL AND source_assignment_id IS NULL - `); - db.exec( - 'CREATE INDEX IF NOT EXISTS idx_journey_entries_source_assignment ON journey_entries(source_place_id, source_assignment_id)', - ); - }, - - /* - * Document providers, part 1 of 3: the registry (#214). - * - * Deliberately its own pair of tables rather than a `kind` column on - * `photo_providers`. The client filters on `type === 'photo_provider'`, the - * Journey cascade runs `UPDATE photo_providers SET enabled = 0` with no - * WHERE clause, and migrations are append-only. Reusing those tables would - * change the behaviour of three existing paths, which is exactly what the - * no-breaking-changes rule forbids. The field columns follow - * `photo_provider_fields` except for `settings_key` and `payload_key`. - * Those map a photo field onto a settings key and a request key; a document - * field is stored under its own `field_key` in one JSON column, and the - * connect form takes its fields from /docsync/providers rather than from - * the generic settings form, so nothing would ever read them. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS document_providers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - icon TEXT DEFAULT 'FileText', - enabled INTEGER DEFAULT 0, - sort_order INTEGER DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS document_provider_fields ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE, - field_key TEXT NOT NULL, - label TEXT NOT NULL, - input_type TEXT NOT NULL DEFAULT 'text', - placeholder TEXT, - hint TEXT, - required INTEGER DEFAULT 0, - secret INTEGER DEFAULT 0, - sort_order INTEGER DEFAULT 0, - UNIQUE(provider_id, field_key) - ); - `); - seedDocumentProviders(db); - }, - - /* - * Document providers, part 2 of 3: the connection, and the trip binding. - * - * The connection carries a `trip_id`, and that is the one place this design - * departs from every integration already in the repo. Immich, Synology - * Photos, AirTrail and Dawarich all hang off a user, and even - * `trip_album_links` carries a `user_id`; photos become visible to the rest - * of a trip only through an opt-in `shared` flag. None of that can satisfy - * "everyone on the trip sees the same documents": it would make a - * document's visibility depend on whose credentials fetched it. So the trip - * admin binds the trip once, the server talks to the provider under that - * single identity, and TREK's own membership decides who sees what. - * - * `owner_user_id` stays separate from `trip_id` so the credential holder is - * always explicit: when that person leaves the trip the binding goes to - * `orphaned` rather than silently continuing to use an ex-member's token. - * - * Secrets live in one encrypted JSON blob instead of per-provider columns: - * a sixth provider then needs no migration, and the key rotation in - * scripts/migrate-encryption.ts stays one line instead of a field list that - * someone will forget, and a forgotten column does not survive a rotation. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS document_connections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE, - owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - base_url TEXT NOT NULL, - secrets TEXT, - settings TEXT NOT NULL DEFAULT '{}', - allow_insecure_tls INTEGER NOT NULL DEFAULT 0, - capabilities TEXT, - last_probe_at TEXT, - last_probe_state TEXT NOT NULL DEFAULT 'never', - last_probe_error TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, provider_id) - ); - CREATE INDEX IF NOT EXISTS idx_document_connections_trip ON document_connections(trip_id); - CREATE INDEX IF NOT EXISTS idx_document_connections_owner ON document_connections(owner_user_id); - - CREATE TABLE IF NOT EXISTS trip_document_links ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - connection_id INTEGER NOT NULL REFERENCES document_connections(id) ON DELETE CASCADE, - provider_id TEXT NOT NULL, - remote_scope_key TEXT NOT NULL, - remote_root_id TEXT, - remote_root_path TEXT, - remote_label TEXT NOT NULL DEFAULT '', - direction TEXT NOT NULL DEFAULT 'both', - delete_policy TEXT NOT NULL DEFAULT 'unlink', - conflict_policy TEXT NOT NULL DEFAULT 'manual', - sync_enabled INTEGER NOT NULL DEFAULT 1, - webhook_token TEXT, - webhook_secret TEXT, - webhook_subscription_id TEXT, - remote_cursor TEXT, - last_sync_at TEXT, - last_sync_state TEXT NOT NULL DEFAULT 'never', - last_sync_error TEXT, - failure_count INTEGER NOT NULL DEFAULT 0, - next_attempt_at TEXT, - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, connection_id, remote_scope_key) - ); - CREATE INDEX IF NOT EXISTS idx_trip_document_links_trip ON trip_document_links(trip_id); - CREATE INDEX IF NOT EXISTS idx_trip_document_links_due ON trip_document_links(sync_enabled, next_attempt_at); - CREATE UNIQUE INDEX IF NOT EXISTS idx_trip_document_links_token - ON trip_document_links(webhook_token) WHERE webhook_token IS NOT NULL; - `); - }, - - /* - * Document providers, part 3 of 3: the pairing and its sync state. - * - * `content_sha256` and `pushed_sha256` are two columns on purpose. The - * first is the bytes both sides last agreed on, the second is what TREK - * itself last uploaded. Collapsing them into one is precisely the mistake - * that builds an echo loop: a webhook fires for TREK's own write, the core - * cannot tell it from a stranger's edit, and the file bounces. - * - * `remote_missing_at` records that something vanished upstream instead of - * acting on it, the rule Dawarich already follows with `source_missing_at`. - * An unmounted share answers with an empty listing, and reading that as - * "everything was deleted" would empty a trip. - * - * `file_id ON DELETE SET NULL` keeps a tombstone behind after a document is - * permanently deleted in TREK, so the next run does not cheerfully download - * it again. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS document_sync_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - link_id INTEGER NOT NULL REFERENCES trip_document_links(id) ON DELETE CASCADE, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - file_id INTEGER REFERENCES trip_files(id) ON DELETE SET NULL, - trek_doc_uid TEXT NOT NULL, - remote_id TEXT, - remote_name TEXT, - remote_version TEXT, - remote_size INTEGER, - remote_modified_at TEXT, - content_sha256 TEXT, - pushed_sha256 TEXT, - state TEXT NOT NULL DEFAULT 'pending', - error_code TEXT, - attempts INTEGER NOT NULL DEFAULT 0, - next_attempt_at TEXT, - remote_missing_at TEXT, - first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - synced_at TEXT - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_remote - ON document_sync_items(link_id, remote_id) WHERE remote_id IS NOT NULL; - CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_file - ON document_sync_items(link_id, file_id) WHERE file_id IS NOT NULL; - CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_uid - ON document_sync_items(link_id, trek_doc_uid); - CREATE INDEX IF NOT EXISTS idx_document_sync_items_trip_state ON document_sync_items(trip_id, state); - CREATE INDEX IF NOT EXISTS idx_document_sync_items_hash ON document_sync_items(link_id, content_sha256); - CREATE INDEX IF NOT EXISTS idx_document_sync_items_due ON document_sync_items(link_id, next_attempt_at); - `); - }, - - /* - * Document providers: when TREK itself put a provider copy in the bin. - * - * A file deleted in TREK under the `trash` policy takes its provider copy - * with it. Taken back out of TREK's trash, it has to go up again; a copy - * somebody else deleted in the meantime must not. Both leave the same gap - * in a listing, so the difference is written down when TREK acts rather - * than guessed at later. - * - * No backfill from the binding's current policy: that policy may not be - * the one the deletion ran under, and reading it back is the retroactive - * mistake this column exists to avoid. A row left NULL is treated like a - * copy somebody else deleted, which flags it instead of uploading it. - */ - () => { - const hasColumn = db - .prepare("SELECT 1 FROM pragma_table_info('document_sync_items') WHERE name = 'remote_trashed_at'") - .get(); - if (!hasColumn) db.exec('ALTER TABLE document_sync_items ADD COLUMN remote_trashed_at TEXT'); - }, - - /* - * Trips longer than a year lost every day past the 365th: generateDays - * clipped the day rows at the old limit while the trip kept its full end - * date, so the last days had a date but nothing to plan on (#2403). The - * limit is 999 now, and this gives the affected trips their missing days. - * - * Only a range whose dated days still run unbroken from the start date is - * extended; a trip that was re-dated by hand or lost a day in the middle is - * left as it is. Dateless days that still hold content stay behind the - * dated ones, where generateDays keeps them. The two-phase renumbering is - * the same dance generateDays does around UNIQUE(trip_id, day_number). - */ - () => { - const dayAfter = (start: string, n: number) => { - const [y, m, d] = start.split('-').map(Number); - return new Date(Date.UTC(y, m - 1, d) + n * 86400000).toISOString().slice(0, 10); - }; - const trips = db - .prepare(` - SELECT id, start_date, end_date, - CAST(julianday(end_date) - julianday(start_date) + 1 AS INTEGER) AS span - FROM trips - WHERE start_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' - AND end_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' - AND julianday(end_date) - julianday(start_date) + 1 BETWEEN 366 AND 999 - `) - .all() as { id: number; start_date: string; end_date: string; span: number }[]; - const dayRows = db.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number'); - const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?'); - const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); - for (const trip of trips) { - const rows = dayRows.all(trip.id) as { id: number; date: string | null }[]; - const dated = rows.filter((r) => r.date); - if (dated.length >= trip.span) continue; - if (dated.some((r, i) => r.date !== dayAfter(trip.start_date, i))) continue; - const dateless = rows.filter((r) => !r.date); - rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id)); - dated.forEach((r, i) => setDayNumber.run(i + 1, r.id)); - for (let i = dated.length; i < trip.span; i++) insertDay.run(trip.id, i + 1, dayAfter(trip.start_date, i)); - dateless.forEach((r, i) => setDayNumber.run(trip.span + i + 1, r.id)); - } - }, - - /* - * The road-trip day boundaries carried the old trip limit as a CHECK on - * day_number. The limit lives in the contract now (MAX_TRIP_DAYS), so the - * table keeps only the floor. SQLite cannot alter a CHECK, hence the - * rebuild; nothing references the table, so the rows are simply copied. - */ - () => { - db.exec(` - CREATE TABLE roadtrip_day_boundaries_new ( - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - day_number INTEGER NOT NULL CHECK (day_number >= 1), - from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, - to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, - fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), - PRIMARY KEY (trip_id, day_number) - ); - INSERT INTO roadtrip_day_boundaries_new - SELECT trip_id, day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries; - DROP TABLE roadtrip_day_boundaries; - ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries; - `); - }, - - /* - * Seat every booked night where its check-in says, the way a night booked - * today is seated. The night leads its day now; the stops a booking put on - * their check-in day before this release sit last, and the trips people - * already have would keep that order until somebody edits the check-in. - * The rules, and the drawn roads that follow the stops, are in - * reseat-booked-nights.ts. Re-runnable. - */ - () => { - const seated = reseatBookedNights(db); - if (seated > 0) console.log(`[DB] Seated ${seated} booked night(s) at the head of their day`); - }, - - /* - * Immich learns the switch Synology, AirTrail and Dawarich already have: a - * server behind a self-signed certificate can be trusted per user (#2475). - * Off by default, and only 1 counts as on. - * - * The two settings rows are the toggle for it and the auto-upload toggle - * migration 112 meant to add. That one only ran where the Immich provider - * row already existed, which on a fresh install it never did (the seeds run - * after the migrations), so fresh installs never showed the upload toggle. - * Both rows are in seeds.ts as well for the same reason; here they reach the - * installs that already have the provider row. Re-runnable. - */ - () => { - const hasColumn = db - .prepare("SELECT 1 FROM pragma_table_info('users') WHERE name = 'immich_allow_insecure_tls'") - .get(); - if (!hasColumn) { - db.exec('ALTER TABLE users ADD COLUMN immich_allow_insecure_tls INTEGER NOT NULL DEFAULT 0'); - } - const hasImmich = db.prepare("SELECT 1 FROM photo_providers WHERE id = 'immich'").get(); - if (hasImmich) { - db.exec(` - INSERT OR IGNORE INTO photo_provider_fields - (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) - VALUES - ('immich', 'immich_allow_insecure_tls', 'skipSSLVerification', 'checkbox', NULL, NULL, 0, 0, 'allow_insecure_tls', 'allow_insecure_tls', 2), - ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, NULL, 0, 0, 'auto_upload', 'auto_upload', 5) - `); - } - }, - - /* - * A place that moves takes its Atlas country with it (#2527). - * - * place_regions caches the country and region Atlas resolved from a - * place's coordinates and address, and nothing re-derives a row that is - * already there. Correcting a place's location left the old row in charge, - * so Atlas, the dashboard stats and the journey stats kept counting the - * country the place had just left. - * - * A trigger rather than a delete in the places code, because the row is - * derived from the place and every writer of lat, lng or address has to - * let go of it: the place editor, update_place, the plugin RPC, the import - * enrichment and its address backfill, and whatever comes next. The WHEN - * clause matters because every place edit writes lat, lng and address - * back whether they changed or not, and renaming a place must not throw - * away a good row. The next Atlas load resolves the place where it is now. - * - * This is 244 on main (4.3.3) as well, so it sits here, ahead of Web Push. - */ - () => createPlaceRegionsFollowPlaceTrigger(db), - - /* - * Web Push (#894): one row per browser a user switched push on in. - * - * The endpoint is the push service URL that browser handed out, so it is - * unique: a browser shared by two accounts belongs to whoever subscribed on - * it last. p256dh and auth are the browser's keys for the RFC 8291 message - * encryption. vapid_public_key is the server key the subscription was made - * against, so a changed key pair shows up as a mismatch the sender can clean - * up, rather than as a push service refusing every message. failure_count - * and last_success_at are bookkeeping for the sender; the rows go with the - * user. - * - * Web Push was 244 before the #2527 trigger took that slot to match main. - * A 4.3.3 install is already at 244 with the trigger and gets the table - * here; an instance that ran this at 244 replays it as a no-op. - */ - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS push_subscriptions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - endpoint TEXT NOT NULL UNIQUE, - p256dh TEXT NOT NULL, - auth TEXT NOT NULL, - vapid_public_key TEXT NOT NULL, - user_agent TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_success_at TEXT, - failure_count INTEGER NOT NULL DEFAULT 0 - ); - CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id); - `); - }, - - /* - * The #2527 trigger once more, for an instance that ran Web Push at 244 - * before the trigger took that slot. Such an instance never runs 244 again, - * so it gets the trigger here. Everywhere else it already exists and this - * is a no-op. - */ - () => createPlaceRegionsFollowPlaceTrigger(db), - - /* - * A stop kept on the day but left out of its route (#2532): shown as a pin, not - * driven to. 0 routes it like every stop before this existed. - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('day_assignments') WHERE name = 'route_excluded'").get(); - if (!has) db.exec('ALTER TABLE day_assignments ADD COLUMN route_excluded INTEGER NOT NULL DEFAULT 0'); - }, - - /* - * How many of an item are packed so far (#2296), for "7 of 10 shirts". NULL is the - * plain checkbox every item has had, and `checked` stays the answer to "done?". - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('packing_items') WHERE name = 'packed_quantity'").get(); - if (!has) db.exec('ALTER TABLE packing_items ADD COLUMN packed_quantity INTEGER'); - }, - - /* - * A bucket-list wish for one state or province rather than the whole country - * (#1901). NULL keeps every existing wish a country wish. - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'region_code'").get(); - if (!has) db.exec('ALTER TABLE bucket_list ADD COLUMN region_code TEXT'); - }, - - /* - * Half company holidays (#2439): 0.5 covers the morning or afternoon and leaves - * the other half open for a half vacation day. 1 is what every holiday was. - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('vacay_company_holidays') WHERE name = 'fraction'").get(); - if (!has) db.exec('ALTER TABLE vacay_company_holidays ADD COLUMN fraction REAL NOT NULL DEFAULT 1'); - }, - - /* - * A journey entry still being written (#696): contributors see it, the public - * share page does not. Its own flag, since `visibility` defaults to 'private' on - * every entry and filtering on it would empty every shared journey. - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('journey_entries') WHERE name = 'is_draft'").get(); - if (!has) db.exec('ALTER TABLE journey_entries ADD COLUMN is_draft INTEGER NOT NULL DEFAULT 0'); - }, - - /* - * Two narrower ways to share a trip (#1712): only the travel (flights, trains, - * stays) without the day's activities, and without the place photos. Both off - * keeps every existing link exactly as it was. - */ - () => { - const hasTravel = db.prepare("SELECT 1 FROM pragma_table_info('share_tokens') WHERE name = 'share_travel_only'").get(); - if (!hasTravel) db.exec('ALTER TABLE share_tokens ADD COLUMN share_travel_only INTEGER NOT NULL DEFAULT 0'); - const hasImages = db.prepare("SELECT 1 FROM pragma_table_info('share_tokens') WHERE name = 'share_hide_images'").get(); - if (!hasImages) db.exec('ALTER TABLE share_tokens ADD COLUMN share_hide_images INTEGER NOT NULL DEFAULT 0'); - }, - /** - * A note on a settle-up payment (#2340), as expenses already have one: - * "paid back in cash at the airport". Null on every existing payment. - */ - () => { - const has = db.prepare("SELECT 1 FROM pragma_table_info('budget_settlements') WHERE name = 'note'").get(); - if (!has) db.exec('ALTER TABLE budget_settlements ADD COLUMN note TEXT'); - }, - /** - * Google API calls per UTC day (#1582), so the admin's daily ceiling has - * something to count against. One row a day, nothing about who searched. - */ - () => { - db.exec('CREATE TABLE IF NOT EXISTS google_api_usage (day TEXT PRIMARY KEY, calls INTEGER NOT NULL DEFAULT 0)'); - }, - /** - * Packing templates remember what an item weighs, how many of it go along - * and which bag it lives in (#1131), so applying one does not mean typing - * all of that in again. The bag is kept by name: bags belong to a trip. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('packing_template_items')").all() as { name: string }[]; - const has = (c: string) => cols.some(col => col.name === c); - if (!has('weight_grams')) db.exec('ALTER TABLE packing_template_items ADD COLUMN weight_grams INTEGER'); - if (!has('quantity')) db.exec('ALTER TABLE packing_template_items ADD COLUMN quantity INTEGER NOT NULL DEFAULT 1'); - if (!has('bag_name')) db.exec('ALTER TABLE packing_template_items ADD COLUMN bag_name TEXT'); - }, - /** - * A journey's state as its owner sets it (#762): draft, live or completed. - * NULL keeps the state derived from the linked trips' dates, which is what - * every journey had so far; a journey with no trip could only ever be a draft. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as { name: string }[]; - if (!cols.some(c => c.name === 'status_override')) db.exec('ALTER TABLE journeys ADD COLUMN status_override TEXT'); - }, - /** - * A place's e-mail and its own opening hours, typed in by hand (#2472): the - * search fills in what it knows, and these are for everything it does not. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as { name: string }[]; - const has = (c: string) => cols.some(col => col.name === c); - if (!has('email')) db.exec('ALTER TABLE places ADD COLUMN email TEXT'); - if (!has('opening_hours')) db.exec('ALTER TABLE places ADD COLUMN opening_hours TEXT'); - }, - /** - * A journey that puts an entry on the map where its first photo was taken - * (#1003). Off unless the owner turns it on: an entry somebody placed by hand - * is never moved, but an entry left without a place on purpose should not - * grow one either just because a picture was added. - */ - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as { name: string }[]; - if (!cols.some(c => c.name === 'photo_location')) db.exec('ALTER TABLE journeys ADD COLUMN photo_location INTEGER NOT NULL DEFAULT 0'); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS tour_types ( - key TEXT PRIMARY KEY, - label_key TEXT NOT NULL, - icon TEXT NOT NULL, - color TEXT NOT NULL, - routing_profile TEXT, - is_sport INTEGER NOT NULL DEFAULT 1, - enabled INTEGER NOT NULL DEFAULT 1, - sort_order INTEGER NOT NULL DEFAULT 0 - ) - `); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS tours ( - place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, - tour_type TEXT NOT NULL REFERENCES tour_types(key), - distance REAL, - elevation_gain REAL, - elevation_loss REAL, - duration REAL, - difficulty TEXT, - wanderer_ref TEXT, - match_confidence REAL, - tour_group_id INTEGER, - created_at TEXT DEFAULT CURRENT_TIMESTAMP - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_tours_tour_type ON tours(tour_type)'); - }, - () => { - db.exec(` - CREATE TABLE IF NOT EXISTS tour_waypoints ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE, - lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90), - lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180), - role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')), - sequence INTEGER NOT NULL CHECK(sequence >= 0), - UNIQUE(place_id, sequence) - ) - `); - db.exec('CREATE INDEX IF NOT EXISTS idx_tour_waypoints_place ON tour_waypoints(place_id, sequence)'); - }, - () => { - const cols = db.prepare("SELECT name FROM pragma_table_info('tours')").all() as Array<{ name: string }>; - if (!cols.some(c => c.name === 'max_hiking_difficulty')) { - db.exec('ALTER TABLE tours ADD COLUMN max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6)'); - } - }, - ]; - - const rewindVersion = toursLineageRewind(db, currentVersion); - if (rewindVersion !== undefined && migrations.slice(rewindVersion).some(step => typeof step !== 'function')) { - throw new Error('[migrations] Unsafe Tours migration lineage: bridge replay contains a raw migration'); - } - const applyMigrations = (startVersion: number): void => { - for (let i = startVersion; i < migrations.length; i++) { - console.log(`[DB] Running migration ${i + 1}/${migrations.length}`); - try { - const migration = migrations[i]; - if (typeof migration === 'function') { - // The version bump has to commit together with the migration. Bumped - // afterwards, a crash in between leaves the schema advanced and the - // version stale, and the next boot replays a step that is not - // idempotent, exits 1, and turns one crash into a permanent boot loop. - db.transaction(() => { - migration(); - db.prepare('UPDATE schema_version SET version = ?').run(i + 1); - })(); - } else { - // raw steps run outside a transaction on purpose (see PRAGMA - // foreign_keys above), so the bump stays a separate statement. - migration.raw(); - db.prepare('UPDATE schema_version SET version = ?').run(i + 1); - } - } catch (err) { - console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err); - if (rewindVersion !== undefined) throw err; - process.exit(1); - } - } - }; - if (rewindVersion !== undefined) { - db.transaction(() => { - db.prepare('UPDATE schema_version SET version = ?').run(rewindVersion); - applyMigrations(rewindVersion); - })(); - } else if (currentVersion < migrations.length) { - applyMigrations(currentVersion); - } - if (currentVersion < migrations.length) { - console.log(`[DB] Migrations complete — schema version ${migrations.length}`); - } -} - -export { runMigrations }; diff --git a/server/src/db/migrations/Migration20200101000000_baseline_schema.ts b/server/src/db/migrations/Migration20200101000000_baseline_schema.ts new file mode 100644 index 000000000..8aa7da5cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101000000_baseline_schema.ts @@ -0,0 +1,774 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Baseline schema — every table `db/schema.ts::createTables()` created on boot, + * before the numbered steps in `db/migrations.ts` ran. + * + * This is not a frozen "v0": `createTables()` was edited as features landed, so it + * is a snapshot of the schema as it stood when the ORM migration took place. The + * `IF NOT EXISTS` guards are kept verbatim, and the numbered steps that follow + * replay on top of it — each one guarded so a column this file already declares + * is not added twice. + * + * The trailing `schema_version` table is the legacy runner's own bookkeeping, + * which it created before running step 1. It is left empty here: the migration + * state now lives in the migrator's table, and nothing in the app reads this + * one. It is kept because step 129 rebuilds it and it is still an entity. + */ +export class Migration20200101000000_baseline_schema extends Migration { + override name = 'Migration20200101000000_baseline_schema'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + maps_api_key TEXT, + unsplash_api_key TEXT, + amap_api_key TEXT, + openweather_api_key TEXT, + avatar TEXT, + oidc_sub TEXT, + oidc_issuer TEXT, + last_login DATETIME, + mfa_enabled INTEGER DEFAULT 0, + mfa_secret TEXT, + mfa_backup_codes TEXT, + immich_url TEXT, + immich_access_token TEXT, + synology_url TEXT, + synology_username TEXT, + synology_password TEXT, + synology_sid TEXT, + must_change_password INTEGER DEFAULT 0, + password_version INTEGER NOT NULL DEFAULT 0, + feed_token TEXT, + is_guest INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + consumed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_ip TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + name TEXT, + aaguid TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT, + UNIQUE(user_id, key) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + start_date TEXT, + end_date TEXT, + currency TEXT DEFAULT 'EUR', + cover_image TEXT, + is_archived INTEGER DEFAULT 0, + reminder_days INTEGER DEFAULT 3, + feed_token TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS days ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL, + date TEXT, + notes TEXT, + title TEXT, + UNIQUE(trip_id, day_number) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT '📍', + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#10b981', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + reservation_status TEXT DEFAULT 'none', + reservation_notes TEXT, + reservation_datetime TEXT, + place_time TEXT, + end_time TEXT, + duration_minutes INTEGER DEFAULT 60, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + website TEXT, + phone TEXT, + transport_mode TEXT DEFAULT 'walking', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS place_tags ( + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (place_id, tag_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(place_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS day_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + order_index INTEGER DEFAULT 0, + notes TEXT, + reservation_status TEXT DEFAULT 'none', + reservation_notes TEXT, + reservation_datetime TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + checked INTEGER DEFAULT 0, + category TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + file_size INTEGER, + mime_type TEXT, + caption TEXT, + taken_at TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + file_size INTEGER, + mime_type TEXT, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS reservations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, + end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL, + title TEXT NOT NULL, + accommodation_id TEXT, + reservation_time TEXT, + reservation_end_time TEXT, + location TEXT, + confirmation_number TEXT, + notes TEXT, + status TEXT DEFAULT 'pending', + type TEXT DEFAULT 'other', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invited_by INTEGER REFERENCES users(id), + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS day_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + text TEXT NOT NULL, + time TEXT, + icon TEXT DEFAULT '📝', + sort_order REAL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS budget_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category TEXT NOT NULL DEFAULT 'Other', + name TEXT NOT NULL, + total_price REAL NOT NULL DEFAULT 0, + persons INTEGER DEFAULT NULL, + days INTEGER DEFAULT NULL, + note TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + -- Addon system + CREATE TABLE IF NOT EXISTS addons ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'global', + icon TEXT DEFAULT 'Puzzle', + enabled INTEGER DEFAULT 0, + config TEXT DEFAULT '{}', + sort_order INTEGER DEFAULT 0 + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS photo_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT DEFAULT 'Image', + enabled INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0 + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS photo_provider_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + label TEXT NOT NULL, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + hint TEXT, + required INTEGER DEFAULT 0, + secret INTEGER DEFAULT 0, + settings_key TEXT, + payload_key TEXT, + sort_order INTEGER DEFAULT 0, + UNIQUE(provider_id, field_key) + ) + `); + + this.addSql(` + -- Vacay addon tables + CREATE TABLE IF NOT EXISTS vacay_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + block_weekends INTEGER DEFAULT 1, + holidays_enabled INTEGER DEFAULT 0, + holidays_region TEXT DEFAULT '', + school_holidays_enabled INTEGER DEFAULT 0, + company_holidays_enabled INTEGER DEFAULT 1, + carry_over_enabled INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(owner_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_plan_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(plan_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_user_colors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + color TEXT DEFAULT '#6366f1', + UNIQUE(user_id, plan_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_years ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + year INTEGER NOT NULL, + UNIQUE(plan_id, year) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_user_years ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + year INTEGER NOT NULL, + vacation_days INTEGER DEFAULT 30, + carried_over INTEGER DEFAULT 0, + UNIQUE(user_id, plan_id, year) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date TEXT NOT NULL, + note TEXT DEFAULT '', + UNIQUE(user_id, plan_id, date) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_company_holidays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + date TEXT NOT NULL, + note TEXT DEFAULT '', + UNIQUE(plan_id, date) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_holiday_calendars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'public_holiday', + region TEXT NOT NULL, + label TEXT, + color TEXT NOT NULL DEFAULT '#fecaca', + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + + this.addSql(` + -- Collections addon tables + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT 'Bookmark', + cover_image TEXT, + links TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + role TEXT NOT NULL DEFAULT 'editor', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + osm_id TEXT, + website TEXT, + phone TEXT, + status TEXT NOT NULL DEFAULT 'idea', + source_trip_id INTEGER, + source_place_id INTEGER, + links TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_place_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_tags ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, tag_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id)`); + + this.addSql(` + -- Per-collection custom labels (distinct from the instance-wide tags table): + -- each list defines its own labels and every place can carry several. + CREATE TABLE IF NOT EXISTS collection_labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#6366f1', + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_labels ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, label_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS day_accommodations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + check_in TEXT, + check_in_end TEXT, + check_out TEXT, + confirmation TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + -- Collab addon tables + CREATE TABLE IF NOT EXISTS collab_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category TEXT DEFAULT 'General', + title TEXT NOT NULL, + content TEXT, + color TEXT DEFAULT '#6366f1', + pinned INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_polls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options TEXT NOT NULL, + multiple INTEGER DEFAULT 0, + closed INTEGER DEFAULT 0, + deadline TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_poll_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_index INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(poll_id, user_id, option_index) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS assignment_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(assignment_id, user_id) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id)`, + ); + + this.addSql(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), + scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), + target INTEGER NOT NULL, + sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title_key TEXT NOT NULL, + title_params TEXT DEFAULT '{}', + text_key TEXT NOT NULL, + text_params TEXT DEFAULT '{}', + positive_text_key TEXT, + negative_text_key TEXT, + positive_callback TEXT, + negative_callback TEXT, + response TEXT CHECK(response IN ('positive', 'negative')), + navigate_text_key TEXT, + navigate_target TEXT, + is_read INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC)`, + ); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC)`, + ); + + this.addSql(` + CREATE TABLE IF NOT EXISTS notification_channel_preferences ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + channel TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (user_id, event_type, channel) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id)`); + + this.addSql( + `CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL)`, + ); + + this.addSql(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); + } +} diff --git a/server/src/db/migrations/Migration20200101000100_users_add_unsplash_api_key.ts b/server/src/db/migrations/Migration20200101000100_users_add_unsplash_api_key.ts new file mode 100644 index 000000000..6e9023343 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000100_users_add_unsplash_api_key.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 1 (`db/migrations.ts`). + */ +export class Migration20200101000100_users_add_unsplash_api_key extends Migration { + override name = 'Migration20200101000100_users_add_unsplash_api_key'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'unsplash_api_key', `unsplash_api_key TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000200_users_add_openweather_api_key.ts b/server/src/db/migrations/Migration20200101000200_users_add_openweather_api_key.ts new file mode 100644 index 000000000..8999dd1c9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000200_users_add_openweather_api_key.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 2 (`db/migrations.ts`). + */ +export class Migration20200101000200_users_add_openweather_api_key extends Migration { + override name = 'Migration20200101000200_users_add_openweather_api_key'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'openweather_api_key', `openweather_api_key TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000300_places_add_duration_minutes.ts b/server/src/db/migrations/Migration20200101000300_places_add_duration_minutes.ts new file mode 100644 index 000000000..fcd3cc89f --- /dev/null +++ b/server/src/db/migrations/Migration20200101000300_places_add_duration_minutes.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 3 (`db/migrations.ts`). + */ +export class Migration20200101000300_places_add_duration_minutes extends Migration { + override name = 'Migration20200101000300_places_add_duration_minutes'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'duration_minutes', `duration_minutes INTEGER DEFAULT 60`); + } +} diff --git a/server/src/db/migrations/Migration20200101000400_places_add_notes.ts b/server/src/db/migrations/Migration20200101000400_places_add_notes.ts new file mode 100644 index 000000000..ef2fa8c66 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000400_places_add_notes.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 4 (`db/migrations.ts`). + */ +export class Migration20200101000400_places_add_notes extends Migration { + override name = 'Migration20200101000400_places_add_notes'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'notes', `notes TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000500_places_add_image_url.ts b/server/src/db/migrations/Migration20200101000500_places_add_image_url.ts new file mode 100644 index 000000000..143d55872 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000500_places_add_image_url.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 5 (`db/migrations.ts`). + */ +export class Migration20200101000500_places_add_image_url extends Migration { + override name = 'Migration20200101000500_places_add_image_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'image_url', `image_url TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000600_places_add_transport_mode.ts b/server/src/db/migrations/Migration20200101000600_places_add_transport_mode.ts new file mode 100644 index 000000000..db8e9a7f0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000600_places_add_transport_mode.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 6 (`db/migrations.ts`). + */ +export class Migration20200101000600_places_add_transport_mode extends Migration { + override name = 'Migration20200101000600_places_add_transport_mode'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'transport_mode', `transport_mode TEXT DEFAULT 'walking'`); + } +} diff --git a/server/src/db/migrations/Migration20200101000700_days_add_title.ts b/server/src/db/migrations/Migration20200101000700_days_add_title.ts new file mode 100644 index 000000000..ce2b6c6e9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000700_days_add_title.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 7 (`db/migrations.ts`). + */ +export class Migration20200101000700_days_add_title extends Migration { + override name = 'Migration20200101000700_days_add_title'; + + override async up(): Promise { + await addColumnIfMissing(this, 'days', 'title', `title TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000800_reservations_add_status.ts b/server/src/db/migrations/Migration20200101000800_reservations_add_status.ts new file mode 100644 index 000000000..cd81010cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101000800_reservations_add_status.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 8 (`db/migrations.ts`). + */ +export class Migration20200101000800_reservations_add_status extends Migration { + override name = 'Migration20200101000800_reservations_add_status'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'status', `status TEXT DEFAULT 'pending'`); + } +} diff --git a/server/src/db/migrations/Migration20200101000900_trip_files_add_reservation_id.ts b/server/src/db/migrations/Migration20200101000900_trip_files_add_reservation_id.ts new file mode 100644 index 000000000..7d1266588 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000900_trip_files_add_reservation_id.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 9 (`db/migrations.ts`). + */ +export class Migration20200101000900_trip_files_add_reservation_id extends Migration { + override name = 'Migration20200101000900_trip_files_add_reservation_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'reservation_id', + `reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101001000_reservations_add_type.ts b/server/src/db/migrations/Migration20200101001000_reservations_add_type.ts new file mode 100644 index 000000000..93e18b291 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001000_reservations_add_type.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 10 (`db/migrations.ts`). + */ +export class Migration20200101001000_reservations_add_type extends Migration { + override name = 'Migration20200101001000_reservations_add_type'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'type', `type TEXT DEFAULT 'other'`); + } +} diff --git a/server/src/db/migrations/Migration20200101001100_trips_add_cover_image.ts b/server/src/db/migrations/Migration20200101001100_trips_add_cover_image.ts new file mode 100644 index 000000000..2f9ccb444 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001100_trips_add_cover_image.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 11 (`db/migrations.ts`). + */ +export class Migration20200101001100_trips_add_cover_image extends Migration { + override name = 'Migration20200101001100_trips_add_cover_image'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'cover_image', `cover_image TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001200_day_notes_add_icon.ts b/server/src/db/migrations/Migration20200101001200_day_notes_add_icon.ts new file mode 100644 index 000000000..e93a3ba86 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001200_day_notes_add_icon.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 12 (`db/migrations.ts`). + */ +export class Migration20200101001200_day_notes_add_icon extends Migration { + override name = 'Migration20200101001200_day_notes_add_icon'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_notes', 'icon', `icon TEXT DEFAULT '📝'`); + } +} diff --git a/server/src/db/migrations/Migration20200101001300_trips_add_is_archived.ts b/server/src/db/migrations/Migration20200101001300_trips_add_is_archived.ts new file mode 100644 index 000000000..045008552 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001300_trips_add_is_archived.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 13 (`db/migrations.ts`). + */ +export class Migration20200101001300_trips_add_is_archived extends Migration { + override name = 'Migration20200101001300_trips_add_is_archived'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'is_archived', `is_archived INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101001400_categories_add_user_id.ts b/server/src/db/migrations/Migration20200101001400_categories_add_user_id.ts new file mode 100644 index 000000000..7d043a8ed --- /dev/null +++ b/server/src/db/migrations/Migration20200101001400_categories_add_user_id.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 14 (`db/migrations.ts`). + */ +export class Migration20200101001400_categories_add_user_id extends Migration { + override name = 'Migration20200101001400_categories_add_user_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'categories', 'user_id', `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101001500_users_add_avatar.ts b/server/src/db/migrations/Migration20200101001500_users_add_avatar.ts new file mode 100644 index 000000000..34e82e319 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001500_users_add_avatar.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 15 (`db/migrations.ts`). + */ +export class Migration20200101001500_users_add_avatar extends Migration { + override name = 'Migration20200101001500_users_add_avatar'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'avatar', `avatar TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001600_users_add_oidc_sub.ts b/server/src/db/migrations/Migration20200101001600_users_add_oidc_sub.ts new file mode 100644 index 000000000..254784fab --- /dev/null +++ b/server/src/db/migrations/Migration20200101001600_users_add_oidc_sub.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 16 (`db/migrations.ts`). + */ +export class Migration20200101001600_users_add_oidc_sub extends Migration { + override name = 'Migration20200101001600_users_add_oidc_sub'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'oidc_sub', `oidc_sub TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001700_users_add_oidc_issuer.ts b/server/src/db/migrations/Migration20200101001700_users_add_oidc_issuer.ts new file mode 100644 index 000000000..025bf2716 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001700_users_add_oidc_issuer.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 17 (`db/migrations.ts`). + */ +export class Migration20200101001700_users_add_oidc_issuer extends Migration { + override name = 'Migration20200101001700_users_add_oidc_issuer'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'oidc_issuer', `oidc_issuer TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001800_users_add_last_login.ts b/server/src/db/migrations/Migration20200101001800_users_add_last_login.ts new file mode 100644 index 000000000..0a68c40e5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001800_users_add_last_login.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 18 (`db/migrations.ts`). + */ +export class Migration20200101001800_users_add_last_login extends Migration { + override name = 'Migration20200101001800_users_add_last_login'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'last_login', `last_login DATETIME`); + } +} diff --git a/server/src/db/migrations/Migration20200101001900_create_budget_items_new.ts b/server/src/db/migrations/Migration20200101001900_create_budget_items_new.ts new file mode 100644 index 000000000..c2078511a --- /dev/null +++ b/server/src/db/migrations/Migration20200101001900_create_budget_items_new.ts @@ -0,0 +1,43 @@ +import { tableSql } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 19 (`db/migrations.ts`). + * + * Drops the `NOT NULL DEFAULT 1` on `budget_items.persons`/`days` so an expense + * can leave them unset. SQLite cannot alter a column default, hence the rebuild; + * it only fires on databases whose stored DDL still carries the old constraint. + * + * The legacy step matched the bare string `NOT NULL DEFAULT 1` anywhere in the + * table's DDL. That is narrowed here to the two columns it is actually about: + * step 132 later adds `exchange_rate REAL NOT NULL DEFAULT 1`, so the loose test + * reports true on a fully-migrated table and the 10-column rebuild below then + * fails against its 17 columns. The legacy runner never hit that because + * `schema_version` stopped the step re-running; nothing gates it here. + */ +export class Migration20200101001900_create_budget_items_new extends Migration { + override name = 'Migration20200101001900_create_budget_items_new'; + + override async up(): Promise { + const sql = await tableSql(this, 'budget_items'); + if (!sql || !/\bpersons\s+INTEGER\s+NOT\s+NULL\s+DEFAULT\s+1/i.test(sql)) return; + + await this.execute(` + CREATE TABLE budget_items_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category TEXT NOT NULL DEFAULT 'Other', + name TEXT NOT NULL, + total_price REAL NOT NULL DEFAULT 0, + persons INTEGER DEFAULT NULL, + days INTEGER DEFAULT NULL, + note TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(`INSERT INTO budget_items_new SELECT * FROM budget_items`); + await this.execute(`DROP TABLE budget_items`); + await this.execute(`ALTER TABLE budget_items_new RENAME TO budget_items`); + } +} diff --git a/server/src/db/migrations/Migration20200101002000_day_accommodations_add_check_in.ts b/server/src/db/migrations/Migration20200101002000_day_accommodations_add_check_in.ts new file mode 100644 index 000000000..b9497dfcc --- /dev/null +++ b/server/src/db/migrations/Migration20200101002000_day_accommodations_add_check_in.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 20 (`db/migrations.ts`). + */ +export class Migration20200101002000_day_accommodations_add_check_in extends Migration { + override name = 'Migration20200101002000_day_accommodations_add_check_in'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_accommodations', 'check_in', `check_in TEXT`); + + await addColumnIfMissing(this, 'day_accommodations', 'check_out', `check_out TEXT`); + + await addColumnIfMissing(this, 'day_accommodations', 'confirmation', `confirmation TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101002100_places_add_end_time.ts b/server/src/db/migrations/Migration20200101002100_places_add_end_time.ts new file mode 100644 index 000000000..07d83e7b8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101002100_places_add_end_time.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 21 (`db/migrations.ts`). + */ +export class Migration20200101002100_places_add_end_time extends Migration { + override name = 'Migration20200101002100_places_add_end_time'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'end_time', `end_time TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101002200_day_assignments_add_reservation_status.ts b/server/src/db/migrations/Migration20200101002200_day_assignments_add_reservation_status.ts new file mode 100644 index 000000000..09ef7c4ad --- /dev/null +++ b/server/src/db/migrations/Migration20200101002200_day_assignments_add_reservation_status.ts @@ -0,0 +1,31 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 22 (`db/migrations.ts`). + * + * Moves reservation state off the place and onto the day assignment, so the same + * place booked on two days keeps two independent bookings. The copy is + * best-effort: it reads `places` columns that a later step removes, and the + * original step logged and swallowed a failure rather than blocking the boot. + */ +export class Migration20200101002200_day_assignments_add_reservation_status extends Migration { + override name = 'Migration20200101002200_day_assignments_add_reservation_status'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'reservation_status', `reservation_status TEXT DEFAULT 'none'`); + await addColumnIfMissing(this, 'day_assignments', 'reservation_notes', `reservation_notes TEXT`); + await addColumnIfMissing(this, 'day_assignments', 'reservation_datetime', `reservation_datetime TEXT`); + + await execBestEffort( + this, + ` + UPDATE day_assignments SET + reservation_status = (SELECT reservation_status FROM places WHERE places.id = day_assignments.place_id), + reservation_notes = (SELECT reservation_notes FROM places WHERE places.id = day_assignments.place_id), + reservation_datetime = (SELECT reservation_datetime FROM places WHERE places.id = day_assignments.place_id) + WHERE place_id IN (SELECT id FROM places WHERE reservation_status IS NOT NULL AND reservation_status != 'none') + `, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002300_reservations_add_assignment_id.ts b/server/src/db/migrations/Migration20200101002300_reservations_add_assignment_id.ts new file mode 100644 index 000000000..92bdda5dc --- /dev/null +++ b/server/src/db/migrations/Migration20200101002300_reservations_add_assignment_id.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 23 (`db/migrations.ts`). + */ +export class Migration20200101002300_reservations_add_assignment_id extends Migration { + override name = 'Migration20200101002300_reservations_add_assignment_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'reservations', + 'assignment_id', + `assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002400_create_assignment_participants.ts b/server/src/db/migrations/Migration20200101002400_create_assignment_participants.ts new file mode 100644 index 000000000..a9e8b0cad --- /dev/null +++ b/server/src/db/migrations/Migration20200101002400_create_assignment_participants.ts @@ -0,0 +1,19 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 24 (`db/migrations.ts`). + */ +export class Migration20200101002400_create_assignment_participants extends Migration { + override name = 'Migration20200101002400_create_assignment_participants'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS assignment_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(assignment_id, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101002500_create_collab_notes.ts b/server/src/db/migrations/Migration20200101002500_create_collab_notes.ts new file mode 100644 index 000000000..8c9285458 --- /dev/null +++ b/server/src/db/migrations/Migration20200101002500_create_collab_notes.ts @@ -0,0 +1,69 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 25 (`db/migrations.ts`). + * + * The Collab addon: shared notes, polls and trip chat. + */ +export class Migration20200101002500_create_collab_notes extends Migration { + override name = 'Migration20200101002500_create_collab_notes'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category TEXT DEFAULT 'General', + title TEXT NOT NULL, + content TEXT, + color TEXT DEFAULT '#6366f1', + pinned INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_polls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options TEXT NOT NULL, + multiple INTEGER DEFAULT 0, + closed INTEGER DEFAULT 0, + deadline TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_poll_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_index INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(poll_id, user_id, option_index) + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id)`); + + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002600_day_assignments_add_assignment_time.ts b/server/src/db/migrations/Migration20200101002600_day_assignments_add_assignment_time.ts new file mode 100644 index 000000000..5696d0c4f --- /dev/null +++ b/server/src/db/migrations/Migration20200101002600_day_assignments_add_assignment_time.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 26 (`db/migrations.ts`). + */ +export class Migration20200101002600_day_assignments_add_assignment_time extends Migration { + override name = 'Migration20200101002600_day_assignments_add_assignment_time'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'assignment_time', `assignment_time TEXT`); + + await addColumnIfMissing(this, 'day_assignments', 'assignment_end_time', `assignment_end_time TEXT`); + + await execBestEffort( + this, + ` + UPDATE day_assignments SET + assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id), + assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id) + `, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002700_create_budget_item_members.ts b/server/src/db/migrations/Migration20200101002700_create_budget_item_members.ts new file mode 100644 index 000000000..1409c5efa --- /dev/null +++ b/server/src/db/migrations/Migration20200101002700_create_budget_item_members.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 27 (`db/migrations.ts`). + */ +export class Migration20200101002700_create_budget_item_members extends Migration { + override name = 'Migration20200101002700_create_budget_item_members'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS budget_item_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + paid INTEGER NOT NULL DEFAULT 0, + UNIQUE(budget_item_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_item_members_item ON budget_item_members(budget_item_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101002800_create_collab_message_reactions.ts b/server/src/db/migrations/Migration20200101002800_create_collab_message_reactions.ts new file mode 100644 index 000000000..c47466faf --- /dev/null +++ b/server/src/db/migrations/Migration20200101002800_create_collab_message_reactions.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 28 (`db/migrations.ts`). + */ +export class Migration20200101002800_create_collab_message_reactions extends Migration { + override name = 'Migration20200101002800_create_collab_message_reactions'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_message_reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES collab_messages(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(message_id, user_id, emoji) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_reactions_msg ON collab_message_reactions(message_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101002900_collab_messages_add_deleted.ts b/server/src/db/migrations/Migration20200101002900_collab_messages_add_deleted.ts new file mode 100644 index 000000000..b499c3c2c --- /dev/null +++ b/server/src/db/migrations/Migration20200101002900_collab_messages_add_deleted.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 29 (`db/migrations.ts`). + */ +export class Migration20200101002900_collab_messages_add_deleted extends Migration { + override name = 'Migration20200101002900_collab_messages_add_deleted'; + + override async up(): Promise { + await addColumnIfMissing(this, 'collab_messages', 'deleted', `deleted INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101003000_trip_files_add_note_id.ts b/server/src/db/migrations/Migration20200101003000_trip_files_add_note_id.ts new file mode 100644 index 000000000..729cff68d --- /dev/null +++ b/server/src/db/migrations/Migration20200101003000_trip_files_add_note_id.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 30 (`db/migrations.ts`). + */ +export class Migration20200101003000_trip_files_add_note_id extends Migration { + override name = 'Migration20200101003000_trip_files_add_note_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'note_id', + `note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'collab_notes', 'website', `website TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003100_reservations_add_reservation_end_time.ts b/server/src/db/migrations/Migration20200101003100_reservations_add_reservation_end_time.ts new file mode 100644 index 000000000..1791d5527 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003100_reservations_add_reservation_end_time.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 31 (`db/migrations.ts`). + */ +export class Migration20200101003100_reservations_add_reservation_end_time extends Migration { + override name = 'Migration20200101003100_reservations_add_reservation_end_time'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'reservation_end_time', `reservation_end_time TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003200_places_add_osm_id.ts b/server/src/db/migrations/Migration20200101003200_places_add_osm_id.ts new file mode 100644 index 000000000..264c45027 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003200_places_add_osm_id.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 32 (`db/migrations.ts`). + */ +export class Migration20200101003200_places_add_osm_id extends Migration { + override name = 'Migration20200101003200_places_add_osm_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'osm_id', `osm_id TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003300_trip_files_add_uploaded_by.ts b/server/src/db/migrations/Migration20200101003300_trip_files_add_uploaded_by.ts new file mode 100644 index 000000000..b8426d324 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003300_trip_files_add_uploaded_by.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 33 (`db/migrations.ts`). + */ +export class Migration20200101003300_trip_files_add_uploaded_by extends Migration { + override name = 'Migration20200101003300_trip_files_add_uploaded_by'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'uploaded_by', + `uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'trip_files', 'starred', `starred INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'trip_files', 'deleted_at', `deleted_at TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003400_reservations_add_accommodation_id.ts b/server/src/db/migrations/Migration20200101003400_reservations_add_accommodation_id.ts new file mode 100644 index 000000000..0d379147e --- /dev/null +++ b/server/src/db/migrations/Migration20200101003400_reservations_add_accommodation_id.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 34 (`db/migrations.ts`). + */ +export class Migration20200101003400_reservations_add_accommodation_id extends Migration { + override name = 'Migration20200101003400_reservations_add_accommodation_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'reservations', + 'accommodation_id', + `accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'reservations', 'metadata', `metadata TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003500_create_invite_tokens.ts b/server/src/db/migrations/Migration20200101003500_create_invite_tokens.ts new file mode 100644 index 000000000..cbbfe2904 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003500_create_invite_tokens.ts @@ -0,0 +1,22 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 35 (`db/migrations.ts`). + */ +export class Migration20200101003500_create_invite_tokens extends Migration { + override name = 'Migration20200101003500_create_invite_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS invite_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + max_uses INTEGER NOT NULL DEFAULT 1, + used_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101003600_users_add_mfa_enabled.ts b/server/src/db/migrations/Migration20200101003600_users_add_mfa_enabled.ts new file mode 100644 index 000000000..811ee8166 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003600_users_add_mfa_enabled.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 36 (`db/migrations.ts`). + */ +export class Migration20200101003600_users_add_mfa_enabled extends Migration { + override name = 'Migration20200101003600_users_add_mfa_enabled'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'mfa_enabled', `mfa_enabled INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'users', 'mfa_secret', `mfa_secret TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003700_create_packing_category_assignees.ts b/server/src/db/migrations/Migration20200101003700_create_packing_category_assignees.ts new file mode 100644 index 000000000..85b89a89c --- /dev/null +++ b/server/src/db/migrations/Migration20200101003700_create_packing_category_assignees.ts @@ -0,0 +1,20 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 37 (`db/migrations.ts`). + */ +export class Migration20200101003700_create_packing_category_assignees extends Migration { + override name = 'Migration20200101003700_create_packing_category_assignees'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_category_assignees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category_name TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(trip_id, category_name, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101003800_create_packing_templates.ts b/server/src/db/migrations/Migration20200101003800_create_packing_templates.ts new file mode 100644 index 000000000..57a438029 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003800_create_packing_templates.ts @@ -0,0 +1,40 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 38 (`db/migrations.ts`). + */ +export class Migration20200101003800_create_packing_templates extends Migration { + override name = 'Migration20200101003800_create_packing_templates'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_template_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL REFERENCES packing_templates(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + + await execBestEffort(this, `DROP TABLE IF EXISTS packing_template_items`); + + await this.execute(` + CREATE TABLE packing_template_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101003900_create_packing_bags.ts b/server/src/db/migrations/Migration20200101003900_create_packing_bags.ts new file mode 100644 index 000000000..695d773ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101003900_create_packing_bags.ts @@ -0,0 +1,32 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 39 (`db/migrations.ts`). + */ +export class Migration20200101003900_create_packing_bags extends Migration { + override name = 'Migration20200101003900_create_packing_bags'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_bags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#6366f1', + weight_limit_grams INTEGER, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await addColumnIfMissing(this, 'packing_items', 'weight_grams', `weight_grams INTEGER`); + + await addColumnIfMissing( + this, + 'packing_items', + 'bag_id', + `bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101004000_create_visited_countries.ts b/server/src/db/migrations/Migration20200101004000_create_visited_countries.ts new file mode 100644 index 000000000..c46857845 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004000_create_visited_countries.ts @@ -0,0 +1,20 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 40 (`db/migrations.ts`). + */ +export class Migration20200101004000_create_visited_countries extends Migration { + override name = 'Migration20200101004000_create_visited_countries'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS visited_countries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, country_code) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004100_create_bucket_list.ts b/server/src/db/migrations/Migration20200101004100_create_bucket_list.ts new file mode 100644 index 000000000..f372c8f59 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004100_create_bucket_list.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 41 (`db/migrations.ts`). + */ +export class Migration20200101004100_create_bucket_list extends Migration { + override name = 'Migration20200101004100_create_bucket_list'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS bucket_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + lat REAL, + lng REAL, + country_code TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004200_vacay_plans_add_weekend_days.ts b/server/src/db/migrations/Migration20200101004200_vacay_plans_add_weekend_days.ts new file mode 100644 index 000000000..1e0d455cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101004200_vacay_plans_add_weekend_days.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 42 (`db/migrations.ts`). + */ +export class Migration20200101004200_vacay_plans_add_weekend_days extends Migration { + override name = 'Migration20200101004200_vacay_plans_add_weekend_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_plans', 'weekend_days', `weekend_days TEXT DEFAULT '0,6'`); + } +} diff --git a/server/src/db/migrations/Migration20200101004300_users_add_immich_url.ts b/server/src/db/migrations/Migration20200101004300_users_add_immich_url.ts new file mode 100644 index 000000000..0ce188e30 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004300_users_add_immich_url.ts @@ -0,0 +1,35 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 43 (`db/migrations.ts`). + * + * Immich integration: per-user credentials, the linked-asset table, and the + * Photos addon tile (disabled by default). + */ +export class Migration20200101004300_users_add_immich_url extends Migration { + override name = 'Migration20200101004300_users_add_immich_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'immich_url', `immich_url TEXT`); + await addColumnIfMissing(this, 'users', 'immich_api_key', `immich_api_key TEXT`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS trip_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + immich_asset_id TEXT NOT NULL, + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, immich_asset_id) + ) + `); + + await execBestEffort( + this, + `INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)`, + ['memories', 'Photos', 'trip', 'Image', 0, 7], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101004400_create_file_links.ts b/server/src/db/migrations/Migration20200101004400_create_file_links.ts new file mode 100644 index 000000000..47168a494 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004400_create_file_links.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 44 (`db/migrations.ts`). + */ +export class Migration20200101004400_create_file_links extends Migration { + override name = 'Migration20200101004400_create_file_links'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS file_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE, + reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE, + assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(file_id, reservation_id), + UNIQUE(file_id, assignment_id), + UNIQUE(file_id, place_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004500_reservations_add_day_plan_position.ts b/server/src/db/migrations/Migration20200101004500_reservations_add_day_plan_position.ts new file mode 100644 index 000000000..f0b707fbd --- /dev/null +++ b/server/src/db/migrations/Migration20200101004500_reservations_add_day_plan_position.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 45 (`db/migrations.ts`). + */ +export class Migration20200101004500_reservations_add_day_plan_position extends Migration { + override name = 'Migration20200101004500_reservations_add_day_plan_position'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'day_plan_position', `day_plan_position REAL DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101004600_budget_items_add_paid_by_user_id.ts b/server/src/db/migrations/Migration20200101004600_budget_items_add_paid_by_user_id.ts new file mode 100644 index 000000000..e67ecb690 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004600_budget_items_add_paid_by_user_id.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 46 (`db/migrations.ts`). + */ +export class Migration20200101004600_budget_items_add_paid_by_user_id extends Migration { + override name = 'Migration20200101004600_budget_items_add_paid_by_user_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'paid_by_user_id', `paid_by_user_id INTEGER REFERENCES users(id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101004700_bucket_list_add_target_date.ts b/server/src/db/migrations/Migration20200101004700_bucket_list_add_target_date.ts new file mode 100644 index 000000000..c0d85e6a3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004700_bucket_list_add_target_date.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 47 (`db/migrations.ts`). + */ +export class Migration20200101004700_bucket_list_add_target_date extends Migration { + override name = 'Migration20200101004700_bucket_list_add_target_date'; + + override async up(): Promise { + await addColumnIfMissing(this, 'bucket_list', 'target_date', `target_date TEXT DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101004800_create_notification_preferences.ts b/server/src/db/migrations/Migration20200101004800_create_notification_preferences.ts new file mode 100644 index 000000000..e4b3650cf --- /dev/null +++ b/server/src/db/migrations/Migration20200101004800_create_notification_preferences.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 48 (`db/migrations.ts`). + */ +export class Migration20200101004800_create_notification_preferences extends Migration { + override name = 'Migration20200101004800_create_notification_preferences'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS notification_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + notify_trip_invite INTEGER DEFAULT 1, + notify_booking_change INTEGER DEFAULT 1, + notify_trip_reminder INTEGER DEFAULT 1, + notify_vacay_invite INTEGER DEFAULT 1, + notify_photos_shared INTEGER DEFAULT 1, + notify_collab_message INTEGER DEFAULT 1, + notify_packing_tagged INTEGER DEFAULT 1, + notify_webhook INTEGER DEFAULT 0, + UNIQUE(user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004900_notification_preferences_add_notify_vacay_invite.ts b/server/src/db/migrations/Migration20200101004900_notification_preferences_add_notify_vacay_invite.ts new file mode 100644 index 000000000..e049bcb43 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004900_notification_preferences_add_notify_vacay_invite.ts @@ -0,0 +1,39 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 49 (`db/migrations.ts`). + */ +export class Migration20200101004900_notification_preferences_add_notify_vacay_invite extends Migration { + override name = 'Migration20200101004900_notification_preferences_add_notify_vacay_invite'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_vacay_invite', + `notify_vacay_invite INTEGER DEFAULT 1`, + ); + + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_photos_shared', + `notify_photos_shared INTEGER DEFAULT 1`, + ); + + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_collab_message', + `notify_collab_message INTEGER DEFAULT 1`, + ); + + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_packing_tagged', + `notify_packing_tagged INTEGER DEFAULT 1`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101005000_create_share_tokens.ts b/server/src/db/migrations/Migration20200101005000_create_share_tokens.ts new file mode 100644 index 000000000..2a63323e8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005000_create_share_tokens.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 50 (`db/migrations.ts`). + */ +export class Migration20200101005000_create_share_tokens extends Migration { + override name = 'Migration20200101005000_create_share_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS share_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + share_map INTEGER DEFAULT 1, + share_bookings INTEGER DEFAULT 1, + share_packing INTEGER DEFAULT 0, + share_budget INTEGER DEFAULT 0, + share_collab INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101005100_share_tokens_add_share_map.ts b/server/src/db/migrations/Migration20200101005100_share_tokens_add_share_map.ts new file mode 100644 index 000000000..5db4db8d7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005100_share_tokens_add_share_map.ts @@ -0,0 +1,21 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 51 (`db/migrations.ts`). + */ +export class Migration20200101005100_share_tokens_add_share_map extends Migration { + override name = 'Migration20200101005100_share_tokens_add_share_map'; + + override async up(): Promise { + await addColumnIfMissing(this, 'share_tokens', 'share_map', `share_map INTEGER DEFAULT 1`); + + await addColumnIfMissing(this, 'share_tokens', 'share_bookings', `share_bookings INTEGER DEFAULT 1`); + + await addColumnIfMissing(this, 'share_tokens', 'share_packing', `share_packing INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'share_tokens', 'share_budget', `share_budget INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'share_tokens', 'share_collab', `share_collab INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101005200_create_audit_log.ts b/server/src/db/migrations/Migration20200101005200_create_audit_log.ts new file mode 100644 index 000000000..c9cd6f5c5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005200_create_audit_log.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 52 (`db/migrations.ts`). + */ +export class Migration20200101005200_create_audit_log extends Migration { + override name = 'Migration20200101005200_create_audit_log'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`); + } +} diff --git a/server/src/db/migrations/Migration20200101005300_users_add_mfa_backup_codes.ts b/server/src/db/migrations/Migration20200101005300_users_add_mfa_backup_codes.ts new file mode 100644 index 000000000..af8e499fb --- /dev/null +++ b/server/src/db/migrations/Migration20200101005300_users_add_mfa_backup_codes.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 53 (`db/migrations.ts`). + */ +export class Migration20200101005300_users_add_mfa_backup_codes extends Migration { + override name = 'Migration20200101005300_users_add_mfa_backup_codes'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'mfa_backup_codes', `mfa_backup_codes TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101005400_mcp_long_lived_api_tokens.ts b/server/src/db/migrations/Migration20200101005400_mcp_long_lived_api_tokens.ts new file mode 100644 index 000000000..33c9893ff --- /dev/null +++ b/server/src/db/migrations/Migration20200101005400_mcp_long_lived_api_tokens.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 54 (`db/migrations.ts`). + * + * MCP long-lived API tokens + */ +export class Migration20200101005400_mcp_long_lived_api_tokens extends Migration { + override name = 'Migration20200101005400_mcp_long_lived_api_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS mcp_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101005500_mcp_addon_entry.ts b/server/src/db/migrations/Migration20200101005500_mcp_addon_entry.ts new file mode 100644 index 000000000..2928316a5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005500_mcp_addon_entry.ts @@ -0,0 +1,19 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 55 (`db/migrations.ts`). + * + * MCP addon entry. + */ +export class Migration20200101005500_mcp_addon_entry extends Migration { + override name = 'Migration20200101005500_mcp_addon_entry'; + + override async up(): Promise { + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101005600_index_on_mcp_tokens.ts b/server/src/db/migrations/Migration20200101005600_index_on_mcp_tokens.ts new file mode 100644 index 000000000..4ccbfcbe5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005600_index_on_mcp_tokens.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 56 (`db/migrations.ts`). + * + * Index on mcp_tokens.token_hash + */ +export class Migration20200101005600_index_on_mcp_tokens extends Migration { + override name = 'Migration20200101005600_index_on_mcp_tokens'; + + override up(): void { + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash)`); + } +} diff --git a/server/src/db/migrations/Migration20200101005700_ensure_mcp_addon_type_is_integration.ts b/server/src/db/migrations/Migration20200101005700_ensure_mcp_addon_type_is_integration.ts new file mode 100644 index 000000000..8567cdafa --- /dev/null +++ b/server/src/db/migrations/Migration20200101005700_ensure_mcp_addon_type_is_integration.ts @@ -0,0 +1,15 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 57 (`db/migrations.ts`). + * + * Ensure MCP addon type is 'integration'. + */ +export class Migration20200101005700_ensure_mcp_addon_type_is_integration extends Migration { + override name = 'Migration20200101005700_ensure_mcp_addon_type_is_integration'; + + override async up(): Promise { + await execBestEffort(this, `UPDATE addons SET type = 'integration' WHERE id = 'mcp'`); + } +} diff --git a/server/src/db/migrations/Migration20200101005800_places_add_route_geometry.ts b/server/src/db/migrations/Migration20200101005800_places_add_route_geometry.ts new file mode 100644 index 000000000..15ab1cfc9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005800_places_add_route_geometry.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 58 (`db/migrations.ts`). + */ +export class Migration20200101005800_places_add_route_geometry extends Migration { + override name = 'Migration20200101005800_places_add_route_geometry'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'route_geometry', `route_geometry TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101005900_users_add_must_change_password.ts b/server/src/db/migrations/Migration20200101005900_users_add_must_change_password.ts new file mode 100644 index 000000000..f25e36b57 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005900_users_add_must_change_password.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 59 (`db/migrations.ts`). + */ +export class Migration20200101005900_users_add_must_change_password extends Migration { + override name = 'Migration20200101005900_users_add_must_change_password'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'must_change_password', `must_change_password INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101010000_trips_add_reminder_days.ts b/server/src/db/migrations/Migration20200101010000_trips_add_reminder_days.ts new file mode 100644 index 000000000..31d6a8202 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010000_trips_add_reminder_days.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 60 (`db/migrations.ts`). + */ +export class Migration20200101010000_trips_add_reminder_days extends Migration { + override name = 'Migration20200101010000_trips_add_reminder_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'reminder_days', `reminder_days INTEGER DEFAULT 3`); + } +} diff --git a/server/src/db/migrations/Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left.ts b/server/src/db/migrations/Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left.ts new file mode 100644 index 000000000..294fba686 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left.ts @@ -0,0 +1,20 @@ +import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 61 (`db/migrations.ts`). + * + * Encrypt any plaintext oidc_client_secret left in app_settings. + */ +export class Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left extends Migration { + override name = 'Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left'; + + override async up(): Promise { + const rows = (await this.execute(`SELECT value FROM app_settings WHERE key = 'oidc_client_secret'`)) as { + value: string | null; + }[]; + const value = rows[0]?.value; + if (!value || value.startsWith('enc:v1:')) return; + await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'oidc_client_secret'`, [encrypt_api_key(value)]); + } +} diff --git a/server/src/db/migrations/Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in.ts b/server/src/db/migrations/Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in.ts new file mode 100644 index 000000000..22c0bd15a --- /dev/null +++ b/server/src/db/migrations/Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in.ts @@ -0,0 +1,20 @@ +import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 62 (`db/migrations.ts`). + * + * Encrypt any plaintext smtp_pass left in app_settings. + */ +export class Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in extends Migration { + override name = 'Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in'; + + override async up(): Promise { + const rows = (await this.execute(`SELECT value FROM app_settings WHERE key = 'smtp_pass'`)) as { + value: string | null; + }[]; + const value = rows[0]?.value; + if (!value || value.startsWith('enc:v1:')) return; + await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'smtp_pass'`, [encrypt_api_key(value)]); + } +} diff --git a/server/src/db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts b/server/src/db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts new file mode 100644 index 000000000..2fbd64f76 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts @@ -0,0 +1,23 @@ +import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 63 (`db/migrations.ts`). + * + * Encrypt any plaintext immich_api_key values in the users table. + */ +export class Migration20200101010300_encrypt_any_plaintext_immich_api_key_values extends Migration { + override name = 'Migration20200101010300_encrypt_any_plaintext_immich_api_key_values'; + + override async up(): Promise { + const rows = (await this.execute( + `SELECT id, immich_api_key FROM users WHERE immich_api_key IS NOT NULL AND immich_api_key != '' AND immich_api_key NOT LIKE 'enc:v1:%'`, + )) as { id: number; immich_api_key: string }[]; + for (const row of rows) { + await this.execute(`UPDATE users SET immich_api_key = ? WHERE id = ?`, [ + encrypt_api_key(row.immich_api_key), + row.id, + ]); + } + } +} diff --git a/server/src/db/migrations/Migration20200101010400_budget_items_add_expense_date.ts b/server/src/db/migrations/Migration20200101010400_budget_items_add_expense_date.ts new file mode 100644 index 000000000..a411629c0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010400_budget_items_add_expense_date.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 64 (`db/migrations.ts`). + */ +export class Migration20200101010400_budget_items_add_expense_date extends Migration { + override name = 'Migration20200101010400_budget_items_add_expense_date'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'expense_date', `expense_date TEXT DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101010500_create_trip_album_links.ts b/server/src/db/migrations/Migration20200101010500_create_trip_album_links.ts new file mode 100644 index 000000000..e378a8816 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010500_create_trip_album_links.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 65 (`db/migrations.ts`). + */ +export class Migration20200101010500_create_trip_album_links extends Migration { + override name = 'Migration20200101010500_create_trip_album_links'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_album_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + immich_album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, immich_album_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101010600_create_notifications.ts b/server/src/db/migrations/Migration20200101010600_create_notifications.ts new file mode 100644 index 000000000..8d55d129d --- /dev/null +++ b/server/src/db/migrations/Migration20200101010600_create_notifications.ts @@ -0,0 +1,42 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 66 (`db/migrations.ts`). + */ +export class Migration20200101010600_create_notifications extends Migration { + override name = 'Migration20200101010600_create_notifications'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), + scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), + target INTEGER NOT NULL, + sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title_key TEXT NOT NULL, + title_params TEXT DEFAULT '{}', + text_key TEXT NOT NULL, + text_params TEXT DEFAULT '{}', + positive_text_key TEXT, + negative_text_key TEXT, + positive_callback TEXT, + negative_callback TEXT, + response TEXT CHECK(response IN ('positive', 'negative')), + navigate_text_key TEXT, + navigate_target TEXT, + is_read INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC)`, + ); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101010700_create_trip_photos.ts b/server/src/db/migrations/Migration20200101010700_create_trip_photos.ts new file mode 100644 index 000000000..1e8585439 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010700_create_trip_photos.ts @@ -0,0 +1,54 @@ +import { columnNames, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const TRIP_PHOTOS_DDL = ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'immich', + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, asset_id, provider) +`; + +/** + * Legacy migration step 67 (`db/migrations.ts`). + * + * Normalize trip_photos to the provider-based schema used by current routes. + * The table arrived in two shapes — Immich-only (`immich_asset_id`) and the + * provider-aware one — so the rebuild reads whichever columns are actually + * present and fills the rest with the Immich defaults. + */ +export class Migration20200101010700_create_trip_photos extends Migration { + override name = 'Migration20200101010700_create_trip_photos'; + + override async up(): Promise { + if (!(await tableExists(this, 'trip_photos'))) { + await this.execute(`CREATE TABLE trip_photos (${TRIP_PHOTOS_DDL})`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`); + return; + } + + const names = await columnNames(this, 'trip_photos'); + const assetSource = names.has('asset_id') ? 'asset_id' : names.has('immich_asset_id') ? 'immich_asset_id' : null; + if (!assetSource) return; + + const providerExpr = names.has('provider') + ? `CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END` + : `'immich'`; + const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1'; + const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + await this.execute(`CREATE TABLE trip_photos_new (${TRIP_PHOTOS_DDL})`); + await this.execute(` + INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at) + SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr} + FROM trip_photos + WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != '' + `); + await this.execute(`DROP TABLE trip_photos`); + await this.execute(`ALTER TABLE trip_photos_new RENAME TO trip_photos`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101010800_create_trip_album_links.ts b/server/src/db/migrations/Migration20200101010800_create_trip_album_links.ts new file mode 100644 index 000000000..1cc875bef --- /dev/null +++ b/server/src/db/migrations/Migration20200101010800_create_trip_album_links.ts @@ -0,0 +1,56 @@ +import { columnNames, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const ALBUM_LINKS_DDL = ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, provider, album_id) +`; + +/** + * Legacy migration step 68 (`db/migrations.ts`). + * + * Normalize trip_album_links to the provider + album_id schema used by current + * routes, reading whichever of the two historical shapes the database has. + */ +export class Migration20200101010800_create_trip_album_links extends Migration { + override name = 'Migration20200101010800_create_trip_album_links'; + + override async up(): Promise { + if (!(await tableExists(this, 'trip_album_links'))) { + await this.execute(`CREATE TABLE trip_album_links (${ALBUM_LINKS_DDL})`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`); + return; + } + + const names = await columnNames(this, 'trip_album_links'); + const albumIdSource = names.has('album_id') ? 'album_id' : names.has('immich_album_id') ? 'immich_album_id' : null; + if (!albumIdSource) return; + + const providerExpr = names.has('provider') + ? `CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END` + : `'immich'`; + const albumNameExpr = names.has('album_name') ? `COALESCE(album_name, '')` : `''`; + const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1'; + const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL'; + const createdAtExpr = names.has('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + await this.execute(`CREATE TABLE trip_album_links_new (${ALBUM_LINKS_DDL})`); + await this.execute(` + INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at) + SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr} + FROM trip_album_links + WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != '' + `); + await this.execute(`DROP TABLE trip_album_links`); + await this.execute(`ALTER TABLE trip_album_links_new RENAME TO trip_album_links`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101010900_users_add_synology_url.ts b/server/src/db/migrations/Migration20200101010900_users_add_synology_url.ts new file mode 100644 index 000000000..a381f85ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101010900_users_add_synology_url.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 69 (`db/migrations.ts`). + */ +export class Migration20200101010900_users_add_synology_url extends Migration { + override name = 'Migration20200101010900_users_add_synology_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'synology_url', `synology_url TEXT`); + + await addColumnIfMissing(this, 'users', 'synology_username', `synology_username TEXT`); + + await addColumnIfMissing(this, 'users', 'synology_password', `synology_password TEXT`); + + await addColumnIfMissing(this, 'users', 'synology_sid', `synology_sid TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101011000_step_070.ts b/server/src/db/migrations/Migration20200101011000_step_070.ts new file mode 100644 index 000000000..c6715abce --- /dev/null +++ b/server/src/db/migrations/Migration20200101011000_step_070.ts @@ -0,0 +1,97 @@ +import { execUnlessTableMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const PROVIDER_FIELDS: [string, string, string, string, string, number, number, string | null, string, number][] = [ + [ + 'synologyphotos', + 'synology_url', + 'providerUrl', + 'url', + 'https://synology.example.com', + 1, + 0, + 'synology_url', + 'synology_url', + 0, + ], + [ + 'synologyphotos', + 'synology_username', + 'providerUsername', + 'text', + 'Username', + 1, + 0, + 'synology_username', + 'synology_username', + 1, + ], + [ + 'synologyphotos', + 'synology_password', + 'providerPassword', + 'password', + 'Password', + 1, + 1, + null, + 'synology_password', + 2, + ], +]; + +/** + * Legacy migration step 70 (`db/migrations.ts`). + * + * Seed the Synology Photos provider and its fields in existing databases. Both + * statements are skipped when the provider tables do not exist yet — on those + * installs the seeder creates the rows instead. + */ +export class Migration20200101011000_step_070 extends Migration { + override name = 'Migration20200101011000_step_070'; + + override async up(): Promise { + await execUnlessTableMissing( + this, + ` + INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + icon = excluded.icon, + enabled = excluded.enabled, + sort_order = excluded.sort_order + `, + [ + 'synologyphotos', + 'Synology Photos', + 'Synology Photos integration with separate account settings', + 'Image', + 0, + 1, + ], + ); + + for (const field of PROVIDER_FIELDS) { + await execUnlessTableMissing( + this, + ` + INSERT INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider_id, field_key) DO UPDATE SET + label = excluded.label, + input_type = excluded.input_type, + placeholder = excluded.placeholder, + required = excluded.required, + secret = excluded.secret, + settings_key = excluded.settings_key, + payload_key = excluded.payload_key, + sort_order = excluded.sort_order + `, + field, + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101011100_step_071.ts b/server/src/db/migrations/Migration20200101011100_step_071.ts new file mode 100644 index 000000000..a72836c66 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011100_step_071.ts @@ -0,0 +1,18 @@ +import { columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 71 (`db/migrations.ts`). + * + * Remove the stored config column from photo_providers now that it is generated + * from the provider id. + */ +export class Migration20200101011100_step_071 extends Migration { + override name = 'Migration20200101011100_step_071'; + + override async up(): Promise { + const names = await columnNames(this, 'photo_providers'); + if (!names.has('config')) return; + await this.execute(`ALTER TABLE photo_providers DROP COLUMN config`); + } +} diff --git a/server/src/db/migrations/Migration20200101011200_step_072.ts b/server/src/db/migrations/Migration20200101011200_step_072.ts new file mode 100644 index 000000000..cd5bdc70d --- /dev/null +++ b/server/src/db/migrations/Migration20200101011200_step_072.ts @@ -0,0 +1,22 @@ +import { columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 72 (`db/migrations.ts`). + * + * The Immich-only column names become provider-neutral ones. A database that + * already went through step 67/68 is left alone. + */ +export class Migration20200101011200_step_072 extends Migration { + override name = 'Migration20200101011200_step_072'; + + override async up(): Promise { + const names = await columnNames(this, 'trip_photos'); + if (names.has('asset_id') && !names.has('immich_asset_id')) return; + + await this.execute('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id'); + await this.execute('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); + await this.execute('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); + await this.execute('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id'); + } +} diff --git a/server/src/db/migrations/Migration20200101011300_trip_photos_add_album_link_id.ts b/server/src/db/migrations/Migration20200101011300_trip_photos_add_album_link_id.ts new file mode 100644 index 000000000..5938e91e7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011300_trip_photos_add_album_link_id.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 73 (`db/migrations.ts`). + */ +export class Migration20200101011300_trip_photos_add_album_link_id extends Migration { + override name = 'Migration20200101011300_trip_photos_add_album_link_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_photos', + 'album_link_id', + `album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL`, + ); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_album_link ON trip_photos(album_link_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101011400_todo_items.ts b/server/src/db/migrations/Migration20200101011400_todo_items.ts new file mode 100644 index 000000000..36c665e52 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011400_todo_items.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 74 (`db/migrations.ts`). + * + * Migration 68: Todo items + */ +export class Migration20200101011400_todo_items extends Migration { + override name = 'Migration20200101011400_todo_items'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS todo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + checked INTEGER DEFAULT 0, + category TEXT, + sort_order INTEGER DEFAULT 0, + due_date TEXT, + description TEXT, + assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + priority INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_todo_items_trip_id ON todo_items(trip_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS todo_category_assignees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category_name TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(trip_id, category_name, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101011500_backfill_addons.ts b/server/src/db/migrations/Migration20200101011500_backfill_addons.ts new file mode 100644 index 000000000..62c80f828 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011500_backfill_addons.ts @@ -0,0 +1,13 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 75 (`db/migrations.ts`). + */ +export class Migration20200101011500_backfill_addons extends Migration { + override name = 'Migration20200101011500_backfill_addons'; + + override async up(): Promise { + await execBestEffort(this, `UPDATE addons SET enabled = 0 WHERE id = 'memories'`); + } +} diff --git a/server/src/db/migrations/Migration20200101011600_place_region_cache_for_sub_national_atlas.ts b/server/src/db/migrations/Migration20200101011600_place_region_cache_for_sub_national_atlas.ts new file mode 100644 index 000000000..be30d43fc --- /dev/null +++ b/server/src/db/migrations/Migration20200101011600_place_region_cache_for_sub_national_atlas.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 76 (`db/migrations.ts`). + * + * Migration 69: Place region cache for sub-national Atlas regions + */ +export class Migration20200101011600_place_region_cache_for_sub_national_atlas extends Migration { + override name = 'Migration20200101011600_place_region_cache_for_sub_national_atlas'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_regions ( + place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, + country_code TEXT NOT NULL, + region_code TEXT NOT NULL, + region_name TEXT NOT NULL + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code)`); + } +} diff --git a/server/src/db/migrations/Migration20200101011700_create_visited_regions.ts b/server/src/db/migrations/Migration20200101011700_create_visited_regions.ts new file mode 100644 index 000000000..923328451 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011700_create_visited_regions.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 77 (`db/migrations.ts`). + */ +export class Migration20200101011700_create_visited_regions extends Migration { + override name = 'Migration20200101011700_create_visited_regions'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS visited_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + region_code TEXT NOT NULL, + region_name TEXT NOT NULL, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, region_code) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code)`); + } +} diff --git a/server/src/db/migrations/Migration20200101011800_normalized_per_user_per_channel_notification_preferences.ts b/server/src/db/migrations/Migration20200101011800_normalized_per_user_per_channel_notification_preferences.ts new file mode 100644 index 000000000..c91983703 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011800_normalized_per_user_per_channel_notification_preferences.ts @@ -0,0 +1,69 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** Old single-row-per-user columns → the event_type they become. */ +const EVENT_COLUMNS: Record = { + trip_invite: 'notify_trip_invite', + booking_change: 'notify_booking_change', + trip_reminder: 'notify_trip_reminder', + vacay_invite: 'notify_vacay_invite', + photos_shared: 'notify_photos_shared', + collab_message: 'notify_collab_message', + packing_tagged: 'notify_packing_tagged', +}; + +/** + * Legacy migration step 78 (`db/migrations.ts`). + * + * Migration 71: Normalized per-user per-channel notification preferences. + * + * Only the *disabled* combinations are written: a missing row means enabled, so + * copying the defaults across would bloat the table for no change in behaviour. + */ +export class Migration20200101011800_normalized_per_user_per_channel_notification_preferences extends Migration { + override name = 'Migration20200101011800_normalized_per_user_per_channel_notification_preferences'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS notification_channel_preferences ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + channel TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (user_id, event_type, channel) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id)`); + + // The old table does not exist on fresh installs. + const oldPrefs = (await tableExists(this, 'notification_preferences')) + ? ((await this.execute(`SELECT * FROM notification_preferences`)) as Record[]) + : []; + + for (const row of oldPrefs) { + const userId = row.user_id; + const webhookEnabled = row.notify_webhook ?? 0; + for (const [eventType, col] of Object.entries(EVENT_COLUMNS)) { + const emailEnabled = row[col] ?? 1; + if (!emailEnabled) { + await this.execute( + `INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)`, + [userId, eventType, 'email', 0], + ); + } + if (!webhookEnabled) { + await this.execute( + `INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)`, + [userId, eventType, 'webhook', 0], + ); + } + } + } + + // Copy the existing single-channel setting to the new plural key. + await this.execute(` + INSERT OR IGNORE INTO app_settings (key, value) + SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel' + `); + } +} diff --git a/server/src/db/migrations/Migration20200101011900_drop_the_old_notification_preferences_table.ts b/server/src/db/migrations/Migration20200101011900_drop_the_old_notification_preferences_table.ts new file mode 100644 index 000000000..7d09b1313 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011900_drop_the_old_notification_preferences_table.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 79 (`db/migrations.ts`). + * + * Migration 72: Drop the old notification_preferences table (data migrated to notification_channel_preferences in migration 71) + */ +export class Migration20200101011900_drop_the_old_notification_preferences_table extends Migration { + override name = 'Migration20200101011900_drop_the_old_notification_preferences_table'; + + override up(): void { + this.addSql(`DROP TABLE IF EXISTS notification_preferences`); + } +} diff --git a/server/src/db/migrations/Migration20200101012000_add_reservation_id_to_budget_items_for.ts b/server/src/db/migrations/Migration20200101012000_add_reservation_id_to_budget_items_for.ts new file mode 100644 index 000000000..bf8b66bed --- /dev/null +++ b/server/src/db/migrations/Migration20200101012000_add_reservation_id_to_budget_items_for.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 80 (`db/migrations.ts`). + * + * Migration 73: Add reservation_id to budget_items for linking budget entries to reservations + */ +export class Migration20200101012000_add_reservation_id_to_budget_items_for extends Migration { + override name = 'Migration20200101012000_add_reservation_id_to_budget_items_for'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'budget_items', + 'reservation_id', + `reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101012100_add_quantity_to_packing_items_user_id.ts b/server/src/db/migrations/Migration20200101012100_add_quantity_to_packing_items_user_id.ts new file mode 100644 index 000000000..d7d4a962a --- /dev/null +++ b/server/src/db/migrations/Migration20200101012100_add_quantity_to_packing_items_user_id.ts @@ -0,0 +1,43 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 81 (`db/migrations.ts`). + * + * Migration 74: Add quantity to packing_items + user_id to packing_bags + + * bag_members table. The single `user_id` becomes the first row of the new + * membership table; the column itself stays for the steps that still read it. + */ +export class Migration20200101012100_add_quantity_to_packing_items_user_id extends Migration { + override name = 'Migration20200101012100_add_quantity_to_packing_items_user_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'quantity', `quantity INTEGER NOT NULL DEFAULT 1`); + await addColumnIfMissing( + this, + 'packing_bags', + 'user_id', + `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL DEFAULT NULL`, + ); + + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_bag_members ( + bag_id INTEGER NOT NULL REFERENCES packing_bags(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (bag_id, user_id) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_packing_bag_members_bag ON packing_bag_members(bag_id)`); + + const bagsWithUser = (await this.execute(`SELECT id, user_id FROM packing_bags WHERE user_id IS NOT NULL`)) as { + id: number; + user_id: number; + }[]; + for (const bag of bagsWithUser) { + await this.execute(`INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)`, [ + bag.id, + bag.user_id, + ]); + } + } +} diff --git a/server/src/db/migrations/Migration20200101012200_per_day_positions_for_multi_day_reservations.ts b/server/src/db/migrations/Migration20200101012200_per_day_positions_for_multi_day_reservations.ts new file mode 100644 index 000000000..ccb69e92d --- /dev/null +++ b/server/src/db/migrations/Migration20200101012200_per_day_positions_for_multi_day_reservations.ts @@ -0,0 +1,55 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** A reservation that still carries the old single, trip-wide plan position. */ +interface PositionedReservation { + id: number; + trip_id: number; + reservation_time: string | null; + reservation_end_time: string | null; + day_plan_position: number; +} + +/** + * Legacy migration step 82 (`db/migrations.ts`). + * + * Per-day positions for multi-day reservations: one global position becomes one + * row per day the reservation spans, so a stay can sit in a different slot on + * each of its days. + */ +export class Migration20200101012200_per_day_positions_for_multi_day_reservations extends Migration { + override name = 'Migration20200101012200_per_day_positions_for_multi_day_reservations'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS reservation_day_positions ( + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + position REAL NOT NULL, + PRIMARY KEY (reservation_id, day_id) + ) + `); + + const reservations = (await this.execute( + `SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL`, + )) as PositionedReservation[]; + + for (const r of reservations) { + const startDate = r.reservation_time?.split('T')[0]; + if (!startDate) continue; + const endDate = r.reservation_end_time?.split('T')[0] || startDate; + + const matchingDays = (await this.execute(`SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?`, [ + r.trip_id, + startDate, + endDate, + ])) as { id: number }[]; + + for (const day of matchingDays) { + await this.execute( + `INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)`, + [r.id, day.id, r.day_plan_position], + ); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101012300_budget_category_ordering.ts b/server/src/db/migrations/Migration20200101012300_budget_category_ordering.ts new file mode 100644 index 000000000..04906bf53 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012300_budget_category_ordering.ts @@ -0,0 +1,39 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 83 (`db/migrations.ts`). + * + * Budget category ordering. Existing categories are seeded in alphabetical order + * per trip, which is the order they were displayed in before this table existed. + */ +export class Migration20200101012300_budget_category_ordering extends Migration { + override name = 'Migration20200101012300_budget_category_ordering'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS budget_category_order ( + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (trip_id, category) + ) + `); + + const rows = (await this.execute( + `SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category`, + )) as { trip_id: number; category: string }[]; + + let lastTripId = -1; + let index = 0; + for (const row of rows) { + if (row.trip_id !== lastTripId) { + lastTripId = row.trip_id; + index = 0; + } + await this.execute( + `INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)`, + [row.trip_id, row.category, index++], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101012400_naver_list_import_addon.ts b/server/src/db/migrations/Migration20200101012400_naver_list_import_addon.ts new file mode 100644 index 000000000..3218f6d25 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012400_naver_list_import_addon.ts @@ -0,0 +1,19 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 84 (`db/migrations.ts`). + * + * Naver list import addon (default off). + */ +export class Migration20200101012400_naver_list_import_addon extends Migration { + override name = 'Migration20200101012400_naver_list_import_addon'; + + override async up(): Promise { + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['naver_list_import', 'Naver List Import', 'Import places from shared Naver Maps lists', 'trip', 'Link2', 0, 13], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101012500_oauth_2.ts b/server/src/db/migrations/Migration20200101012500_oauth_2.ts new file mode 100644 index 000000000..f60c135b0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012500_oauth_2.ts @@ -0,0 +1,61 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 85 (`db/migrations.ts`). + * + * Migration: OAuth 2.1 clients, consents, and tokens for MCP + */ +export class Migration20200101012500_oauth_2 extends Migration { + override name = 'Migration20200101012500_oauth_2'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS oauth_clients ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + client_id TEXT UNIQUE NOT NULL, + client_secret_hash TEXT NOT NULL, + redirect_uris TEXT NOT NULL DEFAULT '[]', + allowed_scopes TEXT NOT NULL DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS oauth_consents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scopes TEXT NOT NULL DEFAULT '[]', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(client_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS oauth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + access_token_hash TEXT UNIQUE NOT NULL, + refresh_token_hash TEXT UNIQUE NOT NULL, + scopes TEXT NOT NULL DEFAULT '[]', + access_token_expires_at DATETIME NOT NULL, + refresh_token_expires_at DATETIME NOT NULL, + revoked_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id)`); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash)`); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash)`); + } +} diff --git a/server/src/db/migrations/Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay.ts b/server/src/db/migrations/Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay.ts new file mode 100644 index 000000000..788770710 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 86 (`db/migrations.ts`). + * + * Migration: Refresh-token rotation chain tracking for replay detection + */ +export class Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay extends Migration { + override name = 'Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'oauth_tokens', + 'parent_token_id', + `parent_token_id INTEGER REFERENCES oauth_tokens(id)`, + ); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101012700_public_client_support_for_browser_initiated_dynamic.ts b/server/src/db/migrations/Migration20200101012700_public_client_support_for_browser_initiated_dynamic.ts new file mode 100644 index 000000000..e43e84862 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012700_public_client_support_for_browser_initiated_dynamic.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 87 (`db/migrations.ts`). + * + * Migration: Public client support for browser-initiated dynamic registration (DCR) + */ +export class Migration20200101012700_public_client_support_for_browser_initiated_dynamic extends Migration { + override name = 'Migration20200101012700_public_client_support_for_browser_initiated_dynamic'; + + override async up(): Promise { + await addColumnIfMissing(this, 'oauth_clients', 'is_public', `is_public INTEGER NOT NULL DEFAULT 0`); + + await addColumnIfMissing(this, 'oauth_clients', 'created_via', `created_via TEXT NOT NULL DEFAULT 'settings_ui'`); + } +} diff --git a/server/src/db/migrations/Migration20200101012800_make_oauth_clients.ts b/server/src/db/migrations/Migration20200101012800_make_oauth_clients.ts new file mode 100644 index 000000000..9a0d777e0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012800_make_oauth_clients.ts @@ -0,0 +1,42 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 88 (`db/migrations.ts`). + * + * Make oauth_clients.user_id nullable to support anonymous RFC 7591 DCR clients. + * + * The legacy step ran outside a transaction so it could `PRAGMA foreign_keys = + * OFF` around the drop-and-rename — SQLite ignores that pragma inside an open + * transaction, and `oauth_tokens`/`oauth_consents` point at this table. Here the + * migration stays transactional and uses `defer_foreign_keys` instead, which is + * the in-transaction equivalent: enforcement is postponed to the commit, by + * which point the rename has made every reference valid again. + */ +export class Migration20200101012800_make_oauth_clients extends Migration { + override name = 'Migration20200101012800_make_oauth_clients'; + + override async up(): Promise { + await this.execute(`PRAGMA defer_foreign_keys = ON`); + await this.execute(` + CREATE TABLE IF NOT EXISTS oauth_clients_new ( + id TEXT PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + client_id TEXT UNIQUE NOT NULL, + client_secret_hash TEXT NOT NULL, + redirect_uris TEXT NOT NULL DEFAULT '[]', + allowed_scopes TEXT NOT NULL DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_public INTEGER NOT NULL DEFAULT 0, + created_via TEXT NOT NULL DEFAULT 'settings_ui' + ) + `); + await this.execute( + `INSERT INTO oauth_clients_new SELECT id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients`, + ); + await this.execute(`DROP TABLE oauth_clients`); + await this.execute(`ALTER TABLE oauth_clients_new RENAME TO oauth_clients`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`); + await this.execute(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101012900_add_otp_field_skip_ssl_column_device.ts b/server/src/db/migrations/Migration20200101012900_add_otp_field_skip_ssl_column_device.ts new file mode 100644 index 000000000..46ecd587e --- /dev/null +++ b/server/src/db/migrations/Migration20200101012900_add_otp_field_skip_ssl_column_device.ts @@ -0,0 +1,39 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 89 (`db/migrations.ts`). + * + * Add OTP field, skip_ssl column, device_id (did) column, and hint column for + * Synology Photos. + */ +export class Migration20200101012900_add_otp_field_skip_ssl_column_device extends Migration { + override name = 'Migration20200101012900_add_otp_field_skip_ssl_column_device'; + + override async up(): Promise { + await addColumnIfMissing(this, 'photo_provider_fields', 'hint', `hint TEXT`); + + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3) + `); + + await addColumnIfMissing(this, 'users', 'synology_skip_ssl', `synology_skip_ssl INTEGER NOT NULL DEFAULT 0`); + await addColumnIfMissing(this, 'users', 'synology_did', `synology_did TEXT`); + + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4) + `); + + await this.execute(` + UPDATE photo_provider_fields + SET hint = 'providerUrlHintSynology' + WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url' + `); + } +} diff --git a/server/src/db/migrations/Migration20200101013000_journey_addon.ts b/server/src/db/migrations/Migration20200101013000_journey_addon.ts new file mode 100644 index 000000000..83cce59c3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013000_journey_addon.ts @@ -0,0 +1,121 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 90 (`db/migrations.ts`). + * + * Migration 84: Journey addon — trip tracking & travel journal. Registered + * disabled: the addon is opt-in. + */ +export class Migration20200101013000_journey_addon extends Migration { + override name = 'Migration20200101013000_journey_addon'; + + override up(): void { + this.addSql(` + INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) + VALUES ('journey', 'Journey', 'Trip tracking & travel journal — check-ins, photos, daily stories', 'global', 'Compass', 0, '{}', 35) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journeys ( + id TEXT PRIMARY KEY, + trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + cover_image TEXT, + status TEXT NOT NULL DEFAULT 'draft', + started_at TEXT, + ended_at TEXT, + is_public INTEGER NOT NULL DEFAULT 0, + public_token TEXT UNIQUE, + settings TEXT DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_checkins ( + id TEXT PRIMARY KEY, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + name TEXT NOT NULL, + lat REAL, + lng REAL, + address TEXT, + country_code TEXT, + notes TEXT, + checked_in_at TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'manual', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_entries ( + id TEXT PRIMARY KEY, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL, + entry_date TEXT NOT NULL, + title TEXT, + body TEXT, + mood TEXT, + weather TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_photos ( + id TEXT PRIMARY KEY, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL, + entry_id TEXT REFERENCES journey_entries(id) ON DELETE SET NULL, + storage_type TEXT NOT NULL DEFAULT 'local', + asset_id TEXT, + file_path TEXT, + thumbnail_path TEXT, + original_name TEXT, + mime_type TEXT, + size_bytes INTEGER, + caption TEXT, + taken_at TEXT, + lat REAL, + lng REAL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_location_trail ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + lat REAL NOT NULL, + lng REAL NOT NULL, + altitude REAL, + accuracy REAL, + recorded_at TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'dawarich' + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journeys_user ON journeys(user_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journeys_trip ON journeys(trip_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journeys_public_token ON journeys(public_token)`); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_checkins_journey ON journey_checkins(journey_id, checked_in_at)`, + ); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_entries_journey_date ON journey_entries(journey_id, entry_date)`, + ); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_photos_checkin ON journey_photos(checkin_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)`); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_trail_journey_time ON journey_location_trail(journey_id, recorded_at)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101013100_journal.ts b/server/src/db/migrations/Migration20200101013100_journal.ts new file mode 100644 index 000000000..822827706 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013100_journal.ts @@ -0,0 +1,40 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 91 (`db/migrations.ts`). + * + * Migration 85: Journal — richer entry fields for magazine-style design + */ +export class Migration20200101013100_journal extends Migration { + override name = 'Migration20200101013100_journal'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'highlight_tags', `highlight_tags TEXT`); + + await addColumnIfMissing(this, 'journey_entries', 'visibility', `visibility TEXT NOT NULL DEFAULT 'private'`); + + await addColumnIfMissing(this, 'journey_entries', 'hero_photo_id', `hero_photo_id TEXT`); + + await addColumnIfMissing(this, 'journey_entries', 'color_accent', `color_accent TEXT`); + + await addColumnIfMissing(this, 'journey_entries', 'place_name', `place_name TEXT`); + + await addColumnIfMissing( + this, + 'journey_entries', + 'place_id', + `place_id INTEGER REFERENCES places(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'journey_entries', 'lat', `lat REAL`); + + await addColumnIfMissing(this, 'journey_entries', 'lng', `lng REAL`); + + await addColumnIfMissing(this, 'journey_checkins', 'photo_id', `photo_id TEXT`); + + await addColumnIfMissing(this, 'journey_photos', 'width', `width INTEGER`); + + await addColumnIfMissing(this, 'journey_photos', 'height', `height INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101013200_journey_multi_trip_support_sharing_collaboration.ts b/server/src/db/migrations/Migration20200101013200_journey_multi_trip_support_sharing_collaboration.ts new file mode 100644 index 000000000..55e730673 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013200_journey_multi_trip_support_sharing_collaboration.ts @@ -0,0 +1,53 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 92 (`db/migrations.ts`). + * + * Migration 86: Journey multi-trip support + sharing/collaboration + */ +export class Migration20200101013200_journey_multi_trip_support_sharing_collaboration extends Migration { + override name = 'Migration20200101013200_journey_multi_trip_support_sharing_collaboration'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_trips ( + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + PRIMARY KEY (journey_id, trip_id) + ) + `); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_trips_journey ON journey_trips(journey_id)`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'viewer', + invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE(journey_id, user_id) + ) + `); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)`); + + await addColumnIfMissing( + this, + 'journey_entries', + 'user_id', + `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing( + this, + 'journey_checkins', + 'user_id', + `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101013300_journey_rebuild.ts b/server/src/db/migrations/Migration20200101013300_journey_rebuild.ts new file mode 100644 index 000000000..573349c07 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013300_journey_rebuild.ts @@ -0,0 +1,226 @@ +import { bestEffort, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** A row from whichever shape of the old journey tables this database had. */ +type LegacyRow = Record; + +const NEW_TABLES = [ + `CREATE TABLE journeys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + subtitle TEXT, + cover_gradient TEXT, + status TEXT DEFAULT 'draft', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + )`, + `CREATE TABLE journey_trips ( + journey_id INTEGER NOT NULL, + trip_id INTEGER NOT NULL, + added_at INTEGER NOT NULL, + PRIMARY KEY (journey_id, trip_id), + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE + )`, + `CREATE TABLE journey_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL, + source_trip_id INTEGER, + source_place_id INTEGER, + author_id INTEGER NOT NULL, + type TEXT NOT NULL, + title TEXT, + story TEXT, + entry_date TEXT NOT NULL, + entry_time TEXT, + location_name TEXT, + location_lat REAL, + location_lng REAL, + mood TEXT, + weather TEXT, + tags TEXT, + visibility TEXT DEFAULT 'private', + sort_order INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (source_trip_id) REFERENCES trips(id) ON DELETE SET NULL, + FOREIGN KEY (source_place_id) REFERENCES places(id) ON DELETE SET NULL, + FOREIGN KEY (author_id) REFERENCES users(id) + )`, + `CREATE TABLE journey_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + thumbnail_path TEXT, + caption TEXT, + sort_order INTEGER DEFAULT 0, + width INTEGER, + height INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE + )`, + `CREATE TABLE journey_contributors ( + journey_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + added_at INTEGER NOT NULL, + PRIMARY KEY (journey_id, user_id), + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) + )`, +]; + +const NEW_INDEXES = [ + `CREATE INDEX idx_journeys_user ON journeys(user_id)`, + `CREATE INDEX idx_journey_entries_journey ON journey_entries(journey_id, entry_date)`, + `CREATE INDEX idx_journey_entries_source ON journey_entries(source_place_id)`, + `CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id)`, + `CREATE INDEX idx_journey_trips_journey ON journey_trips(journey_id)`, + `CREATE INDEX idx_journey_contributors_user ON journey_contributors(user_id)`, +]; + +/** + * Legacy migration step 93 (`db/migrations.ts`). + * + * Migration 87: Journey rebuild — new schema with trip sync. + * + * Step 90's journeys were TEXT-keyed and single-trip; this replaces them with + * integer keys, a journey↔trip join table and contributors. The old rows are + * read into memory, the tables dropped, and the rows re-inserted against the new + * shape — so the old TEXT ids have to be mapped to the new autoincrement ones. + */ +export class Migration20200101013300_journey_rebuild extends Migration { + override name = 'Migration20200101013300_journey_rebuild'; + + /** SQLite's `lastInsertRowid`, which `execute()` does not surface for raw SQL. */ + private async lastInsertId(): Promise { + const rows = (await this.execute(`SELECT last_insert_rowid() AS id`)) as { id: number }[]; + return Number(rows[0].id); + } + + override async up(): Promise { + let oldJourneys: LegacyRow[] = []; + let oldEntries: LegacyRow[] = []; + let oldPhotos: LegacyRow[] = []; + + if (await tableExists(this, 'journeys')) { + await bestEffort(async () => { + oldJourneys = (await this.execute(`SELECT * FROM journeys`)) as LegacyRow[]; + }); + await bestEffort(async () => { + oldEntries = (await this.execute(`SELECT * FROM journey_entries`)) as LegacyRow[]; + }); + await bestEffort(async () => { + oldPhotos = (await this.execute(`SELECT * FROM journey_photos`)) as LegacyRow[]; + }); + + for (const table of [ + 'journey_location_trail', + 'journey_photos', + 'journey_entries', + 'journey_checkins', + 'journey_members', + 'journey_trips', + 'journeys', + ]) { + await this.execute(`DROP TABLE IF EXISTS ${table}`); + } + } + + for (const ddl of NEW_TABLES) await this.execute(ddl); + for (const ddl of NEW_INDEXES) await this.execute(ddl); + + if (oldJourneys.length === 0) return; + + const now = Date.now(); + const toMillis = (value: string | number | null | undefined): number => (value ? new Date(value).getTime() : now); + + const journeyIdMap = new Map(); + for (const j of oldJourneys) { + await this.execute( + `INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + [ + j.user_id, + j.title || 'Untitled Journey', + j.description || null, + j.status || 'draft', + toMillis(j.created_at), + toMillis(j.updated_at), + ], + ); + const newJourneyId = await this.lastInsertId(); + journeyIdMap.set(j.id!, newJourneyId); + + await this.execute( + `INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'owner', ?)`, + [newJourneyId, j.user_id, now], + ); + + if (j.trip_id) { + await bestEffort(async () => { + await this.execute(`INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)`, [ + newJourneyId, + j.trip_id, + now, + ]); + }); + } + } + + const entryIdMap = new Map(); + for (const e of oldEntries) { + const newJourneyId = journeyIdMap.get(e.journey_id!); + if (!newJourneyId) continue; + + const authorId = e.user_id || oldJourneys.find((j) => j.id === e.journey_id)?.user_id || 1; + await this.execute( + `INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, visibility, sort_order, created_at, updated_at) + VALUES (?, ?, 'entry', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + newJourneyId, + authorId, + e.title || null, + e.body || null, + e.entry_date || new Date().toISOString().split('T')[0], + e.place_name || null, + e.lat || null, + e.lng || null, + e.mood || null, + e.weather || null, + e.visibility || 'private', + e.sort_order || 0, + toMillis(e.created_at), + toMillis(e.updated_at), + ], + ); + entryIdMap.set(e.id!, await this.lastInsertId()); + } + + for (const p of oldPhotos) { + const newEntryId = p.entry_id ? entryIdMap.get(p.entry_id) : null; + if (!newEntryId || !p.file_path) continue; + + await this.execute( + `INSERT INTO journey_photos (entry_id, file_path, thumbnail_path, caption, sort_order, width, height, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + newEntryId, + p.file_path, + p.thumbnail_path || null, + p.caption || null, + p.sort_order || 0, + p.width || null, + p.height || null, + toMillis(p.created_at), + ], + ); + } + + console.log( + `[DB] Journey migration: imported ${journeyIdMap.size} journeys, ${entryIdMap.size} entries, photos migrated`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101013400_journey_photos.ts b/server/src/db/migrations/Migration20200101013400_journey_photos.ts new file mode 100644 index 000000000..57eebafa7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013400_journey_photos.ts @@ -0,0 +1,54 @@ +import { bestEffort, columnNames, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 94 (`db/migrations.ts`). + * + * Migration 88: Journey photos — provider support (Immich/Synology). + * + * Every part of this step was swallowed on failure in the original, and it is + * kept that way: the rebuild below assumes the integer-keyed `journey_photos` + * shape, so on a database still carrying step 90's TEXT-keyed table the copy + * fails and the later journey rebuild (step 93) is what actually fixes it. + */ +export class Migration20200101013400_journey_photos extends Migration { + override name = 'Migration20200101013400_journey_photos'; + + override async up(): Promise { + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'`); + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN asset_id TEXT`); + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)`); + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1`); + + // file_path was NOT NULL — recreate the table to make it nullable. + const names = await columnNames(this, 'journey_photos'); + if (!names.has('provider')) return; + + await bestEffort(async () => { + await this.execute(` + CREATE TABLE journey_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + provider TEXT NOT NULL DEFAULT 'local', + asset_id TEXT, + owner_id INTEGER REFERENCES users(id), + file_path TEXT, + thumbnail_path TEXT, + caption TEXT, + sort_order INTEGER DEFAULT 0, + width INTEGER, + height INTEGER, + shared INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE + ) + `); + await this.execute( + `INSERT INTO journey_photos_new SELECT id, entry_id, provider, asset_id, owner_id, file_path, thumbnail_path, caption, sort_order, width, height, shared, created_at FROM journey_photos`, + ); + await this.execute(`DROP TABLE journey_photos`); + await this.execute(`ALTER TABLE journey_photos_new RENAME TO journey_photos`); + await this.execute(`CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id)`); + }); + } +} diff --git a/server/src/db/migrations/Migration20200101013500_journey_cover_image.ts b/server/src/db/migrations/Migration20200101013500_journey_cover_image.ts new file mode 100644 index 000000000..a389a57fc --- /dev/null +++ b/server/src/db/migrations/Migration20200101013500_journey_cover_image.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 95 (`db/migrations.ts`). + * + * Migration 89: Journey cover image + */ +export class Migration20200101013500_journey_cover_image extends Migration { + override name = 'Migration20200101013500_journey_cover_image'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journeys', 'cover_image', `cover_image TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101013600_pros_cons_for_journey_entries.ts b/server/src/db/migrations/Migration20200101013600_pros_cons_for_journey_entries.ts new file mode 100644 index 000000000..3e47376da --- /dev/null +++ b/server/src/db/migrations/Migration20200101013600_pros_cons_for_journey_entries.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 96 (`db/migrations.ts`). + * + * Migration 90: Pros/Cons for journey entries + */ +export class Migration20200101013600_pros_cons_for_journey_entries extends Migration { + override name = 'Migration20200101013600_pros_cons_for_journey_entries'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'pros_cons', `pros_cons TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101013700_journey_share_tokens.ts b/server/src/db/migrations/Migration20200101013700_journey_share_tokens.ts new file mode 100644 index 000000000..f17a08caf --- /dev/null +++ b/server/src/db/migrations/Migration20200101013700_journey_share_tokens.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 97 (`db/migrations.ts`). + * + * Migration 91: Journey share tokens + */ +export class Migration20200101013700_journey_share_tokens extends Migration { + override name = 'Migration20200101013700_journey_share_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_share_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL, + share_timeline INTEGER DEFAULT 1, + share_gallery INTEGER DEFAULT 1, + share_map INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) + ) + `); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101013800_vacay_week_start_setting.ts b/server/src/db/migrations/Migration20200101013800_vacay_week_start_setting.ts new file mode 100644 index 000000000..5a2f48ba4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013800_vacay_week_start_setting.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 98 (`db/migrations.ts`). + * + * Migration: Vacay week_start setting (0=Sunday, 1=Monday default) + */ +export class Migration20200101013800_vacay_week_start_setting extends Migration { + override name = 'Migration20200101013800_vacay_week_start_setting'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_plans', 'week_start', `week_start INTEGER NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101013900_unified_photo_provider_abstraction_layer.ts b/server/src/db/migrations/Migration20200101013900_unified_photo_provider_abstraction_layer.ts new file mode 100644 index 000000000..01aa9f374 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013900_unified_photo_provider_abstraction_layer.ts @@ -0,0 +1,136 @@ +import { columnNames, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const TRIP_PHOTOS_NEW = ` + CREATE TABLE trip_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + shared INTEGER NOT NULL DEFAULT 1, + album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, photo_id) + ) +`; + +/** + * Legacy migration step 99 (`db/migrations.ts`). + * + * Unified Photo Provider Abstraction Layer (#584). A central `trek_photos` + * registry; `trip_photos` and `journey_photos` stop carrying provider details + * and reference it by `photo_id` instead. + * + * `trip_photos` arrived in several shapes over the releases, so the copy reads + * whichever columns the database actually has. + */ +export class Migration20200101013900_unified_photo_provider_abstraction_layer extends Migration { + override name = 'Migration20200101013900_unified_photo_provider_abstraction_layer'; + + override async up(): Promise { + // 1. The central photo registry. + await this.execute(` + CREATE TABLE IF NOT EXISTS trek_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + asset_id TEXT, + owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + file_path TEXT, + thumbnail_path TEXT, + width INTEGER, + height INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL`, + ); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)`); + + // 2. trip_photos → trek_photos + photo_id FK. + if (await tableExists(this, 'trip_photos')) { + const names = await columnNames(this, 'trip_photos'); + const hasProvider = names.has('provider'); + const assetCol = names.has('asset_id') ? 'asset_id' : names.has('immich_asset_id') ? 'immich_asset_id' : null; + + if (assetCol) { + const providerExpr = hasProvider ? 'provider' : `'immich'`; + // Qualified alias needed in the JOIN, where both tables have `provider`. + const providerJoinExpr = hasProvider ? 'tp.provider' : `'immich'`; + const sharedExpr = names.has('shared') ? 'shared' : '1'; + const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + const albumLinkExpr = names.has('album_link_id') ? 'album_link_id' : 'NULL'; + + await this.execute(` + INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at) + SELECT DISTINCT ${providerExpr}, ${assetCol}, user_id, ${addedAtExpr} + FROM trip_photos + WHERE ${assetCol} IS NOT NULL AND TRIM(${assetCol}) != '' + `); + await this.execute(TRIP_PHOTOS_NEW); + await this.execute(` + INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at) + SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr} + FROM trip_photos tp + JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id + `); + } else { + // No asset column at all — recreate empty. + await this.execute(TRIP_PHOTOS_NEW); + } + + await this.execute(`DROP TABLE trip_photos`); + await this.execute(`ALTER TABLE trip_photos_new RENAME TO trip_photos`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)`); + } + + // 3. journey_photos → trek_photos + photo_id FK. + if (await tableExists(this, 'journey_photos')) { + await this.execute(` + INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at) + SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at + FROM journey_photos + WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != '' + `); + // Local photos are unique per row, so no de-duplication. + await this.execute(` + INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at) + SELECT 'local', file_path, thumbnail_path, width, height, created_at + FROM journey_photos + WHERE provider = 'local' AND file_path IS NOT NULL + `); + + await this.execute(` + CREATE TABLE journey_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + caption TEXT, + sort_order INTEGER DEFAULT 0, + shared INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE + ) + `); + await this.execute(` + INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) + SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at + FROM journey_photos jp + JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id + WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL + `); + await this.execute(` + INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) + SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at + FROM journey_photos jp + JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path + WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL + `); + await this.execute(`DROP TABLE journey_photos`); + await this.execute(`ALTER TABLE journey_photos_new RENAME TO journey_photos`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)`); + } + } +} diff --git a/server/src/db/migrations/Migration20200101014000_hide_skeletons_per_user_setting_on_journey.ts b/server/src/db/migrations/Migration20200101014000_hide_skeletons_per_user_setting_on_journey.ts new file mode 100644 index 000000000..17295bddb --- /dev/null +++ b/server/src/db/migrations/Migration20200101014000_hide_skeletons_per_user_setting_on_journey.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 100 (`db/migrations.ts`). + * + * Migration 99: hide_skeletons per-user setting on journey_contributors + */ +export class Migration20200101014000_hide_skeletons_per_user_setting_on_journey extends Migration { + override name = 'Migration20200101014000_hide_skeletons_per_user_setting_on_journey'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'journey_contributors', + 'hide_skeletons', + `hide_skeletons INTEGER NOT NULL DEFAULT 0`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101014100_idempotency_keys_for_offline_mutation_replay.ts b/server/src/db/migrations/Migration20200101014100_idempotency_keys_for_offline_mutation_replay.ts new file mode 100644 index 000000000..9a9d5d149 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014100_idempotency_keys_for_offline_mutation_replay.ts @@ -0,0 +1,27 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 101 (`db/migrations.ts`). + * + * Migration 100: Idempotency keys for offline mutation replay + */ +export class Migration20200101014100_idempotency_keys_for_offline_mutation_replay extends Migration { + override name = 'Migration20200101014100_idempotency_keys_for_offline_mutation_replay'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS idempotency_keys ( + key TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (key, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101014200_enable_naver_list_import_by_default.ts b/server/src/db/migrations/Migration20200101014200_enable_naver_list_import_by_default.ts new file mode 100644 index 000000000..16623322a --- /dev/null +++ b/server/src/db/migrations/Migration20200101014200_enable_naver_list_import_by_default.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 102 (`db/migrations.ts`). + * + * Migration 101: Enable naver_list_import by default. + */ +export class Migration20200101014200_enable_naver_list_import_by_default extends Migration { + override name = 'Migration20200101014200_enable_naver_list_import_by_default'; + + override up(): void { + this.addSql(`UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'`); + } +} diff --git a/server/src/db/migrations/Migration20200101014300_add_check_in_end_column_for_check.ts b/server/src/db/migrations/Migration20200101014300_add_check_in_end_column_for_check.ts new file mode 100644 index 000000000..394f7cb0f --- /dev/null +++ b/server/src/db/migrations/Migration20200101014300_add_check_in_end_column_for_check.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 103 (`db/migrations.ts`). + * + * Migration 102: Add check_in_end column for check-in time ranges + */ +export class Migration20200101014300_add_check_in_end_column_for_check extends Migration { + override name = 'Migration20200101014300_add_check_in_end_column_for_check'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_accommodations', 'check_in_end', `check_in_end TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101014400_system_notices.ts b/server/src/db/migrations/Migration20200101014400_system_notices.ts new file mode 100644 index 000000000..d32be4e77 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014400_system_notices.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 104 (`db/migrations.ts`). + * + * Migration 103: System notices — user tracking columns + dismissals table + */ +export class Migration20200101014400_system_notices extends Migration { + override name = 'Migration20200101014400_system_notices'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'first_seen_version', `first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`); + + await addColumnIfMissing(this, 'users', 'login_count', `login_count INTEGER NOT NULL DEFAULT 0`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS user_notice_dismissals ( + user_id INTEGER NOT NULL, + notice_id TEXT NOT NULL, + dismissed_at INTEGER NOT NULL, + PRIMARY KEY (user_id, notice_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014500_passphrase_support_for_synology_shared_album_links.ts b/server/src/db/migrations/Migration20200101014500_passphrase_support_for_synology_shared_album_links.ts new file mode 100644 index 000000000..709326910 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014500_passphrase_support_for_synology_shared_album_links.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 105 (`db/migrations.ts`). + * + * Migration 104: Passphrase support for Synology shared-album links (#689) + */ +export class Migration20200101014500_passphrase_support_for_synology_shared_album_links extends Migration { + override name = 'Migration20200101014500_passphrase_support_for_synology_shared_album_links'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trip_album_links', 'passphrase', `passphrase TEXT DEFAULT NULL`); + + await addColumnIfMissing(this, 'trek_photos', 'passphrase', `passphrase TEXT DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101014600_persistent_google_place_photo_disk_cache_registry.ts b/server/src/db/migrations/Migration20200101014600_persistent_google_place_photo_disk_cache_registry.ts new file mode 100644 index 000000000..66893d672 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014600_persistent_google_place_photo_disk_cache_registry.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 106 (`db/migrations.ts`). + * + * Migration 105: Persistent Google place photo disk cache registry + */ +export class Migration20200101014600_persistent_google_place_photo_disk_cache_registry extends Migration { + override name = 'Migration20200101014600_persistent_google_place_photo_disk_cache_registry'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS google_place_photo_meta ( + place_id TEXT PRIMARY KEY, + attribution TEXT, + fetched_at INTEGER NOT NULL, + error_at INTEGER + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014700_persistent_place_details_row_cache.ts b/server/src/db/migrations/Migration20200101014700_persistent_place_details_row_cache.ts new file mode 100644 index 000000000..dfddb1d65 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014700_persistent_place_details_row_cache.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 107 (`db/migrations.ts`). + * + * Migration 106: Persistent Place Details row cache + */ +export class Migration20200101014700_persistent_place_details_row_cache extends Migration { + override name = 'Migration20200101014700_persistent_place_details_row_cache'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_details_cache ( + place_id TEXT NOT NULL, + lang TEXT NOT NULL DEFAULT '', + expanded INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + PRIMARY KEY (place_id, lang, expanded) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014800_backfill_expired_signed_google_photo_urls_to.ts b/server/src/db/migrations/Migration20200101014800_backfill_expired_signed_google_photo_urls_to.ts new file mode 100644 index 000000000..53254a409 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014800_backfill_expired_signed_google_photo_urls_to.ts @@ -0,0 +1,28 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 108 (`db/migrations.ts`). + * + * Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs. + * + * The legacy step was declared `raw` (run outside a transaction); it is a single + * UPDATE with no pragma requirement, so it runs transactionally here. + */ +export class Migration20200101014800_backfill_expired_signed_google_photo_urls_to extends Migration { + override name = 'Migration20200101014800_backfill_expired_signed_google_photo_urls_to'; + + override up(): void { + this.addSql(` + UPDATE places + SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes', + updated_at = CURRENT_TIMESTAMP + WHERE google_place_id IS NOT NULL + AND image_url IS NOT NULL + AND image_url != '' + AND ( + (image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%') + OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%') + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014900_disk_cache_metadata_for_remote_provider_photo.ts b/server/src/db/migrations/Migration20200101014900_disk_cache_metadata_for_remote_provider_photo.ts new file mode 100644 index 000000000..2bc5e7bf1 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014900_disk_cache_metadata_for_remote_provider_photo.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 109 (`db/migrations.ts`). + * + * Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology) + */ +export class Migration20200101014900_disk_cache_metadata_for_remote_provider_photo extends Migration { + override name = 'Migration20200101014900_disk_cache_metadata_for_remote_provider_photo'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS trek_photo_cache_meta ( + cache_key TEXT PRIMARY KEY, + content_type TEXT NOT NULL DEFAULT 'image/jpeg', + fetched_at INTEGER NOT NULL + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101015000_reservation_endpoints.ts b/server/src/db/migrations/Migration20200101015000_reservation_endpoints.ts new file mode 100644 index 000000000..7c7a63985 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015000_reservation_endpoints.ts @@ -0,0 +1,36 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 110 (`db/migrations.ts`). + * + * Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587 + */ +export class Migration20200101015000_reservation_endpoints extends Migration { + override name = 'Migration20200101015000_reservation_endpoints'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS reservation_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + role TEXT NOT NULL, + sequence INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL, + code TEXT, + lat REAL NOT NULL, + lng REAL NOT NULL, + timezone TEXT, + local_time TEXT, + local_date TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_reservation_endpoints_reservation_id ON reservation_endpoints(reservation_id)`, + ); + + await addColumnIfMissing(this, 'reservations', 'needs_review', `needs_review INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101015100_reservations_add_end_day_id.ts b/server/src/db/migrations/Migration20200101015100_reservations_add_end_day_id.ts new file mode 100644 index 000000000..55a2ddd64 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015100_reservations_add_end_day_id.ts @@ -0,0 +1,47 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 111 (`db/migrations.ts`). + * + * Migration 110 — link transport reservations to days via day_id / end_day_id + */ +export class Migration20200101015100_reservations_add_end_day_id extends Migration { + override name = 'Migration20200101015100_reservations_add_end_day_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'reservations', + 'end_day_id', + `end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL`, + ); + + await this.execute(` + UPDATE reservations + SET day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_time, 1, 10) + LIMIT 1 + ) + WHERE type IN ('flight','train','car','cruise','bus') + AND reservation_time IS NOT NULL + AND day_id IS NULL + `); + + await this.execute(` + UPDATE reservations + SET end_day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + LIMIT 1 + ) + WHERE type IN ('flight','train','car','cruise','bus') + AND reservation_end_time IS NOT NULL + AND end_day_id IS NULL + AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101015200_opt_in_immich_auto_upload.ts b/server/src/db/migrations/Migration20200101015200_opt_in_immich_auto_upload.ts new file mode 100644 index 000000000..025c93539 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015200_opt_in_immich_auto_upload.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 112 (`db/migrations.ts`). + * + * Migration 111: opt-in Immich auto-upload — users column only (#730) + * Default is off — uploading to Immich must be an explicit choice, not a + * side effect of having a writable API key. + */ +export class Migration20200101015200_opt_in_immich_auto_upload extends Migration { + override name = 'Migration20200101015200_opt_in_immich_auto_upload'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'immich_auto_upload', `immich_auto_upload INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101015300_expose_immich_auto_upload_toggle_in_the.ts b/server/src/db/migrations/Migration20200101015300_expose_immich_auto_upload_toggle_in_the.ts new file mode 100644 index 000000000..612eb0e37 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015300_expose_immich_auto_upload_toggle_in_the.ts @@ -0,0 +1,27 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 113 (`db/migrations.ts`). + * + * Migration 112: expose the immich auto-upload toggle in the Settings UI (#730). + * Runs after Immich provider seeding so the FK to photo_providers holds. + */ +export class Migration20200101015300_expose_immich_auto_upload_toggle_in_the extends Migration { + override name = 'Migration20200101015300_expose_immich_auto_upload_toggle_in_the'; + + override async up(): Promise { + if (!(await tableExists(this, 'photo_providers'))) return; + if (!(await tableExists(this, 'photo_provider_fields'))) return; + + const immich = await this.execute(`SELECT 1 AS present FROM photo_providers WHERE id = 'immich' LIMIT 1`); + if (immich.length === 0) return; + + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, 0, 0, 'auto_upload', 'auto_upload', 5) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101015400_rfc_8707_resource_indicators.ts b/server/src/db/migrations/Migration20200101015400_rfc_8707_resource_indicators.ts new file mode 100644 index 000000000..3ef141c09 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015400_rfc_8707_resource_indicators.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 114 (`db/migrations.ts`). + * + * Migration: RFC 8707 resource indicators — audience-bind OAuth tokens to /mcp + */ +export class Migration20200101015400_rfc_8707_resource_indicators extends Migration { + override name = 'Migration20200101015400_rfc_8707_resource_indicators'; + + override async up(): Promise { + await addColumnIfMissing(this, 'oauth_tokens', 'audience', `audience TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101015500_password_reset.ts b/server/src/db/migrations/Migration20200101015500_password_reset.ts new file mode 100644 index 000000000..8f60a53ef --- /dev/null +++ b/server/src/db/migrations/Migration20200101015500_password_reset.ts @@ -0,0 +1,33 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 115 (`db/migrations.ts`). + * + * Migration: password reset — add password_version for session + * invalidation, and a token table keyed by SHA-256 hash (raw tokens + * never hit the DB). + */ +export class Migration20200101015500_password_reset extends Migration { + override name = 'Migration20200101015500_password_reset'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'password_version', `password_version INTEGER NOT NULL DEFAULT 0`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + consumed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_ip TEXT + ) + `); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id)`); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash)`); + } +} diff --git a/server/src/db/migrations/Migration20200101015600_todo_due_date_reminders.ts b/server/src/db/migrations/Migration20200101015600_todo_due_date_reminders.ts new file mode 100644 index 000000000..c4b48771d --- /dev/null +++ b/server/src/db/migrations/Migration20200101015600_todo_due_date_reminders.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 116 (`db/migrations.ts`). + * + * Migration: todo due-date reminders — track when we last sent a + * reminder for each todo so we don't spam the same notification + * every day the scheduler runs. + */ +export class Migration20200101015600_todo_due_date_reminders extends Migration { + override name = 'Migration20200101015600_todo_due_date_reminders'; + + override async up(): Promise { + await addColumnIfMissing(this, 'todo_items', 'reminded_at', `reminded_at DATETIME`); + } +} diff --git a/server/src/db/migrations/Migration20200101015700_security_audit_batch_1.ts b/server/src/db/migrations/Migration20200101015700_security_audit_batch_1.ts new file mode 100644 index 000000000..48a30cc7c --- /dev/null +++ b/server/src/db/migrations/Migration20200101015700_security_audit_batch_1.ts @@ -0,0 +1,56 @@ +import { addColumnIfMissing, bestEffort, columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 117 (`db/migrations.ts`). + * + * Security audit batch 1 — columns + indexes required by several fixes bundled + * into one PR. + * + * - `share_tokens.expires_at`: public share links now get a 90-day TTL by + * default; existing rows stay NULL (= no expiry) so already-published links + * are not silently broken. + * - Missing indexes on high-cardinality query paths (PERF-H1 in the audit): + * every `listTrips()` used to full-scan trips on user_id, and + * notifications/photos/reservations had similar gaps. + */ +export class Migration20200101015700_security_audit_batch_1 extends Migration { + override name = 'Migration20200101015700_security_audit_batch_1'; + + override async up(): Promise { + await addColumnIfMissing(this, 'share_tokens', 'expires_at', `expires_at TEXT`); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trips_created_at ON trips(created_at DESC)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_photos_day_id ON photos(day_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_photos_place_id ON photos(place_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_reservations_day_id ON reservations(day_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_share_tokens_token ON share_tokens(token)`); + + // day_accommodations may have either start_day_id/end_day_id or a single + // day_id depending on how far the schema has evolved; build whichever index + // makes sense for the live columns. Non-fatal: the table may not exist on + // very old installs. + await bestEffort(async () => { + const names = await columnNames(this, 'day_accommodations'); + if (names.has('start_day_id')) { + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)`, + ); + } + if (names.has('end_day_id')) { + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)`, + ); + } + }); + + // The notifications schema has varied; probe before indexing. + await bestEffort(async () => { + const names = await columnNames(this, 'notifications'); + if (names.has('target') && names.has('scope')) { + await this.execute(`CREATE INDEX IF NOT EXISTS idx_notifications_target_scope ON notifications(target, scope)`); + } + }); + } +} diff --git a/server/src/db/migrations/Migration20200101015800_widen_idempotency_keys_primary_key_to.ts b/server/src/db/migrations/Migration20200101015800_widen_idempotency_keys_primary_key_to.ts new file mode 100644 index 000000000..bc549649e --- /dev/null +++ b/server/src/db/migrations/Migration20200101015800_widen_idempotency_keys_primary_key_to.ts @@ -0,0 +1,42 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 118 (`db/migrations.ts`). + * + * Widen the idempotency_keys primary key to (key, user_id, method, path). The + * middleware lookup was widened in the same audit batch so a reused + * `X-Idempotency-Key` against a different endpoint does not replay the cached + * body of an unrelated request. The old PK was only (key, user_id), so the + * `INSERT OR IGNORE` on the second endpoint silently skipped — the cache never + * stored request B's response and replays re-executed the handler. Rebuilding + * with the widened PK preserves existing rows: the old PK guarantees there are + * no conflicts under the new, strictly looser unique key. + */ +export class Migration20200101015800_widen_idempotency_keys_primary_key_to extends Migration { + override name = 'Migration20200101015800_widen_idempotency_keys_primary_key_to'; + + override async up(): Promise { + if (!(await tableExists(this, 'idempotency_keys'))) return; + + await this.execute(` + CREATE TABLE idempotency_keys_new ( + key TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (key, user_id, method, path) + ) + `); + await this.execute(` + INSERT INTO idempotency_keys_new (key, user_id, method, path, status_code, response_body, created_at) + SELECT key, user_id, method, path, status_code, response_body, created_at FROM idempotency_keys + `); + await this.execute(`DROP TABLE idempotency_keys`); + await this.execute(`ALTER TABLE idempotency_keys_new RENAME TO idempotency_keys`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued.ts b/server/src/db/migrations/Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued.ts new file mode 100644 index 000000000..a4c686280 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued.ts @@ -0,0 +1,20 @@ +import { columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 119 (`db/migrations.ts`). + * + * SEC-H6: revoke all OAuth tokens issued before audience binding was enforced. + * `mcp/index.ts` now unconditionally checks audience; tokens with + * `audience = NULL` would be permanently rejected by that check, so removing + * them here avoids leaving dead rows and makes the intent clear. + */ +export class Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued extends Migration { + override name = 'Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued'; + + override async up(): Promise { + const names = await columnNames(this, 'oauth_tokens'); + if (!names.has('audience')) return; + await this.execute(`DELETE FROM oauth_tokens WHERE audience IS NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101020000_remove_not_null_constraint_on_day_accommodations.ts b/server/src/db/migrations/Migration20200101020000_remove_not_null_constraint_on_day_accommodations.ts new file mode 100644 index 000000000..8a5d5b596 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020000_remove_not_null_constraint_on_day_accommodations.ts @@ -0,0 +1,48 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 120 (`db/migrations.ts`). + * + * Remove NOT NULL constraint on day_accommodations.place_id so hotel + * reservations created from the Bookings tab without a linked place can + * still persist their date range. Change ON DELETE CASCADE → SET NULL so + * deleting a place orphans the accommodation row instead of cascading. + */ +export class Migration20200101020000_remove_not_null_constraint_on_day_accommodations extends Migration { + override name = 'Migration20200101020000_remove_not_null_constraint_on_day_accommodations'; + + override up(): void { + this.addSql(` + CREATE TABLE day_accommodations_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + check_in TEXT, + check_in_end TEXT, + check_out TEXT, + confirmation TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + INSERT INTO day_accommodations_new + SELECT id, trip_id, place_id, start_day_id, end_day_id, + check_in, check_in_end, check_out, confirmation, notes, created_at + FROM day_accommodations + `); + + this.addSql(`DROP TABLE day_accommodations`); + + this.addSql(`ALTER TABLE day_accommodations_new RENAME TO day_accommodations`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101020100_null_out_proxy_image_url_entries_that.ts b/server/src/db/migrations/Migration20200101020100_null_out_proxy_image_url_entries_that.ts new file mode 100644 index 000000000..add7bd2cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101020100_null_out_proxy_image_url_entries_that.ts @@ -0,0 +1,30 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 121 (`db/migrations.ts`). + * + * Migration: null out proxy image_url entries that have no backing disk cache. + * Migrations 107 and the migration below wrote /api/maps/place-photo//bytes + * into places.image_url without actually fetching/caching the photo bytes. The + * photoService short-circuits on that prefix and hits /bytes directly → 404. + * Rows with a confirmed disk cache entry in google_place_photo_meta are left alone; + * only stale proxy URLs (never actually fetched) are cleared so the normal + * fetch-and-cache flow can repopulate them. + */ +export class Migration20200101020100_null_out_proxy_image_url_entries_that extends Migration { + override name = 'Migration20200101020100_null_out_proxy_image_url_entries_that'; + + override up(): void { + this.addSql(` + UPDATE places + SET image_url = NULL, updated_at = CURRENT_TIMESTAMP + WHERE image_url LIKE '/api/maps/place-photo/%/bytes' + AND google_place_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM google_place_photo_meta + WHERE place_id = places.google_place_id + AND error_at IS NULL + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020200_clear_legacy_google_photo_urls_missed_by.ts b/server/src/db/migrations/Migration20200101020200_clear_legacy_google_photo_urls_missed_by.ts new file mode 100644 index 000000000..b9a8863bd --- /dev/null +++ b/server/src/db/migrations/Migration20200101020200_clear_legacy_google_photo_urls_missed_by.ts @@ -0,0 +1,30 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 122 (`db/migrations.ts`). + * + * Migration: clear legacy Google photo URLs missed by Migration 107. + * Migration 107 matched /places/%/photos/% only; lh3.googleusercontent.com URLs use + * /place-photos/ or /places/ paths and were skipped. NULL those stale URLs + * so the normal fetch-and-cache flow repopulates image_url with a real proxy URL. + */ +export class Migration20200101020200_clear_legacy_google_photo_urls_missed_by extends Migration { + override name = 'Migration20200101020200_clear_legacy_google_photo_urls_missed_by'; + + override up(): void { + this.addSql(` + UPDATE places + SET image_url = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE image_url IS NOT NULL + AND image_url != '' + AND image_url NOT LIKE '/api/maps/place-photo/%' + AND ( + image_url LIKE 'http://%googleusercontent.com/%' + OR image_url LIKE 'https://%googleusercontent.com/%' + OR image_url LIKE 'http://%places.googleapis.com/%' + OR image_url LIKE 'https://%places.googleapis.com/%' + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020300_journey_gallery_refactor.ts b/server/src/db/migrations/Migration20200101020300_journey_gallery_refactor.ts new file mode 100644 index 000000000..cae9d29ea --- /dev/null +++ b/server/src/db/migrations/Migration20200101020300_journey_gallery_refactor.ts @@ -0,0 +1,104 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 123 (`db/migrations.ts`). + * + * Migration 121: Journey gallery refactor — decouple photo ownership from + * entries. `journey_photos` becomes a per-journey gallery (one row per unique + * photo per journey). A new junction table `journey_entry_photos` links gallery + * photos to the entries that reference them, so the same photo can appear in + * several entries without being duplicated. Synthetic wrapper entries + * ('Gallery', '[Trip Photos]') created by the old model are removed — the + * gallery table replaces them. + */ +export class Migration20200101020300_journey_gallery_refactor extends Migration { + override name = 'Migration20200101020300_journey_gallery_refactor'; + + override async up(): Promise { + const hasOld = await tableExists(this, 'journey_photos'); + const hasBackup = await tableExists(this, 'journey_photos_old'); + if (hasOld && !hasBackup) { + await this.execute(`ALTER TABLE journey_photos RENAME TO journey_photos_old`); + } + + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + caption TEXT, + shared INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + provider TEXT, + asset_id TEXT, + owner_id INTEGER, + created_at INTEGER NOT NULL, + UNIQUE(journey_id, photo_id) + ) + `); + + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_entry_photos ( + entry_id INTEGER NOT NULL REFERENCES journey_entries(id) ON DELETE CASCADE, + journey_photo_id INTEGER NOT NULL REFERENCES journey_photos(id) ON DELETE CASCADE, + sort_order INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + PRIMARY KEY(entry_id, journey_photo_id) + ) + `); + + if (hasOld || hasBackup) { + // Backfill the gallery: deduplicate by (journey_id, photo_id), keeping the + // earliest row (MIN(id) = earliest created_at on AUTOINCREMENT). + await this.execute(` + INSERT OR IGNORE INTO journey_photos + (journey_id, photo_id, caption, shared, sort_order, created_at) + SELECT + je.journey_id, + jpo.photo_id, + jpo.caption, + jpo.shared, + jpo.sort_order, + jpo.created_at + FROM journey_photos_old jpo + JOIN journey_entries je ON je.id = jpo.entry_id + WHERE jpo.id IN ( + SELECT MIN(jpo2.id) + FROM journey_photos_old jpo2 + JOIN journey_entries je2 ON je2.id = jpo2.entry_id + GROUP BY je2.journey_id, jpo2.photo_id + ) + `); + + // Backfill the junction: one row per (entry_id, photo_id), resolved to the + // new gallery ids. + await this.execute(` + INSERT OR IGNORE INTO journey_entry_photos + (entry_id, journey_photo_id, sort_order, created_at) + SELECT + jpo.entry_id, + jp.id, + jpo.sort_order, + jpo.created_at + FROM journey_photos_old jpo + JOIN journey_entries je ON je.id = jpo.entry_id + JOIN journey_photos jp + ON jp.journey_id = je.journey_id + AND jp.photo_id = jpo.photo_id + `); + + await this.execute(`DROP TABLE journey_photos_old`); + } + + // Remove the synthetic wrapper entries the gallery model replaces. The + // ON DELETE CASCADE on journey_entry_photos cleans up the junction rows. + await this.execute(`DELETE FROM journey_entries WHERE title IN ('Gallery', '[Trip Photos]')`); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)`); + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101020400_correct_stale_day_id_end_day_id.ts b/server/src/db/migrations/Migration20200101020400_correct_stale_day_id_end_day_id.ts new file mode 100644 index 000000000..765af9be5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020400_correct_stale_day_id_end_day_id.ts @@ -0,0 +1,74 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 124 (`db/migrations.ts`). + * + * Migration 122: Correct stale day_id / end_day_id on non-transport + * reservations. Migration 110 only backfilled transport types; tours, + * restaurants, events and "other" bookings kept a stale day_id from + * older code paths that often defaulted to the first day of the trip. + * Starting with v3.0.0 the planner renders reservations by day_id + * instead of reservation_time, so those stale rows show up on the + * wrong day. This migration nulls out day_id / end_day_id values that + * don't match the reservation's time and then backfills them from + * reservation_time / reservation_end_time. + */ +export class Migration20200101020400_correct_stale_day_id_end_day_id extends Migration { + override name = 'Migration20200101020400_correct_stale_day_id_end_day_id'; + + override up(): void { + this.addSql(` + UPDATE reservations + SET day_id = NULL + WHERE reservation_time IS NOT NULL + AND day_id IS NOT NULL + AND type != 'hotel' + AND NOT EXISTS ( + SELECT 1 FROM days d + WHERE d.id = reservations.day_id + AND d.date = substr(reservations.reservation_time, 1, 10) + ) + `); + + this.addSql(` + UPDATE reservations + SET end_day_id = NULL + WHERE reservation_end_time IS NOT NULL + AND end_day_id IS NOT NULL + AND type != 'hotel' + AND NOT EXISTS ( + SELECT 1 FROM days d + WHERE d.id = reservations.end_day_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + ) + `); + + this.addSql(` + UPDATE reservations + SET day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_time, 1, 10) + LIMIT 1 + ) + WHERE type != 'hotel' + AND reservation_time IS NOT NULL + AND day_id IS NULL + `); + + this.addSql(` + UPDATE reservations + SET end_day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + LIMIT 1 + ) + WHERE type != 'hotel' + AND reservation_end_time IS NOT NULL + AND end_day_id IS NULL + AND substr(reservations.reservation_end_time, 1, 10) + != substr(reservations.reservation_time, 1, 10) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020500_846_make_sort_order_authoritative_within_a.ts b/server/src/db/migrations/Migration20200101020500_846_make_sort_order_authoritative_within_a.ts new file mode 100644 index 000000000..d6f2cebc5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020500_846_make_sort_order_authoritative_within_a.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 125 (`db/migrations.ts`). + * + * #846: make sort_order authoritative within a day. The previous ORDER BY put + * entry_time before sort_order, silently ignoring reorder clicks when two + * same-date entries had different times. The backfill renumbers using the old + * effective key (entry_time ASC, id ASC) so existing journeys keep their + * current visual order. + */ +export class Migration20200101020500_846_make_sort_order_authoritative_within_a extends Migration { + override name = 'Migration20200101020500_846_make_sort_order_authoritative_within_a'; + + override up(): void { + this.addSql(` + WITH ranked AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY journey_id, entry_date + ORDER BY entry_time ASC, id ASC + ) - 1 AS rn + FROM journey_entries + ) + UPDATE journey_entries + SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id) + `); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_entries_order ON journey_entries(journey_id, entry_date, sort_order)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101020600_swap_inverted_start_day_id_end_day.ts b/server/src/db/migrations/Migration20200101020600_swap_inverted_start_day_id_end_day.ts new file mode 100644 index 000000000..2c52ac6fd --- /dev/null +++ b/server/src/db/migrations/Migration20200101020600_swap_inverted_start_day_id_end_day.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 126 (`db/migrations.ts`). + * + * Swap inverted start_day_id/end_day_id pairs in day_accommodations caused + * by the old Math.min/Math.max picker bug (pre-8e05ba7) which used raw IDs + * instead of positional order on trips with non-monotonic day ID layouts. + */ +export class Migration20200101020600_swap_inverted_start_day_id_end_day extends Migration { + override name = 'Migration20200101020600_swap_inverted_start_day_id_end_day'; + + override up(): void { + this.addSql(` + UPDATE day_accommodations + SET start_day_id = end_day_id, end_day_id = start_day_id + WHERE (SELECT day_number FROM days WHERE id = start_day_id) + > (SELECT day_number FROM days WHERE id = end_day_id) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020700_prepare_migration_to_nest_typeorm.ts b/server/src/db/migrations/Migration20200101020700_prepare_migration_to_nest_typeorm.ts new file mode 100644 index 000000000..e69030b32 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020700_prepare_migration_to_nest_typeorm.ts @@ -0,0 +1,21 @@ +import { readEnv } from '../../app-config'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 127 (`db/migrations.ts`). + * + * Prepare migration to nest + typeorm. + */ +export class Migration20200101020700_prepare_migration_to_nest_typeorm extends Migration { + override name = 'Migration20200101020700_prepare_migration_to_nest_typeorm'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL)`, + ); + await this.execute(`INSERT INTO migrations (timestamp, name) VALUES (1777810195344, 'InitialSchema1777810195344')`); + await this.execute(`INSERT INTO app_settings (key, value) VALUES ('app_version', ?)`, [ + readEnv().app.appVersion || '3.0.14', + ]); + } +} diff --git a/server/src/db/migrations/Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames.ts b/server/src/db/migrations/Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames.ts new file mode 100644 index 000000000..401ec39d4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames.ts @@ -0,0 +1,91 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** A user row with whitespace around its username or email. */ +interface DirtyRow { + id: number; + username?: string; + email?: string; +} + +/** + * Legacy migration step 128 (`db/migrations.ts`). + * + * Trim leading/trailing whitespace from stored usernames and emails. + * + * A trimmed value can collide with an existing account, and the unique indexes + * would reject the UPDATE. Rather than fail the boot, the colliding row is + * suffixed with `__migrated_` and a flag is written to `app_settings` so the + * admin UI can surface that a manual review is needed. A renamed email means + * that user cannot sign in with it until an operator fixes it — which is the + * behaviour the original chose, deliberately, over refusing to start. + */ +export class Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames extends Migration { + override name = 'Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames'; + + override async up(): Promise { + let hadCollision = false; + + const dirtyUsernames = (await this.execute( + `SELECT id, username FROM users WHERE username != TRIM(username)`, + )) as DirtyRow[]; + + for (const row of dirtyUsernames) { + const trimmed = row.username!.trim(); + const collisions = (await this.execute(`SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?`, [ + trimmed, + row.id, + ])) as { id: number }[]; + const collision = collisions[0]; + + const final = collision ? `${trimmed}__migrated_${row.id}` : trimmed; + if (collision) { + hadCollision = true; + console.warn( + `[migration] WHITESPACE COLLISION username: user id=${row.id} ` + + `original=${JSON.stringify(row.username)} trimmed="${trimmed}" ` + + `collides with user id=${collision.id}. Renamed to "${final}". ` + + `Manual review required.`, + ); + } else { + console.warn( + `[migration] Trimmed username for user id=${row.id}: ` + `${JSON.stringify(row.username)} → "${final}"`, + ); + } + await this.execute(`UPDATE users SET username = ? WHERE id = ?`, [final, row.id]); + } + + const dirtyEmails = (await this.execute(`SELECT id, email FROM users WHERE email != TRIM(email)`)) as DirtyRow[]; + + for (const row of dirtyEmails) { + const trimmed = row.email!.trim(); + const collisions = (await this.execute(`SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?`, [ + trimmed, + row.id, + ])) as { id: number }[]; + const collision = collisions[0]; + + let final = trimmed; + if (collision) { + hadCollision = true; + const at = trimmed.lastIndexOf('@'); + final = + at > 0 ? `${trimmed.slice(0, at)}__migrated_${row.id}${trimmed.slice(at)}` : `${trimmed}__migrated_${row.id}`; + console.warn( + `[migration] WHITESPACE COLLISION email: user id=${row.id} ` + + `original=${JSON.stringify(row.email)} trimmed="${trimmed}" ` + + `collides with user id=${collision.id}. Renamed to "${final}". ` + + `User cannot sign in with this email until manually corrected.`, + ); + } else { + console.warn(`[migration] Trimmed email for user id=${row.id}: ` + `${JSON.stringify(row.email)} → "${final}"`); + } + await this.execute(`UPDATE users SET email = ? WHERE id = ?`, [final, row.id]); + } + + if (hadCollision) { + await this.execute( + `INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')`, + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101020900_create_schema_version_new.ts b/server/src/db/migrations/Migration20200101020900_create_schema_version_new.ts new file mode 100644 index 000000000..eb23cdff9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020900_create_schema_version_new.ts @@ -0,0 +1,24 @@ +import { readEnv } from '../../app-config'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 129 (`db/migrations.ts`). + * + * Give `schema_version` a primary key. The original table was a single bare + * `version` column. + */ +export class Migration20200101020900_create_schema_version_new extends Migration { + override name = 'Migration20200101020900_create_schema_version_new'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS schema_version_new (id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER NOT NULL)`, + ); + await this.execute(`INSERT INTO schema_version_new (version) SELECT version FROM schema_version`); + await this.execute(`DROP TABLE schema_version`); + await this.execute(`ALTER TABLE schema_version_new RENAME TO schema_version`); + await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'app_version'`, [ + readEnv().app.appVersion || '3.0.15', + ]); + } +} diff --git a/server/src/db/migrations/Migration20200101021000_oauth_2.ts b/server/src/db/migrations/Migration20200101021000_oauth_2.ts new file mode 100644 index 000000000..a208b1ce5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021000_oauth_2.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 130 (`db/migrations.ts`). + * + * Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential + * clients to skip the browser consent flow entirely and obtain tokens directly + * via client_id + client_secret. Flag is immutable after creation so existing + * authorization-code clients are not silently upgraded. + */ +export class Migration20200101021000_oauth_2 extends Migration { + override name = 'Migration20200101021000_oauth_2'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'oauth_clients', + 'allows_client_credentials', + `allows_client_credentials INTEGER NOT NULL DEFAULT 0`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021100_drop_stale_atlas_cache_rows_for_territories.ts b/server/src/db/migrations/Migration20200101021100_drop_stale_atlas_cache_rows_for_territories.ts new file mode 100644 index 000000000..7689816ba --- /dev/null +++ b/server/src/db/migrations/Migration20200101021100_drop_stale_atlas_cache_rows_for_territories.ts @@ -0,0 +1,38 @@ +import { execUnlessTableMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** [minLng, minLat, maxLng, maxLat] per territory. */ +const ENCLAVE_BOXES: [number, number, number, number][] = [ + [113.83, 22.15, 114.43, 22.56], // HK + [113.53, 22.1, 113.6, 22.21], // MO + [12.4, 43.89, 12.52, 43.99], // SM + [12.44, 41.9, 12.46, 41.91], // VA + [7.4, 43.72, 7.44, 43.75], // MC + [9.47, 47.05, 9.64, 47.27], // LI + [-5.36, 36.11, -5.33, 36.16], // GI + [-67.3, 17.88, -65.22, 18.53], // PR +]; + +/** + * Legacy migration step 131 (`db/migrations.ts`). + * + * Drop stale atlas cache rows for territories that used to resolve to their + * surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy, + * etc.) before their own bounding boxes existed. The next atlas stats request + * re-resolves any place inside these boxes with the corrected country code. + */ +export class Migration20200101021100_drop_stale_atlas_cache_rows_for_territories extends Migration { + override name = 'Migration20200101021100_drop_stale_atlas_cache_rows_for_territories'; + + override async up(): Promise { + for (const [minLng, minLat, maxLng, maxLat] of ENCLAVE_BOXES) { + await execUnlessTableMissing( + this, + `DELETE FROM place_regions WHERE place_id IN ( + SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ? + )`, + [minLat, maxLat, minLng, maxLng], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101021200_costs_rework.ts b/server/src/db/migrations/Migration20200101021200_costs_rework.ts new file mode 100644 index 000000000..b602da336 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021200_costs_rework.ts @@ -0,0 +1,65 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 132 (`db/migrations.ts`). + * + * Costs rework (budget → "Costs", Tricount/Splitwise style). Adds, additively + * and without touching existing rows: + * - per-expense currency + exchange_rate, so an expense can be entered in a + * foreign currency and converted to the trip base currency (NULL currency = + * base currency; rate 1.0). Closes the multi-currency request (#551). + * - budget_item_payers: several people can each have paid part of one expense + * (amounts in the expense currency), replacing the single paid_by_user_id. + * - budget_settlements: persisted "X paid Y" transfers so the settle-up history + * (with undo) is shared across all trip members. + * + * The equal-split participants stay in budget_item_members. The single legacy + * payer is backfilled into budget_item_payers as one payer covering the total. + */ +export class Migration20200101021200_costs_rework extends Migration { + override name = 'Migration20200101021200_costs_rework'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'currency', `currency TEXT`); + await addColumnIfMissing(this, 'budget_items', 'exchange_rate', `exchange_rate REAL NOT NULL DEFAULT 1`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS budget_item_payers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount REAL NOT NULL DEFAULT 0, + UNIQUE(budget_item_id, user_id) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_budget_item_payers_item ON budget_item_payers(budget_item_id)`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS budget_settlements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount REAL NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by_user_id INTEGER REFERENCES users(id) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_budget_settlements_trip ON budget_settlements(trip_id)`); + + // Backfill the legacy single payer: that person paid the full total of the + // expense, in the (base) currency the existing amount was already stored in. + // `paid_by_user_id` does not exist on every database — skip where it is absent. + try { + await this.execute(` + INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) + SELECT id, paid_by_user_id, total_price + FROM budget_items + WHERE paid_by_user_id IS NOT NULL + `); + } catch (err: unknown) { + if (!(err instanceof Error) || !err.message.includes('no such column')) throw err; + } + } +} diff --git a/server/src/db/migrations/Migration20200101021300_rename_the_budget_planner_addon_to_costs.ts b/server/src/db/migrations/Migration20200101021300_rename_the_budget_planner_addon_to_costs.ts new file mode 100644 index 000000000..74bba8b6e --- /dev/null +++ b/server/src/db/migrations/Migration20200101021300_rename_the_budget_planner_addon_to_costs.ts @@ -0,0 +1,18 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 133 (`db/migrations.ts`). + * + * Rename the "Budget Planner" addon to "Costs" in the admin add-on list. This is + * a display rename only — the addon id, tables, permissions and MCP tools all + * stay 'budget'. Scoped to the default name so a customised one is kept. + */ +export class Migration20200101021300_rename_the_budget_planner_addon_to_costs extends Migration { + override name = 'Migration20200101021300_rename_the_budget_planner_addon_to_costs'; + + override up(): void { + this.addSql( + `UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021400_webauthn_passkey_support_per_user_credentials_single.ts b/server/src/db/migrations/Migration20200101021400_webauthn_passkey_support_per_user_credentials_single.ts new file mode 100644 index 000000000..69cc73d35 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021400_webauthn_passkey_support_per_user_credentials_single.ts @@ -0,0 +1,46 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 134 (`db/migrations.ts`). + * + * WebAuthn / passkey support: per-user credentials + single-use login + * challenges. Additive (CREATE TABLE IF NOT EXISTS) so existing installs are + * untouched; both tables also live in schema.ts for fresh installs. + */ +export class Migration20200101021400_webauthn_passkey_support_per_user_credentials_single extends Migration { + override name = 'Migration20200101021400_webauthn_passkey_support_per_user_credentials_single'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + name TEXT, + aaguid TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries.ts b/server/src/db/migrations/Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries.ts new file mode 100644 index 000000000..94292840f --- /dev/null +++ b/server/src/db/migrations/Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries.ts @@ -0,0 +1,131 @@ +import { Migration } from '@mikro-orm/migrations'; + +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; + +/** A manually-marked sub-national region carrying a possibly-stale code. */ +interface VisitedRegion { + id: number; + region_code: string; + region_name: string; + country_code: string; +} + +interface Admin1Feature { + properties?: { iso_a2?: string; iso_3166_2?: string; name?: string }; +} + +/** + * Curated crosswalk for regions absorbed into a *renamed* successor (the + * name-match step cannot catch these, because the name changed). Norway's + * 2018/2020 reforms; extend as the pinned geoBoundaries dataset gains further + * reforms. + */ +const MERGE_CROSSWALK: Record = { + 'NO-04': 'NO-34', + 'NO-05': 'NO-34', // Hedmark, Oppland → Innlandet + 'NO-12': 'NO-46', + 'NO-14': 'NO-46', // Hordaland, Sogn og Fjordane → Vestland + 'NO-09': 'NO-42', + 'NO-10': 'NO-42', // Aust-/Vest-Agder → Agder + 'NO-01': 'NO-30', + 'NO-02': 'NO-30', + 'NO-06': 'NO-30', // Østfold/Akershus/Buskerud → Viken + 'NO-07': 'NO-38', + 'NO-08': 'NO-38', // Vestfold, Telemark → Vestfold og Telemark + 'NO-19': 'NO-54', + 'NO-20': 'NO-54', // Troms, Finnmark → Troms og Finnmark + 'NO-16': 'NO-50', + 'NO-17': 'NO-50', // Sør-/Nord-Trøndelag → Trøndelag +}; + +/** + * Legacy migration step 135 (`db/migrations.ts`). + * + * Atlas dropped Natural Earth for geoBoundaries. Manually-marked sub-national + * regions (`visited_regions`) stored the OLD Natural Earth ISO-3166-2 codes; + * some no longer match any polygon in the new bundle and would stop + * highlighting. Reconcile every row against the ACTUAL shipped admin-1 bundle so + * this covers *all* countries, not just one hand-listed reform: + * 1. code still present in the new bundle → leave it (already correct); + * 2. else a region in the same country shares → adopt that region's code+name + * the stored region_name (case-insensitive) (handles code re-spellings, + * e.g. ES-AN → ES_AND); + * 3. else a curated merge crosswalk maps it → adopt the merged region; + * 4. else → leave as-is (cannot be resolved; the client's name fallback may + * still highlight it, and nothing is destroyed). + * + * Other Atlas tables need NO remap: `visited_countries` / `bucket_list` hold + * only ISO-3166-1 alpha-2 codes (invariant across the swap), `bucket_list.name` + * is free text we must not auto-rewrite, and `place_regions` is a re-derivable + * Nominatim cache. + */ +export class Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries extends Migration { + override name = 'Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries'; + + override async up(): Promise { + const rows = (await this.execute( + `SELECT id, region_code, region_name, country_code FROM visited_regions`, + )) as VisitedRegion[]; + if (rows.length === 0) return; // nothing marked → skip the bundle read entirely + + // Index the shipped admin-1 bundle: valid codes, name→code per country, + // code→name. `__dirname` resolves ../../../assets under both dist + // (dist/db/migrations) and ts-node/vitest (src/db/migrations). + let features: Admin1Feature[]; + try { + const file = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz'); + features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || []; + } catch { + features = []; // bundle missing → degrade to the curated crosswalk below + } + + const validCodes = new Set(); + const nameToCode = new Map(); // `${A2}|${nameLower}` → code + const codeToName = new Map(); + for (const f of features) { + const a2 = (f.properties?.iso_a2 || '').toUpperCase(); + const code = f.properties?.iso_3166_2 || ''; + const name = f.properties?.name || ''; + if (!code) continue; + validCodes.add(code); + if (!codeToName.has(code)) codeToName.set(code, name); + if (a2 && name) nameToCode.set(`${a2}|${name.toLowerCase()}`, code); + } + + const resolve = (row: VisitedRegion): string | null => { + if (validCodes.has(row.region_code)) return null; // already valid + const a2 = (row.country_code || '').toUpperCase(); + const byName = nameToCode.get(`${a2}|${(row.region_name || '').toLowerCase()}`); + if (byName) return byName; + const merged = MERGE_CROSSWALK[row.region_code]; + // Only trust the crosswalk target if it actually exists in the bundle (or + // the bundle was unreadable, in which case apply the curated map blindly). + if (merged && (validCodes.size === 0 || validCodes.has(merged))) return merged; + return null; + }; + + for (const row of rows) { + const newCode = resolve(row); + if (!newCode || newCode === row.region_code) continue; + const newName = codeToName.get(newCode) || row.region_name; + + await this.execute(`UPDATE OR IGNORE visited_regions SET region_code = ?, region_name = ? WHERE id = ?`, [ + newCode, + newName, + row.id, + ]); + + // UNIQUE(user_id, region_code): if the user already had the target code the + // UPDATE was IGNORED and this row still carries the old code → drop the + // duplicate. + const after = (await this.execute(`SELECT region_code FROM visited_regions WHERE id = ?`, [row.id])) as { + region_code: string; + }[]; + if (after[0]?.region_code === row.region_code) { + await this.execute(`DELETE FROM visited_regions WHERE id = ?`, [row.id]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101021600_step_136.ts b/server/src/db/migrations/Migration20200101021600_step_136.ts new file mode 100644 index 000000000..3302dcead --- /dev/null +++ b/server/src/db/migrations/Migration20200101021600_step_136.ts @@ -0,0 +1,21 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 136 (`db/migrations.ts`). + * + * AirTrail integration addon — disabled by default (opt-in). The per-user + * connection lives in Settings → Integrations; this row is only the admin-level + * global toggle. + */ +export class Migration20200101021600_step_136 extends Migration { + override name = 'Migration20200101021600_step_136'; + + override async up(): Promise { + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['airtrail', 'AirTrail', 'Sync flights from your self-hosted AirTrail instance', 'integration', 'Plane', 0, 14], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021700_users_add_airtrail_url.ts b/server/src/db/migrations/Migration20200101021700_users_add_airtrail_url.ts new file mode 100644 index 000000000..469d3a34d --- /dev/null +++ b/server/src/db/migrations/Migration20200101021700_users_add_airtrail_url.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 137 (`db/migrations.ts`). + */ +export class Migration20200101021700_users_add_airtrail_url extends Migration { + override name = 'Migration20200101021700_users_add_airtrail_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'airtrail_url', `airtrail_url TEXT`); + + await addColumnIfMissing(this, 'users', 'airtrail_api_key', `airtrail_api_key TEXT`); + + await addColumnIfMissing( + this, + 'users', + 'airtrail_allow_insecure_tls', + `airtrail_allow_insecure_tls INTEGER DEFAULT 0`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021800_reservations_add_external_source.ts b/server/src/db/migrations/Migration20200101021800_reservations_add_external_source.ts new file mode 100644 index 000000000..4b9c6b354 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021800_reservations_add_external_source.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 138 (`db/migrations.ts`). + */ +export class Migration20200101021800_reservations_add_external_source extends Migration { + override name = 'Migration20200101021800_reservations_add_external_source'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'external_source', `external_source TEXT`); + + await addColumnIfMissing(this, 'reservations', 'external_id', `external_id TEXT`); + + await addColumnIfMissing(this, 'reservations', 'external_owner_user_id', `external_owner_user_id INTEGER`); + + await addColumnIfMissing(this, 'reservations', 'external_synced_at', `external_synced_at TEXT`); + + await addColumnIfMissing(this, 'reservations', 'sync_enabled', `sync_enabled INTEGER DEFAULT 1`); + + await addColumnIfMissing(this, 'reservations', 'external_hash', `external_hash TEXT`); + + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021900_users_add_airtrail_write_enabled.ts b/server/src/db/migrations/Migration20200101021900_users_add_airtrail_write_enabled.ts new file mode 100644 index 000000000..71dc7b906 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021900_users_add_airtrail_write_enabled.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 139 (`db/migrations.ts`). + */ +export class Migration20200101021900_users_add_airtrail_write_enabled extends Migration { + override name = 'Migration20200101021900_users_add_airtrail_write_enabled'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'airtrail_write_enabled', `airtrail_write_enabled INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101022000_store_google_maps_feature_ids_separately_from.ts b/server/src/db/migrations/Migration20200101022000_store_google_maps_feature_ids_separately_from.ts new file mode 100644 index 000000000..81d316e58 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022000_store_google_maps_feature_ids_separately_from.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 140 (`db/migrations.ts`). + * + * Store Google Maps feature IDs separately from real Google Places API IDs. + */ +export class Migration20200101022000_store_google_maps_feature_ids_separately_from extends Migration { + override name = 'Migration20200101022000_store_google_maps_feature_ids_separately_from'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'google_ftid', `google_ftid TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101022100_remember_the_app_version_a_notice_was.ts b/server/src/db/migrations/Migration20200101022100_remember_the_app_version_a_notice_was.ts new file mode 100644 index 000000000..015d12ec6 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022100_remember_the_app_version_a_notice_was.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 141 (`db/migrations.ts`). + * + * Remember the app version a notice was dismissed at, so per-version recurring + * notices (e.g. the thank-you) re-appear on the next install/upgrade. + */ +export class Migration20200101022100_remember_the_app_version_a_notice_was extends Migration { + override name = 'Migration20200101022100_remember_the_app_version_a_notice_was'; + + override async up(): Promise { + await addColumnIfMissing(this, 'user_notice_dismissals', 'dismissed_app_version', `dismissed_app_version TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101022200_budget_item_members_add_amount.ts b/server/src/db/migrations/Migration20200101022200_budget_item_members_add_amount.ts new file mode 100644 index 000000000..bb48a476d --- /dev/null +++ b/server/src/db/migrations/Migration20200101022200_budget_item_members_add_amount.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 142 (`db/migrations.ts`). + */ +export class Migration20200101022200_budget_item_members_add_amount extends Migration { + override name = 'Migration20200101022200_budget_item_members_add_amount'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_item_members', 'amount', `amount REAL`); + } +} diff --git a/server/src/db/migrations/Migration20200101022300_calendar_feed_tokens.ts b/server/src/db/migrations/Migration20200101022300_calendar_feed_tokens.ts new file mode 100644 index 000000000..9526d07ad --- /dev/null +++ b/server/src/db/migrations/Migration20200101022300_calendar_feed_tokens.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 143 (`db/migrations.ts`). + * + * Calendar feed tokens — subscribable ICS links for per-trip and all-trips feeds + */ +export class Migration20200101022300_calendar_feed_tokens extends Migration { + override name = 'Migration20200101022300_calendar_feed_tokens'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'feed_token', `feed_token TEXT`); + + await addColumnIfMissing(this, 'users', 'feed_token', `feed_token TEXT`); + + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_trips_feed_token ON trips(feed_token) WHERE feed_token IS NOT NULL`, + ); + + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_feed_token ON users(feed_token) WHERE feed_token IS NOT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection.ts b/server/src/db/migrations/Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection.ts new file mode 100644 index 000000000..61c28d653 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 144 (`db/migrations.ts`). + * + * Optimistic-concurrency token for offline conflict detection (#1135). + * packing_items had only created_at, so an offline edit could not be checked + * against a concurrent server change. SQLite forbids a non-constant DEFAULT on + * ALTER ADD COLUMN, so add it nullable and backfill from created_at; new rows + * set it explicitly (packingService). Additive: a request without the + * X-Base-Updated-At header keeps the old last-write-wins behaviour. + */ +export class Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection extends Migration { + override name = 'Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'updated_at', `updated_at DATETIME`); + + await this.execute( + `UPDATE packing_items SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101022500_video_support.ts b/server/src/db/migrations/Migration20200101022500_video_support.ts new file mode 100644 index 000000000..23ee89f59 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022500_video_support.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 145 (`db/migrations.ts`). + * + * Video support (#823): the trek_photos registry held only images. media_type + * discriminates image vs video so the gallery, lightbox and provider proxy can + * branch; duration_ms is optional metadata for the player. Additive — existing + * rows default to 'image'. + */ +export class Migration20200101022500_video_support extends Migration { + override name = 'Migration20200101022500_video_support'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trek_photos', 'media_type', `media_type TEXT NOT NULL DEFAULT 'image'`); + await addColumnIfMissing(this, 'trek_photos', 'duration_ms', `duration_ms INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101022600_dedicated_booking_url.ts b/server/src/db/migrations/Migration20200101022600_dedicated_booking_url.ts new file mode 100644 index 000000000..90fb684a3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022600_dedicated_booking_url.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 146 (`db/migrations.ts`). + * + * Dedicated booking URL (#935) — users previously stuffed links into notes. + * Additive nullable TEXT; existing rows default to NULL. + */ +export class Migration20200101022600_dedicated_booking_url extends Migration { + override name = 'Migration20200101022600_dedicated_booking_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'url', `url TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101022700_private_packing_items.ts b/server/src/db/migrations/Migration20200101022700_private_packing_items.ts new file mode 100644 index 000000000..e7d879fe9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022700_private_packing_items.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 147 (`db/migrations.ts`). + * + * Private packing items (#858): an item can be hidden from other trip members. + * `is_private` toggles the visibility; `owner_id` records who it belongs to so + * the listing can show it only to them. `owner_id` is NULL on legacy rows + * (shared). + */ +export class Migration20200101022700_private_packing_items extends Migration { + override name = 'Migration20200101022700_private_packing_items'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'is_private', `is_private INTEGER NOT NULL DEFAULT 0`); + await addColumnIfMissing( + this, + 'packing_items', + 'owner_id', + `owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101022800_guest_members.ts b/server/src/db/migrations/Migration20200101022800_guest_members.ts new file mode 100644 index 000000000..d491e5f14 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022800_guest_members.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 148 (`db/migrations.ts`). + * + * Guest members (#1362): people added to a trip without an account. A guest is a + * users row flagged is_guest=1 (no usable credentials) joined into trip_members, + * so it's assignable everywhere a member is — but must never authenticate or show + * up in the global user directory. The flag is the discriminator for those guards. + */ +export class Migration20200101022800_guest_members extends Migration { + override name = 'Migration20200101022800_guest_members'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'is_guest', `is_guest INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101022900_three_tier_packing_sharing.ts b/server/src/db/migrations/Migration20200101022900_three_tier_packing_sharing.ts new file mode 100644 index 000000000..1a5aa389f --- /dev/null +++ b/server/src/db/migrations/Migration20200101022900_three_tier_packing_sharing.ts @@ -0,0 +1,36 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 149 (`db/migrations.ts`). + * + * Three-tier packing sharing (#858 follow-up): an item is Common (is_private=0, + * every existing item — non-breaking), Personal (is_private=1, owner only) or + * Shared-with-people (is_private=1 + recipient rows). owner_id is the "bringer". + * Contributors are extra people who said "I can bring that too" on a Common item + * (status 'pending' until the owner accepts). + */ +export class Migration20200101022900_three_tier_packing_sharing extends Migration { + override name = 'Migration20200101022900_three_tier_packing_sharing'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_item_recipients ( + item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (item_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_packing_item_recipients_user ON packing_item_recipients(user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_item_contributors ( + item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'accepted', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (item_id, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101023000_collections_addon.ts b/server/src/db/migrations/Migration20200101023000_collections_addon.ts new file mode 100644 index 000000000..7f7e89a10 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023000_collections_addon.ts @@ -0,0 +1,89 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 150 (`db/migrations.ts`). + * + * Collections addon — personal place library (#1081). Multi-list + per-place + * status (idea/want/visited) + fusion sharing. + */ +export class Migration20200101023000_collections_addon extends Migration { + override name = 'Migration20200101023000_collections_addon'; + + override up(): void { + this.addSql(` + INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) + VALUES ('collections', 'Collections', 'Personal place library — save places across trips into named lists, copy into any trip, share with others', 'global', 'Bookmark', 0, '{}', 16) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT 'Bookmark', + cover_image TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + osm_id TEXT, + website TEXT, + phone TEXT, + status TEXT NOT NULL DEFAULT 'idea', + source_trip_id INTEGER, + source_place_id INTEGER, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_tags ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, tag_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id)`); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id)`, + ); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101023100_user_added_links_on_collections_saved_places.ts b/server/src/db/migrations/Migration20200101023100_user_added_links_on_collections_saved_places.ts new file mode 100644 index 000000000..57e68b255 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023100_user_added_links_on_collections_saved_places.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 151 (`db/migrations.ts`). + * + * Migration 151: user-added links on collections + saved places (JSON text) + */ +export class Migration20200101023100_user_added_links_on_collections_saved_places extends Migration { + override name = 'Migration20200101023100_user_added_links_on_collections_saved_places'; + + override async up(): Promise { + await addColumnIfMissing(this, 'collections', 'links', `links TEXT`); + + await addColumnIfMissing(this, 'collection_places', 'links', `links TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101023200_per_member_permission_role_on_a_shared.ts b/server/src/db/migrations/Migration20200101023200_per_member_permission_role_on_a_shared.ts new file mode 100644 index 000000000..113fa420a --- /dev/null +++ b/server/src/db/migrations/Migration20200101023200_per_member_permission_role_on_a_shared.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 152 (`db/migrations.ts`). + * + * Migration 152: per-member permission role on a shared list. Existing + * accepted members default to 'editor' so nothing regresses. + */ +export class Migration20200101023200_per_member_permission_role_on_a_shared extends Migration { + override name = 'Migration20200101023200_per_member_permission_role_on_a_shared'; + + override async up(): Promise { + await addColumnIfMissing(this, 'collection_members', 'role', `role TEXT NOT NULL DEFAULT 'editor'`); + } +} diff --git a/server/src/db/migrations/Migration20200101023300_per_trip_invite_links.ts b/server/src/db/migrations/Migration20200101023300_per_trip_invite_links.ts new file mode 100644 index 000000000..b046845a2 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023300_per_trip_invite_links.ts @@ -0,0 +1,28 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 153 (`db/migrations.ts`). + * + * Migration 153: per-trip invite links (#1143). One rotating token per trip; + * a logged-in existing user who opens the link joins the trip as a member. + * Deleting the trip drops the token (CASCADE); the creator is nulled if their + * account is removed so the link keeps working. + */ +export class Migration20200101023300_per_trip_invite_links extends Migration { + override name = 'Migration20200101023300_per_trip_invite_links'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_invite_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + expires_at TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_invite_tokens_token ON trip_invite_tokens(token)`); + } +} diff --git a/server/src/db/migrations/Migration20200101023400_optional_trip_binding_on_an_admin_invite.ts b/server/src/db/migrations/Migration20200101023400_optional_trip_binding_on_an_admin_invite.ts new file mode 100644 index 000000000..5bbd83bcb --- /dev/null +++ b/server/src/db/migrations/Migration20200101023400_optional_trip_binding_on_an_admin_invite.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 154 (`db/migrations.ts`). + * + * Migration 154: optional trip binding on an admin invite link (#1402). A user + * who REGISTERS via the link is auto-added to the trip. Nullable for backward + * compatibility; ON DELETE SET NULL so removing the trip just unbinds the invite. + */ +export class Migration20200101023400_optional_trip_binding_on_an_admin_invite extends Migration { + override name = 'Migration20200101023400_optional_trip_binding_on_an_admin_invite'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'invite_tokens', + 'trip_id', + `trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101023500_plugin_system_scaffold.ts b/server/src/db/migrations/Migration20200101023500_plugin_system_scaffold.ts new file mode 100644 index 000000000..4e4e0ccbb --- /dev/null +++ b/server/src/db/migrations/Migration20200101023500_plugin_system_scaffold.ts @@ -0,0 +1,83 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 155 (`db/migrations.ts`). + * + * Migration 155: plugin system scaffold (#plugins). A plugin is a row here; + * its code lives on the /plugins volume and (once the runtime lands) runs in + * an isolated child process. This migration only lays down the registry + * tables — nothing executes yet. Own data lives in a per-plugin sqlite file + * under /plugins-data, never in these tables. + */ +export class Migration20200101023500_plugin_system_scaffold extends Migration { + override name = 'Migration20200101023500_plugin_system_scaffold'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'integration', + icon TEXT DEFAULT 'Blocks', + version TEXT, + api_version INTEGER DEFAULT 1, + min_trek_version TEXT, + permissions TEXT DEFAULT '[]', + granted_permissions TEXT DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'inactive', + config TEXT DEFAULT '{}', + source_repo TEXT, + source_commit TEXT, + sha256 TEXT, + crash_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + reviewed_at TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + installed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_meta_migrations ( + plugin_id TEXT NOT NULL, + migration_id TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (plugin_id, migration_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_error_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + ts DATETIME DEFAULT CURRENT_TIMESTAMP, + level TEXT NOT NULL DEFAULT 'error', + message TEXT, + stack TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_plugin_error_log_plugin ON plugin_error_log(plugin_id, ts)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_settings_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + field_key TEXT NOT NULL, + label TEXT, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + hint TEXT, + required INTEGER NOT NULL DEFAULT 0, + secret INTEGER NOT NULL DEFAULT 0, + scope TEXT NOT NULL DEFAULT 'instance', + options TEXT, + oauth_config TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (plugin_id, field_key) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101023600_separate_the_admin_s_on_off_intent.ts b/server/src/db/migrations/Migration20200101023600_separate_the_admin_s_on_off_intent.ts new file mode 100644 index 000000000..02656b020 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023600_separate_the_admin_s_on_off_intent.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 156 (`db/migrations.ts`). + * + * Migration 156: separate the admin's ON/OFF intent (`enabled`) from the + * runtime health (`status`). A crash used to flip status to 'error', which + * erased the "keep it on" intent, so the plugin never rebooted after a deploy. + * Boot now retries every enabled plugin regardless of last status. + */ +export class Migration20200101023600_separate_the_admin_s_on_off_intent extends Migration { + override name = 'Migration20200101023600_separate_the_admin_s_on_off_intent'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'enabled', `enabled INTEGER NOT NULL DEFAULT 0`); + + await execBestEffort(this, `UPDATE plugins SET enabled = 1 WHERE status != 'inactive'`); + } +} diff --git a/server/src/db/migrations/Migration20200101023700_plugin_capabilities.ts b/server/src/db/migrations/Migration20200101023700_plugin_capabilities.ts new file mode 100644 index 000000000..14f67399d --- /dev/null +++ b/server/src/db/migrations/Migration20200101023700_plugin_capabilities.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 157 (`db/migrations.ts`). + * + * Migration 157: plugin capabilities (from trek-plugin.json) — the client + * needs them to place widgets (e.g. widget.slot 'hero' renders as an overlay + * on the boarding-pass bar instead of the dashboard sidebar). + */ +export class Migration20200101023700_plugin_capabilities extends Migration { + override name = 'Migration20200101023700_plugin_capabilities'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'capabilities', `capabilities TEXT NOT NULL DEFAULT '{}'`); + } +} diff --git a/server/src/db/migrations/Migration20200101023800_tofu_pin_for_a_plugin_s_author.ts b/server/src/db/migrations/Migration20200101023800_tofu_pin_for_a_plugin_s_author.ts new file mode 100644 index 000000000..7d2619a8c --- /dev/null +++ b/server/src/db/migrations/Migration20200101023800_tofu_pin_for_a_plugin_s_author.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 158 (`db/migrations.ts`). + * + * Migration 158: TOFU pin for a plugin's author signing key (#plugins, #4). + * Set on first install of a signed plugin; a later install whose registry key + * differs is a hard stop (author change / key rotation / attack) unless an + * admin re-trusts. NULL for unsigned plugins (signing is opt-in). + */ +export class Migration20200101023800_tofu_pin_for_a_plugin_s_author extends Migration { + override name = 'Migration20200101023800_tofu_pin_for_a_plugin_s_author'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'author_pubkey', `author_pubkey TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101023900_hash_chained_capability_audit_log.ts b/server/src/db/migrations/Migration20200101023900_hash_chained_capability_audit_log.ts new file mode 100644 index 000000000..0323cad41 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023900_hash_chained_capability_audit_log.ts @@ -0,0 +1,39 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 159 (`db/migrations.ts`). + * + * Migration 159: hash-chained capability audit log (#plugins, L1 hardening). + * Every host-mediated capability call the plugin makes is recorded at the RPC + * boundary (where the plugin provably can't reach) with the host-bound acting + * user and a per-plugin hash chain, so wide data grants stay attributable + + * tamper-evident + user-visible. + */ +export class Migration20200101023900_hash_chained_capability_audit_log extends Migration { + override name = 'Migration20200101023900_hash_chained_capability_audit_log'; + + override async up(): Promise { + await execBestEffort( + this, + ` + CREATE TABLE IF NOT EXISTS plugin_capability_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + acting_user_id INTEGER, + method TEXT NOT NULL, + resource TEXT, + code TEXT NOT NULL, + ts TEXT NOT NULL DEFAULT (datetime('now')), + prev_hash TEXT, + hash TEXT NOT NULL + ) + `, + ); + + await execBestEffort( + this, + `CREATE INDEX IF NOT EXISTS idx_plugin_audit_plugin ON plugin_capability_audit (plugin_id, id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101024000_per_collection_custom_labels.ts b/server/src/db/migrations/Migration20200101024000_per_collection_custom_labels.ts new file mode 100644 index 000000000..ca8c9ecfe --- /dev/null +++ b/server/src/db/migrations/Migration20200101024000_per_collection_custom_labels.ts @@ -0,0 +1,41 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 160 (`db/migrations.ts`). + * + * Migration 160: per-collection custom labels (#collections). Each list owns + * its own label set (unlike the instance-wide `tags` table), and a place can + * carry several labels. Used for grouping + filtering places within a list. + */ +export class Migration20200101024000_per_collection_custom_labels extends Migration { + override name = 'Migration20200101024000_per_collection_custom_labels'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#6366f1', + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_labels ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, label_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101024100_plugin_owned_metadata_on_core_entities.ts b/server/src/db/migrations/Migration20200101024100_plugin_owned_metadata_on_core_entities.ts new file mode 100644 index 000000000..d881a20fe --- /dev/null +++ b/server/src/db/migrations/Migration20200101024100_plugin_owned_metadata_on_core_entities.ts @@ -0,0 +1,34 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 161 (`db/migrations.ts`). + * + * Migration 161: plugin-owned metadata on core entities (#1429). A namespaced + * key/value store so a plugin can attach data to a trip/place/day WITHOUT + * forking the core schema. One row per (plugin, entity, key); a plugin only + * ever sees its own rows. entity_type is polymorphic (no cross-table FK), so + * rows are purged by plugin_id on uninstall; entity deletes leave harmless + * orphans that the plugin's own reads never surface. + */ +export class Migration20200101024100_plugin_owned_metadata_on_core_entities extends Migration { + override name = 'Migration20200101024100_plugin_owned_metadata_on_core_entities'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_entity_metadata ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, entity_type, entity_id, key) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_plugin_meta_entity ON plugin_entity_metadata (plugin_id, entity_type, entity_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101024200_freeze_the_fx_rate_on_settle_up.ts b/server/src/db/migrations/Migration20200101024200_freeze_the_fx_rate_on_settle_up.ts new file mode 100644 index 000000000..8dc5be0b0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024200_freeze_the_fx_rate_on_settle_up.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 162 (`db/migrations.ts`). + * + * Freeze the FX rate on settle-up transfers too (#1445). budget_settlements + * stored only a bare `amount` in whatever display currency the payer was + * viewing, so a later live-rate drift re-opened an already-settled position + * with a few-cent residual. Capture the display `currency` and the rate frozen + * at settle time (units of that currency per 1 trip currency), mirroring the + * budget_items columns. Legacy rows keep currency = NULL / rate = 1 and stay on + * live rates until re-edited. + */ +export class Migration20200101024200_freeze_the_fx_rate_on_settle_up extends Migration { + override name = 'Migration20200101024200_freeze_the_fx_rate_on_settle_up'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_settlements', 'currency', `currency TEXT`); + + await addColumnIfMissing(this, 'budget_settlements', 'exchange_rate', `exchange_rate REAL NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101024300_1446_guests_are_per_trip_people_but.ts b/server/src/db/migrations/Migration20200101024300_1446_guests_are_per_trip_people_but.ts new file mode 100644 index 000000000..f8bc726b7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024300_1446_guests_are_per_trip_people_but.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 163 (`db/migrations.ts`). + * + * #1446: guests are per-trip people, but their display name lived in the globally + * UNIQUE users.username, so a second "Jake" on another trip was auto-renamed to + * "Jake 2". Add a non-unique display_name; new guests store the human name here and + * get a uuid-based username that is never shown (the member views COALESCE to it). + */ +export class Migration20200101024300_1446_guests_are_per_trip_people_but extends Migration { + override name = 'Migration20200101024300_1446_guests_are_per_trip_people_but'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'display_name', `display_name TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101024400_plugin_dependencies.ts b/server/src/db/migrations/Migration20200101024400_plugin_dependencies.ts new file mode 100644 index 000000000..8d1e48208 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024400_plugin_dependencies.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 164 (`db/migrations.ts`). + * + * Plugin dependencies (#plugins): a plugin's trek-plugin.json can now declare + * `requiredAddons` (addon ids that must be enabled to activate) and + * `pluginDependencies` ({id, version-range} of other plugins that must be + * installed + satisfied). Stored as one JSON blob and populated by the + * discovery upsert on every install path. Legacy rows default to '{}' (no deps). + */ +export class Migration20200101024400_plugin_dependencies extends Migration { + override name = 'Migration20200101024400_plugin_dependencies'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'dependencies', `dependencies TEXT NOT NULL DEFAULT '{}'`); + } +} diff --git a/server/src/db/migrations/Migration20200101024500_per_user_plugin_settings.ts b/server/src/db/migrations/Migration20200101024500_per_user_plugin_settings.ts new file mode 100644 index 000000000..c180d68a7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024500_per_user_plugin_settings.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 165 (`db/migrations.ts`). + * + * Per-user plugin settings (#plugins). A plugin can declare `scope:'user'` + * settings fields (e.g. an API key or a personal preference); each USER stores + * their own values here, separate from the admin-owned instance `plugins.config`. + * Secrets are encrypted with the same apiKeyCrypto as instance secrets and are + * never echoed back to the client (masked). Runtime reads the acting user's row. + */ +export class Migration20200101024500_per_user_plugin_settings extends Migration { + override name = 'Migration20200101024500_per_user_plugin_settings'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_user_config ( + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + config TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (plugin_id, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101024600_host_brokered_outbound_oauth.ts b/server/src/db/migrations/Migration20200101024600_host_brokered_outbound_oauth.ts new file mode 100644 index 000000000..c42b527ae --- /dev/null +++ b/server/src/db/migrations/Migration20200101024600_host_brokered_outbound_oauth.ts @@ -0,0 +1,38 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 166 (`db/migrations.ts`). + * + * Host-brokered outbound OAuth (#plugins). A plugin becomes an OAuth *client* of a + * third-party service; the HOST runs authorize->callback->token->refresh with + * PKCE+state and owns the tokens — the plugin never sees the refresh token. Tokens + * are per-user + encrypted at rest; the in-flight PKCE verifier/state is short-lived. + */ +export class Migration20200101024600_host_brokered_outbound_oauth extends Migration { + override name = 'Migration20200101024600_host_brokered_outbound_oauth'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_oauth_tokens ( + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + access_token TEXT, + refresh_token TEXT, + expires_at INTEGER, + scope TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (plugin_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_oauth_state ( + state TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + verifier TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101024700_persistent_plugin_scheduler.ts b/server/src/db/migrations/Migration20200101024700_persistent_plugin_scheduler.ts new file mode 100644 index 000000000..2470eff02 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024700_persistent_plugin_scheduler.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 167 (`db/migrations.ts`). + * + * Persistent plugin scheduler (#plugins). A plugin with the existing `jobs:run` + * grant can schedule a userless callback to fire at a future time (once, or + * recurring), surviving server restarts because the entry lives here. Same risk + * class as cron jobs (no user, no trip reads, own db + declared egress only) — + * so it rides on `jobs:run`, no new consent. UNIQUE(plugin_id, name) makes + * scheduler.set an upsert and cancel deterministic. Rows are purged on uninstall. + */ +export class Migration20200101024700_persistent_plugin_scheduler extends Migration { + override name = 'Migration20200101024700_persistent_plugin_scheduler'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_scheduled_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + name TEXT NOT NULL, + due_at INTEGER NOT NULL, + payload TEXT NOT NULL DEFAULT 'null', + every_ms INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, name) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_plugin_sched_due ON plugin_scheduled_tasks (due_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101024800_durable_gdpr_erasure_queue.ts b/server/src/db/migrations/Migration20200101024800_durable_gdpr_erasure_queue.ts new file mode 100644 index 000000000..7dafe5607 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024800_durable_gdpr_erasure_queue.ts @@ -0,0 +1,30 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 168 (`db/migrations.ts`). + * + * Durable GDPR erasure queue (#plugins). When a TREK account is deleted, every + * installed plugin holding `hook:user-data` gets a pending row here so its own + * deleteUserData handler runs even if the plugin was offline at delete time — + * erasure must not be lost across a restart, so it is persisted (unlike the + * best-effort event buffer). The row is removed once the plugin acknowledges, + * and all of a plugin's rows are purged on uninstall. UNIQUE(plugin_id, user_id) + * makes re-enqueue idempotent. + */ +export class Migration20200101024800_durable_gdpr_erasure_queue extends Migration { + override name = 'Migration20200101024800_durable_gdpr_erasure_queue'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_user_erasure_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_plugin_erasure_plugin ON plugin_user_erasure_queue (plugin_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101024900_tombstones_for_atlas_countries_the_user_has.ts b/server/src/db/migrations/Migration20200101024900_tombstones_for_atlas_countries_the_user_has.ts new file mode 100644 index 000000000..0121713cc --- /dev/null +++ b/server/src/db/migrations/Migration20200101024900_tombstones_for_atlas_countries_the_user_has.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 169 (`db/migrations.ts`). + * + * Tombstones for Atlas countries the user has explicitly removed (#1490). + * Atlas derives visited countries from trip places and transport endpoints on every + * request, so those countries have no row to delete — "Remove" deleted from + * visited_countries (which never had the row), the client hid it optimistically, and + * the next getStats re-derived it. Recording the removal here lets getStats suppress + * a derived country. Re-marking a country deletes its tombstone. + */ +export class Migration20200101024900_tombstones_for_atlas_countries_the_user_has extends Migration { + override name = 'Migration20200101024900_tombstones_for_atlas_countries_the_user_has'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS hidden_countries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, country_code) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_hidden_countries_user ON hidden_countries (user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin.ts b/server/src/db/migrations/Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin.ts new file mode 100644 index 000000000..85ce0d166 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin.ts @@ -0,0 +1,37 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 170 (`db/migrations.ts`). + * + * Operator-supplied egress hosts for a plugin (#plugins). + * + * A plugin's egress allow-list is fixed in its manifest at publish time, but a + * plugin that talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the + * operator's hostname — so a community plugin could serve nobody. These rows let + * the ADMIN add hosts post-install; the runtime unions them into the child's + * allow-list at spawn. Consent stays with the admin (never the end user), + * exactly as for manifest egress. + */ +export class Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin extends Migration { + override name = 'Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS plugin_egress_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + host TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, host) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_plugin_egress_hosts_plugin ON plugin_egress_hosts (plugin_id)`); + + // Whether the plugin DECLARED that it needs operator-supplied hosts. Only + // such a plugin may have hosts added — an admin can never widen egress for a + // plugin that never asked for it, so install-time consent still bounds what + // is possible. + await addColumnIfMissing(this, 'plugins', 'operator_egress', `operator_egress INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101025100_settings_page_action_buttons_a_plugin_contributes.ts b/server/src/db/migrations/Migration20200101025100_settings_page_action_buttons_a_plugin_contributes.ts new file mode 100644 index 000000000..9f75f3554 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025100_settings_page_action_buttons_a_plugin_contributes.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 171 (`db/migrations.ts`). + * + * Settings-page action buttons a plugin contributes ("Test connection", "Sync now"). + * Descriptors only — the handler lives in the plugin's code and is invoked host-side + * with the CLICKING user bound, so it can read that user's own settings. + */ +export class Migration20200101025100_settings_page_action_buttons_a_plugin_contributes extends Migration { + override name = 'Migration20200101025100_settings_page_action_buttons_a_plugin_contributes'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_actions ( + plugin_id TEXT NOT NULL, + action_key TEXT NOT NULL, + label TEXT NOT NULL, + hint TEXT, + danger INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (plugin_id, action_key) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101025200_why_a_plugin_s_update_was_refused.ts b/server/src/db/migrations/Migration20200101025200_why_a_plugin_s_update_was_refused.ts new file mode 100644 index 000000000..cc60d7300 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025200_why_a_plugin_s_update_was_refused.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 172 (`db/migrations.ts`). + * + * Why a plugin's update was REFUSED by the signature check (#plugins). A refused + * update leaves a working plugin pinned at its old version — previously the + * reason lived only in a transient toast, so the plugin quietly stopped updating + * and the admin had to re-attempt an update to rediscover why. Record it instead. + * + * `update_block_version` is the registry version that was refused: once the + * registry offers something NEWER, the block describes an artifact nobody is + * being offered anymore, so it reads as stale and the admin can just re-attempt + * (the next install re-verifies and either succeeds or re-blocks with fresh + * values). Deliberately no `status = 'error'` — the plugin still runs fine on + * its old code. + */ +export class Migration20200101025200_why_a_plugin_s_update_was_refused extends Migration { + override name = 'Migration20200101025200_why_a_plugin_s_update_was_refused'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'update_block_code', `update_block_code TEXT`); + await addColumnIfMissing(this, 'plugins', 'update_block_detail', `update_block_detail TEXT`); + await addColumnIfMissing(this, 'plugins', 'update_block_version', `update_block_version TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101025300_the_semver_range_of_trek_versions_a.ts b/server/src/db/migrations/Migration20200101025300_the_semver_range_of_trek_versions_a.ts new file mode 100644 index 000000000..62c4658cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101025300_the_semver_range_of_trek_versions_a.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 173 (`db/migrations.ts`). + * + * The semver RANGE of TREK versions a plugin declares it supports (its manifest's + * `trek`, e.g. ">=3.2.0 <4.0.0"). The existing `min_trek_version` only carries the + * lower bound, so it cannot express "stops working at 4.0" — which is precisely the + * case the activation gate has to catch after a TREK upgrade. Kept nullable: a plugin + * installed before this column existed has no range recorded, and the gate refuses to + * activate it rather than guessing (see TREK_VERSION_UNKNOWN). + */ +export class Migration20200101025300_the_semver_range_of_trek_versions_a extends Migration { + override name = 'Migration20200101025300_the_semver_range_of_trek_versions_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'trek_range', `trek_range TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101025400_place_regions_is_a_re_derivable_nominatim.ts b/server/src/db/migrations/Migration20200101025400_place_regions_is_a_re_derivable_nominatim.ts new file mode 100644 index 000000000..ec44ba57a --- /dev/null +++ b/server/src/db/migrations/Migration20200101025400_place_regions_is_a_re_derivable_nominatim.ts @@ -0,0 +1,27 @@ +import { execUnlessTableMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 174 (`db/migrations.ts`). + * + * `place_regions` is a re-derivable Nominatim cache, only ever populated for a + * place ID that isn't already cached — so a wrong row, once written, was + * permanent. Region resolution now resolves a place's lat/lng directly against + * the bundled admin1 polygons (the same ones the client renders) instead of + * trusting Nominatim's address level, which could name a subdivision level the + * bundle doesn't carry (Barcelona's ES-B province vs the bundle's ES-CT + * autonomous community) and never highlight. That fix only helps places + * re-resolved after it, so clear the cache once and let every place re-resolve + * on the next Atlas load. The country_code stored alongside is cleared too, + * which also drops the old wrong-country rows a US-state-abbreviation address + * used to produce. + */ +export class Migration20200101025400_place_regions_is_a_re_derivable_nominatim extends Migration { + override name = 'Migration20200101025400_place_regions_is_a_re_derivable_nominatim'; + + override async up(): Promise { + // place_regions is created by an earlier migration; tolerate its absence on + // an unusual partial DB rather than aborting startup. + await execUnlessTableMissing(this, `DELETE FROM place_regions`); + } +} diff --git a/server/src/db/migrations/Migration20200101025500_tombstones_for_atlas_regions_the_user_has.ts b/server/src/db/migrations/Migration20200101025500_tombstones_for_atlas_regions_the_user_has.ts new file mode 100644 index 000000000..c9c61422a --- /dev/null +++ b/server/src/db/migrations/Migration20200101025500_tombstones_for_atlas_regions_the_user_has.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 175 (`db/migrations.ts`). + * + * Tombstones for Atlas regions the user has explicitly removed — the region-level + * counterpart to hidden_countries above (#1490). A visited region is normally derived + * fresh from place_regions/visited_regions on every request, so "removing" it has + * nothing to delete; recording it here lets getVisitedRegions suppress a derived region + * the same way getStats already suppresses a derived country. Unlike the country-level + * tombstone (originally only reachable for a manually-marked or zero-count country), + * this also covers a region derived from real place data — e.g. one that ended up on + * the wrong side of a border-simplification gap and the user just wants gone. + */ +export class Migration20200101025500_tombstones_for_atlas_regions_the_user_has extends Migration { + override name = 'Migration20200101025500_tombstones_for_atlas_regions_the_user_has'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS hidden_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + region_code TEXT NOT NULL, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, region_code) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_hidden_regions_user ON hidden_regions (user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101025600_half_vacation_days.ts b/server/src/db/migrations/Migration20200101025600_half_vacation_days.ts new file mode 100644 index 000000000..a7f2964be --- /dev/null +++ b/server/src/db/migrations/Migration20200101025600_half_vacation_days.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 176 (`db/migrations.ts`). + * + * Half vacation days (#552): a vacay entry can now count as a full day (1) or + * a half day (0.5) toward the entitlement. Existing entries default to a full + * day, so the entitlement maths are unchanged for everyone already using vacay. + * Guarded so re-running the migration tail (e.g. the crosswalk test) is a no-op. + */ +export class Migration20200101025600_half_vacation_days extends Migration { + override name = 'Migration20200101025600_half_vacation_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_entries', 'fraction', `fraction REAL NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101025700_read_only_vacay_calendar_sharing.ts b/server/src/db/migrations/Migration20200101025700_read_only_vacay_calendar_sharing.ts new file mode 100644 index 000000000..500a1abe8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025700_read_only_vacay_calendar_sharing.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 177 (`db/migrations.ts`). + * + * Read-only vacay calendar sharing (#444/#667): a user can let other users + * view their vacation calendar without fusing plans. owner_id is the sharing + * user, user_id the viewer; hidden lets the viewer hide the overlay without + * removing the share. Follows the person, not the plan, so it survives + * fusion and dissolution. + */ +export class Migration20200101025700_read_only_vacay_calendar_sharing extends Migration { + override name = 'Migration20200101025700_read_only_vacay_calendar_sharing'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + hidden INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (owner_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_vacay_shares_user ON vacay_shares (user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101025800_collaborative_place_ratings.ts b/server/src/db/migrations/Migration20200101025800_collaborative_place_ratings.ts new file mode 100644 index 000000000..96c4ea004 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025800_collaborative_place_ratings.ts @@ -0,0 +1,42 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 178 (`db/migrations.ts`). + * + * Collaborative place ratings (#1435): every trip member can rate a trip + * place 1-5, every collection member a saved place; the displayed value is + * the average. One row per user and place, mirroring collab_poll_votes. + */ +export class Migration20200101025800_collaborative_place_ratings extends Migration { + override name = 'Migration20200101025800_collaborative_place_ratings'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(place_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_ratings_place ON place_ratings (place_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_place_id, user_id) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_ratings_place ON collection_place_ratings (collection_place_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101025900_per_segment_travel_mode.ts b/server/src/db/migrations/Migration20200101025900_per_segment_travel_mode.ts new file mode 100644 index 000000000..0fcede2fb --- /dev/null +++ b/server/src/db/migrations/Migration20200101025900_per_segment_travel_mode.ts @@ -0,0 +1,21 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 179 (`db/migrations.ts`). + * + * Per-segment travel mode (#1281): the day-plan route can use a different + * transport mode for each leg. leg_transport_mode on an assignment is the mode + * of the leg LEAVING that stop (NULL = inherit the day default); days gains a + * persisted default_transport_mode so the whole-day choice survives a reload. + * Both nullable → existing itineraries keep today's single-mode behaviour. + */ +export class Migration20200101025900_per_segment_travel_mode extends Migration { + override name = 'Migration20200101025900_per_segment_travel_mode'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'leg_transport_mode', `leg_transport_mode TEXT`); + + await addColumnIfMissing(this, 'days', 'default_transport_mode', `default_transport_mode TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101030000_school_holidays_are_a_visual_vacay_calendar.ts b/server/src/db/migrations/Migration20200101030000_school_holidays_are_a_visual_vacay_calendar.ts new file mode 100644 index 000000000..0590f238e --- /dev/null +++ b/server/src/db/migrations/Migration20200101030000_school_holidays_are_a_visual_vacay_calendar.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 180 (`db/migrations.ts`). + * + * School holidays are a visual Vacay calendar layer. Keep them separate from + * public holidays so applyHolidayCalendars never removes vacation entries for + * school-break dates. + */ +export class Migration20200101030000_school_holidays_are_a_visual_vacay_calendar extends Migration { + override name = 'Migration20200101030000_school_holidays_are_a_visual_vacay_calendar'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'vacay_plans', + 'school_holidays_enabled', + `school_holidays_enabled INTEGER DEFAULT 0`, + ); + await addColumnIfMissing(this, 'vacay_holiday_calendars', 'type', `type TEXT NOT NULL DEFAULT 'public_holiday'`); + } +} diff --git a/server/src/db/migrations/Migration20200101030100_1517.ts b/server/src/db/migrations/Migration20200101030100_1517.ts new file mode 100644 index 000000000..85a6466ed --- /dev/null +++ b/server/src/db/migrations/Migration20200101030100_1517.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 181 (`db/migrations.ts`). + * + * #1517 — assign trip members / named guests to a reservation (mirrors budget_item_members). + */ +export class Migration20200101030100_1517 extends Migration { + override name = 'Migration20200101030100_1517'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS reservation_travelers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(reservation_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_reservation_travelers_res ON reservation_travelers(reservation_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_reservation_travelers_user ON reservation_travelers(user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101030200_comp_flex_days.ts b/server/src/db/migrations/Migration20200101030200_comp_flex_days.ts new file mode 100644 index 000000000..ee464ac9f --- /dev/null +++ b/server/src/db/migrations/Migration20200101030200_comp_flex_days.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 182 (`db/migrations.ts`). + * + * Comp/Flex days (#1074): a vacay entry is either a vacation day (counts toward + * the entitlement) or a comp/flex day (kind='comp', costs 0 — flextime/overtime + * offset). Orthogonal to fraction, so a half comp day is kind='comp' + fraction=0.5. + */ +export class Migration20200101030200_comp_flex_days extends Migration { + override name = 'Migration20200101030200_comp_flex_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_entries', 'kind', `kind TEXT NOT NULL DEFAULT 'vacation'`); + } +} diff --git a/server/src/db/migrations/Migration20200101030300_configurable_vacation_year.ts b/server/src/db/migrations/Migration20200101030300_configurable_vacation_year.ts new file mode 100644 index 000000000..d0103718d --- /dev/null +++ b/server/src/db/migrations/Migration20200101030300_configurable_vacation_year.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 183 (`db/migrations.ts`). + * + * Configurable vacation year (#737): per-user leave-year window. 'calendar' keeps + * the Jan 1–Dec 31 default (unchanged for everyone); 'fiscal' starts on a fixed + * month/day; 'anniversary' starts on the month/day of the hire date. The year + * integer still names a period; the service resolves it to a [start,end) range. + */ +export class Migration20200101030300_configurable_vacation_year extends Migration { + override name = 'Migration20200101030300_configurable_vacation_year'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_user_settings ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + year_type TEXT NOT NULL DEFAULT 'calendar', + year_start_month INTEGER NOT NULL DEFAULT 1, + year_start_day INTEGER NOT NULL DEFAULT 1, + hire_date TEXT + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101030400_manual_gpx_track_colour.ts b/server/src/db/migrations/Migration20200101030400_manual_gpx_track_colour.ts new file mode 100644 index 000000000..cf30cbdce --- /dev/null +++ b/server/src/db/migrations/Migration20200101030400_manual_gpx_track_colour.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 184 (`db/migrations.ts`). + * + * Manual GPX track colour (#776): imported tracks all render in the same blue + * because the importer never assigns a category, so several walks in the same + * area are indistinguishable. NULL keeps the old behaviour (category colour, + * then the #3b82f6 fallback) for every existing row. + */ +export class Migration20200101030400_manual_gpx_track_colour extends Migration { + override name = 'Migration20200101030400_manual_gpx_track_colour'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'route_color', `route_color TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101030500_backfill_the_three_places_feature_toggles_before.ts b/server/src/db/migrations/Migration20200101030500_backfill_the_three_places_feature_toggles_before.ts new file mode 100644 index 000000000..95e3bd883 --- /dev/null +++ b/server/src/db/migrations/Migration20200101030500_backfill_the_three_places_feature_toggles_before.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +const PLACES_TOGGLES = ['places_photos_enabled', 'places_autocomplete_enabled', 'places_details_enabled']; + +/** + * Legacy migration step 185 (`db/migrations.ts`). + * + * Backfill the three places feature toggles before their read flips from + * fail-open (`value !== 'false'`) to fail-closed (`value === 'true'`), so + * existing installs that never touched the admin switches keep the features they + * have today. A row that already says 'false' is left alone. + */ +export class Migration20200101030500_backfill_the_three_places_feature_toggles_before extends Migration { + override name = 'Migration20200101030500_backfill_the_three_places_feature_toggles_before'; + + override up(): void { + for (const key of PLACES_TOGGLES) { + this.addSql(`INSERT OR IGNORE INTO app_settings (key, value) VALUES ('${key}', 'true')`); + } + } +} diff --git a/server/src/db/migrations/Migration20200101030600_free_the_note_column_on_budget_items.ts b/server/src/db/migrations/Migration20200101030600_free_the_note_column_on_budget_items.ts new file mode 100644 index 000000000..2a1a1a7f4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101030600_free_the_note_column_on_budget_items.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 186 (`db/migrations.ts`). + * + * Free the `note` column on budget items for its actual purpose (#1658). The + * costs UI stores an itemized receipt in it as `TICKETJSON:{...}`, which means an + * expense split by receipt can never carry a written note, and saving an expense + * any other way wipes whatever was typed in the budget table. The receipt moves + * to its own column and note becomes text again. + * + * GLOB, not LIKE: LIKE is case-insensitive in SQLite, so a hand-written note + * starting "ticketjson:" would be chopped up and its text dropped. + */ +export class Migration20200101030600_free_the_note_column_on_budget_items extends Migration { + override name = 'Migration20200101030600_free_the_note_column_on_budget_items'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'ticket_json', `ticket_json TEXT`); + await this.execute(` + UPDATE budget_items + SET ticket_json = substr(note, 12), note = NULL + WHERE note GLOB 'TICKETJSON:*' + `); + } +} diff --git a/server/src/db/migrations/Migration20200101030700_note_colours.ts b/server/src/db/migrations/Migration20200101030700_note_colours.ts new file mode 100644 index 000000000..5b86940d4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101030700_note_colours.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 187 (`db/migrations.ts`). + * + * Note colours (#1629). A day note is a label as much as a reminder — "watch + * out", "must see", "already booked" — and a wall of identical grey cards + * makes that impossible to see at a glance. NULL keeps the neutral card + * every existing note has today. + */ +export class Migration20200101030700_note_colours extends Migration { + override name = 'Migration20200101030700_note_colours'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_notes', 'color', `color TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101030800_per_segment_travel_mode_for_boundary_legs.ts b/server/src/db/migrations/Migration20200101030800_per_segment_travel_mode_for_boundary_legs.ts new file mode 100644 index 000000000..4450d004c --- /dev/null +++ b/server/src/db/migrations/Migration20200101030800_per_segment_travel_mode_for_boundary_legs.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 188 (`db/migrations.ts`). + * + * Per-segment travel mode for boundary legs: a leg whose ORIGIN is not a place + * (booking arrival, morning hotel) stores its mode on the DESTINATION stop. + * NULL = inherit the day default. INERT whenever the previous timeline element + * is itself a place (that place's outgoing leg_transport_mode wins). + * Appended LAST: the array is index-addressed against schema_version, so a slot + * inserted anywhere above this line is simply skipped on every existing database. + */ +export class Migration20200101030800_per_segment_travel_mode_for_boundary_legs extends Migration { + override name = 'Migration20200101030800_per_segment_travel_mode_for_boundary_legs'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'day_assignments', + 'incoming_leg_transport_mode', + `incoming_leg_transport_mode TEXT`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101030900_1298.ts b/server/src/db/migrations/Migration20200101030900_1298.ts new file mode 100644 index 000000000..d6d3a43af --- /dev/null +++ b/server/src/db/migrations/Migration20200101030900_1298.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 189 (`db/migrations.ts`). + * + * #1298 — an expense can hang off a place, exactly as it already hangs off a + * reservation. Same nullable FK, same ON DELETE SET NULL: the delete paths + * take the linked expense with the place themselves, so the constraint is a + * backstop for anything that removes a place without going through them. + * Appended LAST: the array is index-addressed against schema_version, so a + * slot inserted above this line never runs on an existing database. + */ +export class Migration20200101030900_1298 extends Migration { + override name = 'Migration20200101030900_1298'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'budget_items', + 'place_id', + `place_id INTEGER REFERENCES places(id) ON DELETE SET NULL DEFAULT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101031000_a_reservation_day_positions_row_only_makes.ts b/server/src/db/migrations/Migration20200101031000_a_reservation_day_positions_row_only_makes.ts new file mode 100644 index 000000000..63ac9e6b4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031000_a_reservation_day_positions_row_only_makes.ts @@ -0,0 +1,28 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 190 (`db/migrations.ts`). + * + * A reservation_day_positions row only makes sense when its reservation and + * its day are on the same trip. The table carries no trip_id and its two + * foreign keys only ask that the ids exist, so pairs that never belonged + * together could accumulate; the writer refuses them now, and this clears + * whatever an older build let through. This slot keeps the index it shipped + * with on dev — the array is index-addressed against schema_version. + */ +export class Migration20200101031000_a_reservation_day_positions_row_only_makes extends Migration { + override name = 'Migration20200101031000_a_reservation_day_positions_row_only_makes'; + + override up(): void { + this.addSql(` + DELETE FROM reservation_day_positions + WHERE rowid IN ( + SELECT rdp.rowid + FROM reservation_day_positions rdp + JOIN reservations r ON r.id = rdp.reservation_id + JOIN days d ON d.id = rdp.day_id + WHERE d.trip_id <> r.trip_id + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101031100_1939.ts b/server/src/db/migrations/Migration20200101031100_1939.ts new file mode 100644 index 000000000..918eeef15 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031100_1939.ts @@ -0,0 +1,58 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 191 (`db/migrations.ts`). + * + * #1939 — the Google Places and Unsplash keys are instance configuration and now + * resolve out of app_settings (nest/settings/instance-api-keys.ts). This carries + * the value the old resolver would have handed out — the lowest-id admin who has + * one — into that row, so an install that upgrades keeps the key it was + * searching with instead of falling back to OpenStreetMap. + * + * Only where that key already was the whole install's, though. The resolver + * reads the instance row before the caller's own column, so promoting a personal + * key while a second row still holds one would move that member onto a + * stranger's key and a stranger's bill without anyone saying so. As soon as + * another row has a value for the same column nothing is written: everybody + * keeps resolving what they resolved before, and a member with no key of their + * own searches via OpenStreetMap until an admin saves the instance key once in + * the panel. Handing a key to the whole instance is the admin's call, not a + * migration's. + * + * The users columns are left alone: they are still the per-user fallback, and + * nothing here can lose a value. Stored blobs are copied verbatim; both sides + * use the same apiKeyCrypto format, legacy plaintext included. + */ +export class Migration20200101031100_1939 extends Migration { + override name = 'Migration20200101031100_1939'; + + override async up(): Promise { + for (const column of ['maps_api_key', 'unsplash_api_key'] as const) { + const existing = (await this.execute(`SELECT value FROM app_settings WHERE key = ?`, [column])) as { + value: string | null; + }[]; + if (existing[0]?.value) continue; + + const candidates = (await this.execute( + `SELECT id, ${column} AS value FROM users + WHERE role = 'admin' AND ${column} IS NOT NULL AND ${column} != '' + ORDER BY id ASC LIMIT 1`, + )) as { id: number; value: string }[]; + const row = candidates[0]; + if (!row?.value) continue; + + const otherHolder = await this.execute( + `SELECT 1 AS present FROM users + WHERE id != ? AND ${column} IS NOT NULL AND ${column} != '' LIMIT 1`, + [row.id], + ); + if (otherHolder.length > 0) continue; + + await this.execute( + `INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [column, row.value], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101031200_capture_metadata_on_the_photo_itself.ts b/server/src/db/migrations/Migration20200101031200_capture_metadata_on_the_photo_itself.ts new file mode 100644 index 000000000..73cf521bb --- /dev/null +++ b/server/src/db/migrations/Migration20200101031200_capture_metadata_on_the_photo_itself.ts @@ -0,0 +1,26 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 192 (`db/migrations.ts`). + * + * Capture metadata on the photo itself (#1614): when it was taken and where. + * Both are needed before photos can sit on the Journey map by their own + * coordinates, and before a gallery can be ordered by when a picture was taken + * rather than when it happened to be added. Nullable throughout — most providers + * answer with neither, and a photo without them is normal. + */ +export class Migration20200101031200_capture_metadata_on_the_photo_itself extends Migration { + override name = 'Migration20200101031200_capture_metadata_on_the_photo_itself'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trek_photos', 'taken_at', `taken_at TEXT`); + await addColumnIfMissing(this, 'trek_photos', 'lat', `lat REAL`); + await addColumnIfMissing(this, 'trek_photos', 'lng', `lng REAL`); + + // Answering "which photos of this journey have coordinates" without a scan. + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101031300_a_journey_shared_while_the_trip_is.ts b/server/src/db/migrations/Migration20200101031300_a_journey_shared_while_the_trip_is.ts new file mode 100644 index 000000000..c861df6e6 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031300_a_journey_shared_while_the_trip_is.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 193 (`db/migrations.ts`). + * + * A journey shared while the trip is still running reads like a blog, and a + * blog puts the newest entry first (#1614). The owner decides per share link, + * because it is a property of how the link is meant to be read, not of the + * journey. Off by default: an already-published link must not reorder itself + * under its readers. + */ +export class Migration20200101031300_a_journey_shared_while_the_trip_is extends Migration { + override name = 'Migration20200101031300_a_journey_shared_while_the_trip_is'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_share_tokens', 'newest_first', `newest_first INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101031400_trek_studio_books.ts b/server/src/db/migrations/Migration20200101031400_trek_studio_books.ts new file mode 100644 index 000000000..f87a6f133 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031400_trek_studio_books.ts @@ -0,0 +1,37 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 194 (`db/migrations.ts`). + * + * TREK Studio books (#1973). + * One row per book, the document itself stored as JSON. A book is a + * document rather than a graph of records: the editor loads it whole, the + * renderer prints it whole, and nothing ever queries "which books contain a + * heart-shaped frame". Normalising spreads and elements into tables would + * buy queries nobody makes and cost a join on every open plus a schema + * migration for every new element kind. + * `updated_at` and `version` are what make concurrent editing possible: + * the version increments on every write, and a client that saves against a + * version it did not read gets told rather than silently overwriting. + */ +export class Migration20200101031400_trek_studio_books extends Migration { + override name = 'Migration20200101031400_trek_studio_books'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + document TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_books_journey ON journey_books(journey_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101031500_great_britain_gained_its_counties_and_boroughs.ts b/server/src/db/migrations/Migration20200101031500_great_britain_gained_its_counties_and_boroughs.ts new file mode 100644 index 000000000..14afbb064 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031500_great_britain_gained_its_counties_and_boroughs.ts @@ -0,0 +1,79 @@ +import { Migration } from '@mikro-orm/migrations'; + +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; + +interface VisitedRegion { + id: number; + region_code: string; + region_name: string; + country_code: string; +} + +interface Admin1Feature { + properties?: { iso_a2?: string; iso_3166_2?: string; name?: string }; +} + +/** + * Legacy migration step 195 (`db/migrations.ts`). + * + * Great Britain gained its counties and boroughs back (#1974). + * + * geoBoundaries' GBR ADM1 is the four constituent countries, so since 3.1.0 the + * atlas had no polygon below "England" and a London borough marked as visited + * had nothing to light up. The bundle now ships GB at ADM2, which is the + * granularity the rest of the world gets and the granularity the pre-3.1.0 + * Natural Earth layer had. + * + * Two things need saying to the data that already exists: + * + * `visited_regions` rows marked before 3.1.0 hold the old Natural Earth borough + * codes. They are reconciled by name within the same country, exactly as the + * 3.1.1 step does for every other country — a row that still cannot be resolved + * is left alone rather than destroyed, and the client's name fallback may well + * still highlight it. + * + * `place_regions` is a re-derivable cache, and it is full of GB-ENG rows that + * were correct under the old bundle. Nothing re-derives a row that is already + * there, so without clearing them every English place would keep reporting + * "England" forever. + */ +export class Migration20200101031500_great_britain_gained_its_counties_and_boroughs extends Migration { + override name = 'Migration20200101031500_great_britain_gained_its_counties_and_boroughs'; + + override async up(): Promise { + await this.execute(`DELETE FROM place_regions WHERE UPPER(COALESCE(country_code, '')) = 'GB'`); + + const rows = (await this.execute( + `SELECT id, region_code, region_name, country_code FROM visited_regions WHERE UPPER(COALESCE(country_code, '')) = 'GB'`, + )) as VisitedRegion[]; + if (rows.length === 0) return; + + let features: Admin1Feature[]; + try { + const file = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz'); + features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || []; + } catch { + return; // bundle unreadable — leave the rows untouched rather than guessing + } + + const validCodes = new Set(); + const nameToCode = new Map(); + for (const f of features) { + const p = f.properties || {}; + if ((p.iso_a2 || '').toUpperCase() !== 'GB' || !p.iso_3166_2) continue; + validCodes.add(p.iso_3166_2); + if (p.name) nameToCode.set(p.name.toLowerCase(), p.iso_3166_2); + } + if (validCodes.size === 0) return; + + for (const row of rows) { + if (validCodes.has(row.region_code)) continue; + const byName = nameToCode.get((row.region_name || '').toLowerCase()); + if (byName) { + await this.execute(`UPDATE OR IGNORE visited_regions SET region_code = ? WHERE id = ?`, [byName, row.id]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101031600_storage_slice_2.ts b/server/src/db/migrations/Migration20200101031600_storage_slice_2.ts new file mode 100644 index 000000000..bf8ec5eb1 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031600_storage_slice_2.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 196 (`db/migrations.ts`). + * + * Storage slice 2 — collab note attachments historically stored 'files/' + * in trip_files.filename while the file manager stored bare names in the same + * column; the storage layer addresses objects as category + bare name, so + * normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of + * 'files/'. The LIKE guard makes it a no-op on already-bare rows. + * Appended LAST again, and for the same reason as the note this replaces: + * the array is index-addressed against schema_version, so a slot that has + * shipped in dev keeps the index it shipped with and anything from this + * branch goes after it. A database that already ran this migration at its + * pre-merge index replays it harmlessly (the LIKE guard) but skips whatever + * now occupies that index. Acceptable only because this branch has never + * been published; never do this with a released slot. + */ +export class Migration20200101031600_storage_slice_2 extends Migration { + override name = 'Migration20200101031600_storage_slice_2'; + + override up(): void { + this.addSql(`UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'`); + } +} diff --git a/server/src/db/migrations/Migration20200101031700_give_back_the_notes_the_ticketjson_step.ts b/server/src/db/migrations/Migration20200101031700_give_back_the_notes_the_ticketjson_step.ts new file mode 100644 index 000000000..e646fb9a8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031700_give_back_the_notes_the_ticketjson_step.ts @@ -0,0 +1,43 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 197 (`db/migrations.ts`). + * + * Give back the notes the TICKETJSON step chopped up (#1658). + * + * That step matched with LIKE, which is case-insensitive in SQLite, so a note + * somebody had typed starting "ticketjson:" was read as a receipt: the first + * eleven characters were dropped, the rest was moved into ticket_json and the + * note was set to NULL. The step now matches with GLOB, but that only helps a + * fresh install — the legacy array was index-addressed against schema_version, + * so a repaired step never ran again on a database that had already applied it. + * + * A receipt comes out of JSON.stringify and always parses; typed text does not. + * So a row moves back only when its ticket_json fails to parse AND the note is + * still empty — anything that parses, and any row someone has written a note on + * since, is left exactly as it is. The eleven marker characters themselves are + * gone for good: their case was the only thing separating a receipt from a note, + * and writing "TICKETJSON:" back would hand the row straight to the reader that + * reads that prefix as a receipt. + */ +export class Migration20200101031700_give_back_the_notes_the_ticketjson_step extends Migration { + override name = 'Migration20200101031700_give_back_the_notes_the_ticketjson_step'; + + override async up(): Promise { + const rows = (await this.execute( + `SELECT id, ticket_json FROM budget_items + WHERE ticket_json IS NOT NULL AND ticket_json != '' AND COALESCE(note, '') = ''`, + )) as { id: number; ticket_json: string }[]; + + for (const row of rows) { + try { + JSON.parse(row.ticket_json); + } catch { + await this.execute(`UPDATE budget_items SET note = ?, ticket_json = NULL WHERE id = ?`, [ + row.ticket_json, + row.id, + ]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101031800_a_deliberate_non_latest_plugin_install_sets.ts b/server/src/db/migrations/Migration20200101031800_a_deliberate_non_latest_plugin_install_sets.ts new file mode 100644 index 000000000..6b2ff866f --- /dev/null +++ b/server/src/db/migrations/Migration20200101031800_a_deliberate_non_latest_plugin_install_sets.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 198 (`db/migrations.ts`). + * + * A deliberate non-latest plugin install sets `update_hold`: the row leaves the + * update banner and "Update all" until the admin resumes updates, or until an + * install lands back on the newest compatible version. Only an EXPLICIT version + * pick ever sets it — dependency resolution pins versions too, but never + * deliberately. Appended LAST — the array is index-addressed against schema_version. + */ +export class Migration20200101031800_a_deliberate_non_latest_plugin_install_sets extends Migration { + override name = 'Migration20200101031800_a_deliberate_non_latest_plugin_install_sets'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'update_hold', `update_hold INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101031900_reservations_an_automated_ingest_parked_for_review.ts b/server/src/db/migrations/Migration20200101031900_reservations_an_automated_ingest_parked_for_review.ts new file mode 100644 index 000000000..ce8de24ec --- /dev/null +++ b/server/src/db/migrations/Migration20200101031900_reservations_an_automated_ingest_parked_for_review.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 199 (`db/migrations.ts`). + * + * Reservations an automated ingest parked for review are 'staged' and stay out + * of the two anonymous exports (ICS feed, shared trip) until a person confirms + * them. Nothing writes 'staged' yet; this is the gate the mail ingest writes + * through. + * + * 'live' as the default is what keeps this from being a breaking change: SQLite + * fills every existing row on the ALTER, and no current writer names the column, + * so every booking that is visible today stays visible. + */ +export class Migration20200101031900_reservations_an_automated_ingest_parked_for_review extends Migration { + override name = 'Migration20200101031900_reservations_an_automated_ingest_parked_for_review'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'ingest_state', `ingest_state TEXT NOT NULL DEFAULT 'live'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032000_separate_an_integration_key_from_an_mcp.ts b/server/src/db/migrations/Migration20200101032000_separate_an_integration_key_from_an_mcp.ts new file mode 100644 index 000000000..9d19028f2 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032000_separate_an_integration_key_from_an_mcp.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 200 (`db/migrations.ts`). + * + * Separate an integration key from an MCP token (#2089). + * Both live in mcp_tokens and until now both opened everything a token can + * open. That was fine while /mcp was the only consumer; with a public REST + * surface it means a key somebody minted for a chat client also reads their + * trips over HTTP, and a key minted for an integration can drive every MCP + * tool. One credential, two very different blast radii. + * `kind` splits them, and every existing row becomes 'mcp' — that is what + * they were issued for, and silently widening a key that is already in + * somebody's config would be the opposite of what this migration is for. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032000_separate_an_integration_key_from_an_mcp extends Migration { + override name = 'Migration20200101032000_separate_an_integration_key_from_an_mcp'; + + override async up(): Promise { + await addColumnIfMissing(this, 'mcp_tokens', 'kind', `kind TEXT NOT NULL DEFAULT 'mcp'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032100_naver_list_import_was_typed_trip_but.ts b/server/src/db/migrations/Migration20200101032100_naver_list_import_was_typed_trip_but.ts new file mode 100644 index 000000000..2bf2947ea --- /dev/null +++ b/server/src/db/migrations/Migration20200101032100_naver_list_import_was_typed_trip_but.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 201 (`db/migrations.ts`). + * + * `naver_list_import` was typed 'trip', but a trip addon is one that earns its + * own tab inside a trip — this one has no tab (it is not in tripTabs.ts) and no + * page. It calls an external service to pull places into the sidebar, which is + * exactly what 'integration' means here. + * + * The type is presentational: only `type === 'global'` is read anywhere (client + * navItems.ts), so this moves the tile between admin groups and changes nothing + * about how the import behaves. + */ +export class Migration20200101032100_naver_list_import_was_typed_trip_but extends Migration { + override name = 'Migration20200101032100_naver_list_import_was_typed_trip_but'; + + override up(): void { + this.addSql(`UPDATE addons SET type = 'integration' WHERE id = 'naver_list_import'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032200_whether_a_journey_s_map_draws_the.ts b/server/src/db/migrations/Migration20200101032200_whether_a_journey_s_map_draws_the.ts new file mode 100644 index 000000000..2d9520e11 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032200_whether_a_journey_s_map_draws_the.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 202 (`db/migrations.ts`). + * + * Whether a journey's map draws the GPX tracks of the trips behind it (#2194). + * #1260 added those tracks unconditionally and with nothing to switch off, + * on the reasoning that a route imported into a linked trip is part of the + * journey's story. For a trip carrying a season of recorded drives it is + * instead a map nobody asked for, drawn from places that never became an + * entry — so it becomes a journey-level setting. + * DEFAULT 0, i.e. off: the tracks are opt-in from here on. That is a + * deliberate behaviour change rather than a preserved default — a journal + * should show what its author put in it, and #1260's set is everything the + * linked trips happen to contain. Owners who want them back have one + * switch in Journey Settings. + */ +export class Migration20200101032200_whether_a_journey_s_map_draws_the extends Migration { + override name = 'Migration20200101032200_whether_a_journey_s_map_draws_the'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journeys', 'show_trip_tracks', `show_trip_tracks INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101032300_settings_field_defaults.ts b/server/src/db/migrations/Migration20200101032300_settings_field_defaults.ts new file mode 100644 index 000000000..fab647aaf --- /dev/null +++ b/server/src/db/migrations/Migration20200101032300_settings_field_defaults.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 203 (`db/migrations.ts`). + * + * Settings-field defaults (#plugins, PR-87 feedback). A manifest `default` is the + * field's effective value when nothing is stored — the settings form pre-fills it and + * the runtime resolves it (settings-defaults.ts); it was previously accepted by the + * manifest and silently dropped here. JSON-encoded so string/number/boolean round-trip. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032300_settings_field_defaults extends Migration { + override name = 'Migration20200101032300_settings_field_defaults'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugin_settings_fields', 'default_value', `default_value TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101032400_settings_form_actions_gain_a_scope.ts b/server/src/db/migrations/Migration20200101032400_settings_form_actions_gain_a_scope.ts new file mode 100644 index 000000000..6073ad8ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101032400_settings_form_actions_gain_a_scope.ts @@ -0,0 +1,21 @@ +import { addColumnIfMissing, columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 204 (`db/migrations.ts`). + * + * Settings-form actions gain a scope (#plugins): 'user' renders on the user + * Settings tab, 'instance' in the admin instance-settings dialog. Existing rows + * predate the column and were all user-tab buttons, so the default keeps them + * where they were. + */ +export class Migration20200101032400_settings_form_actions_gain_a_scope extends Migration { + override name = 'Migration20200101032400_settings_form_actions_gain_a_scope'; + + override async up(): Promise { + // An empty column list means the table does not exist on this database. + const names = await columnNames(this, 'plugin_actions'); + if (names.size === 0) return; + await addColumnIfMissing(this, 'plugin_actions', 'scope', `scope TEXT NOT NULL DEFAULT 'user'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032500_a_journal_entry_that_is_not_a.ts b/server/src/db/migrations/Migration20200101032500_a_journal_entry_that_is_not_a.ts new file mode 100644 index 000000000..751ec3b77 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032500_a_journal_entry_that_is_not_a.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 205 (`db/migrations.ts`). + * + * A journal entry that is not a stop (discussion #2064). + * Studio draws its route and prints its distance from every entry that + * carries coordinates, and that is right until the journal starts at the + * home airport: the night before the flight, the stopover, the place the + * trip was planned from all become stops, and the distance counts the legs + * to and from them. The traveller knows which of those are the journey and + * which are the way there, so the switch sits on the entry. The entry stays + * in the journal; it is only left out of the arithmetic. + * DEFAULT 0: every existing entry keeps counting, which is what it did. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032500_a_journal_entry_that_is_not_a extends Migration { + override name = 'Migration20200101032500_a_journal_entry_that_is_not_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'stats_excluded', `stats_excluded INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101032600_place_shadow_log_which_search_result_a.ts b/server/src/db/migrations/Migration20200101032600_place_shadow_log_which_search_result_a.ts new file mode 100644 index 000000000..d06f4b20b --- /dev/null +++ b/server/src/db/migrations/Migration20200101032600_place_shadow_log_which_search_result_a.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 206 (`db/migrations.ts`). + * + * Place shadow log: which search result a user actually picked. + * The corpus behind "would our own index have found that too". No user id, + * no trip, no session and no result list — the evaluation compares a query + * against a pick, and anything beyond that would be collecting for its own + * sake on an instance that promises not to. + * The table is created regardless of the switch: the switch decides whether + * rows are written, and a schema that appears only when a feature is + * enabled is a schema that differs between installs. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032600_place_shadow_log_which_search_result_a extends Migration { + override name = 'Migration20200101032600_place_shadow_log_which_search_result_a'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_shadow_picks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + query TEXT NOT NULL, + lang TEXT, + bias_lat REAL, + bias_lng REAL, + source TEXT NOT NULL, + live_rank INTEGER NOT NULL, + live_count INTEGER NOT NULL, + picked_name TEXT NOT NULL, + picked_lat REAL NOT NULL, + picked_lng REAL NOT NULL, + picked_place_id TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_shadow_created ON place_shadow_picks(created_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101032700_what_kind_of_stop_a_place_is.ts b/server/src/db/migrations/Migration20200101032700_what_kind_of_stop_a_place_is.ts new file mode 100644 index 000000000..c7f6c6d2c --- /dev/null +++ b/server/src/db/migrations/Migration20200101032700_what_kind_of_stop_a_place_is.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 207 (`db/migrations.ts`). + * + * What kind of stop a place is on a drive — fuel, charging, rest area, campsite. + * Deliberately NOT a `categories` row. Those are the traveller's own list, editable + * and instance-wide (`categories.service.ts` selects them without a user filter), so + * seeding four road-trip kinds there would push them into everyone's dropdown and hand + * their colour to whoever edits the list first. A refuelling stop is not a taste; it is + * a fact about the place, and the road-trip categories already own its icon and colour + * (`poiCategories.ts`). + * Free text rather than a CHECK constraint: the set grows with what the corridor + * search can look for, and SQLite cannot alter a constraint without rebuilding the + * table. NULL means an ordinary place, which is every row that exists today. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032700_what_kind_of_stop_a_place_is extends Migration { + override name = 'Migration20200101032700_what_kind_of_stop_a_place_is'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'stop_type', `stop_type TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101032800_points_a_day_s_drive_is_made.ts b/server/src/db/migrations/Migration20200101032800_points_a_day_s_drive_is_made.ts new file mode 100644 index 000000000..0c79f8750 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032800_points_a_day_s_drive_is_made.ts @@ -0,0 +1,38 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 208 (`db/migrations.ts`). + * + * Points a day's drive is made to pass through, without being stops (#1797). + * The difference is the whole point: a stop is somewhere you go, and it takes a + * number in the chain, a place row, an arrival time and a line in the itinerary. A + * via is none of that — it only bends the route, which is what "take the coast road + * instead" means. Storing one as a place was the alternative, and it would have put a + * numbered stop in the middle of the day for a spot nobody stops at. + * Anchored to `after_order_index` rather than to an assignment id: a stop added + * mid-day is written with a temporary negative id and swapped for the real one moments + * later, so a foreign key to it would dangle. The index is what the routing request is + * built from anyway. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032800_points_a_day_s_drive_is_made extends Migration { + override name = 'Migration20200101032800_points_a_day_s_drive_is_made'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS roadtrip_vias ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + after_order_index INTEGER NOT NULL, + sequence INTEGER NOT NULL DEFAULT 0, + lat REAL NOT NULL, + lng REAL NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_roadtrip_vias_day ON roadtrip_vias(day_id, after_order_index, sequence)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101032900_which_imported_track_a_day_s_drive.ts b/server/src/db/migrations/Migration20200101032900_which_imported_track_a_day_s_drive.ts new file mode 100644 index 000000000..3e0a2e96d --- /dev/null +++ b/server/src/db/migrations/Migration20200101032900_which_imported_track_a_day_s_drive.ts @@ -0,0 +1,33 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 209 (`db/migrations.ts`). + * + * Which imported track a day's drive was fitted to. + * The vias alone already make the day follow it, so this stores nothing the drive + * needs — it stores what the traveller needs: the name of the road they chose, still + * on the day after a reload, and something to re-fit against when the stops change. + * One row per day, hence `day_id` as the key: a day follows one road or none. The + * cascade on `place_id` is the point of the foreign key — delete the imported track + * and the label goes with it, rather than leaving a day claiming to follow a line + * nobody can see any more. The vias it laid down stay, because they are the drive. + * `stray_km` is how far the fitted route still ran from the track at its worst point, + * kept so the day can say how good a fit it is without routing again. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032900_which_imported_track_a_day_s_drive extends Migration { + override name = 'Migration20200101032900_which_imported_track_a_day_s_drive'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS roadtrip_day_tracks ( + day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + stray_km REAL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_roadtrip_day_tracks_place ON roadtrip_day_tracks(place_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033000_rename_the_misnamed_guangdong_province.ts b/server/src/db/migrations/Migration20200101033000_rename_the_misnamed_guangdong_province.ts new file mode 100644 index 000000000..777db83ef --- /dev/null +++ b/server/src/db/migrations/Migration20200101033000_rename_the_misnamed_guangdong_province.ts @@ -0,0 +1,45 @@ +import { Migration } from '@mikro-orm/migrations'; + +const OLD_MATCH = `(region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`; + +/** + * Legacy migration step 210 (`db/migrations.ts`). + * + * Rename the misnamed Guangdong Province (shipped as "Guangzhou Province"). + * + * geoBoundaries labelled the whole province with the name of its capital, so + * every row a user collected under it carries the wrong code. All three tables + * that key on a region are moved over: the two per-user ones with an + * `UPDATE OR IGNORE` plus a `DELETE`, because a user who already holds the + * correct region would otherwise hit the unique index and keep a duplicate, and + * `place_regions` with a plain UPDATE, because place_id is its primary key and + * nothing there can collide. + */ +export class Migration20200101033000_rename_the_misnamed_guangdong_province extends Migration { + override name = 'Migration20200101033000_rename_the_misnamed_guangdong_province'; + + override up(): void { + this.addSql(` + UPDATE OR IGNORE visited_regions + SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' + WHERE UPPER(country_code) = 'CN' AND ${OLD_MATCH} + `); + this.addSql(`DELETE FROM visited_regions WHERE UPPER(country_code) = 'CN' AND ${OLD_MATCH}`); + + this.addSql(` + UPDATE OR IGNORE place_regions + SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' + WHERE UPPER(country_code) = 'CN' AND ${OLD_MATCH} + `); + + // hidden_regions is the other direction: it remembers which derived region a + // user switched off. Left behind, the tombstone stops matching and the region + // a user deliberately hid comes back. + this.addSql(` + UPDATE OR IGNORE hidden_regions + SET region_code = 'CN-GUANGDONGPROVINCE' + WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE' + `); + this.addSql(`DELETE FROM hidden_regions WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE'`); + } +} diff --git a/server/src/db/migrations/Migration20200101033100_let_a_file_hang_off_an_expense.ts b/server/src/db/migrations/Migration20200101033100_let_a_file_hang_off_an_expense.ts new file mode 100644 index 000000000..231eb95bc --- /dev/null +++ b/server/src/db/migrations/Migration20200101033100_let_a_file_hang_off_an_expense.ts @@ -0,0 +1,38 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 211 (`db/migrations.ts`). + * + * Let a file hang off an expense, so a receipt or an invoice can be attached to + * what it paid for. + * + * A column on `file_links` rather than a table of its own: the row already ties + * one file to one thing, and every other attachment kind is a column here too. + * The unique index stops the same receipt being linked twice; SQLite treats + * NULLs as distinct, so the rows that exist today, which all carry a NULL here, + * do not collide with each other. + * + * SET NULL rather than CASCADE, unlike the three columns beside it, because + * those predate the shared row: one row can carry a place link AND a receipt + * link for the same file, and a cascade would delete the whole row when the + * expense goes, silently detaching the file from the place as well. Deleting the + * expense drops the receipt link and nothing else; the budget service removes + * the row afterwards when it carries no other link. + */ +export class Migration20200101033100_let_a_file_hang_off_an_expense extends Migration { + override name = 'Migration20200101033100_let_a_file_hang_off_an_expense'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'file_links', + 'budget_item_id', + `budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE SET NULL`, + ); + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)`, + ); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033200_which_chat_message_an_uploaded_image_belongs.ts b/server/src/db/migrations/Migration20200101033200_which_chat_message_an_uploaded_image_belongs.ts new file mode 100644 index 000000000..6a1c0e49c --- /dev/null +++ b/server/src/db/migrations/Migration20200101033200_which_chat_message_an_uploaded_image_belongs.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 212 (`db/migrations.ts`). + * + * Which chat message an uploaded image belongs to. + * + * A column on `trip_files` rather than a link row, matching the two that predate + * it: a chat image is uploaded for exactly one message and dies with it, so the + * cascade is the whole relationship. Guarded through pragma_table_info like + * every other column add here, not through a caught "duplicate column name": + * that swallows the next error too. + */ +export class Migration20200101033200_which_chat_message_an_uploaded_image_belongs extends Migration { + override name = 'Migration20200101033200_which_chat_message_an_uploaded_image_belongs'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'message_id', + `message_id INTEGER REFERENCES collab_messages(id) ON DELETE CASCADE`, + ); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_files_message_id ON trip_files(message_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033300_links_somebody_shared_with_the_trip.ts b/server/src/db/migrations/Migration20200101033300_links_somebody_shared_with_the_trip.ts new file mode 100644 index 000000000..2bf7d0256 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033300_links_somebody_shared_with_the_trip.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 213 (`db/migrations.ts`). + * + * Links somebody shared with the trip. + * Its own table rather than a note with a URL in it: a link is pinned, + * ordered and opened, and none of that is what a note does. `user_id` is who + * shared it, so the list can say so and so a member leaving takes their rows + * with them. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101033300_links_somebody_shared_with_the_trip extends Migration { + override name = 'Migration20200101033300_links_somebody_shared_with_the_trip'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + url TEXT NOT NULL, + pinned INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_links_trip ON collab_links(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033400_route_usage_counters.ts b/server/src/db/migrations/Migration20200101033400_route_usage_counters.ts new file mode 100644 index 000000000..6e3f6d4de --- /dev/null +++ b/server/src/db/migrations/Migration20200101033400_route_usage_counters.ts @@ -0,0 +1,36 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 214 (`db/migrations.ts`). + * + * Route usage counters — how much routing this instance really does. + * Daily aggregates, not a log: one row per day, profile, surface and engine + * kind, carrying totals. No query, no coordinate, no route, no user, no trip. + * The question they answer is whether TREK could host a router itself, and + * that needs volume, not itineraries. + * The table is created regardless of the switch, like the shadow log above: + * the switch decides whether rows are written, and a schema that appears only + * when a feature is on is a schema that differs between installs. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101033400_route_usage_counters extends Migration { + override name = 'Migration20200101033400_route_usage_counters'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS route_usage_daily ( + day TEXT NOT NULL, + profile TEXT NOT NULL, + surface TEXT NOT NULL, + self_hosted INTEGER NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + waypoints INTEGER NOT NULL DEFAULT 0, + km REAL NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, profile, surface, self_hosted) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_route_usage_day ON route_usage_daily(day)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033500_how_full_this_stop_fills_the_tank.ts b/server/src/db/migrations/Migration20200101033500_how_full_this_stop_fills_the_tank.ts new file mode 100644 index 000000000..3c3115f66 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033500_how_full_this_stop_fills_the_tank.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 215 (`db/migrations.ts`). + * + * How full THIS stop fills the tank, 1 to 100 (#1797). + * Beside stop_type rather than in the traveller's settings, because it is a property + * of the stop and not of the person: a motorway rapid charger gets 80 % because the + * last fifth would cost as long again, while the one at the hotel gets 100 % because + * the car stands there all night. One figure for the whole trip cannot say both, and + * the difference between them is a leg. + * NULL means "whatever the traveller's own setting says", which is every row that + * exists today and every stop nobody has an opinion about. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101033500_how_full_this_stop_fills_the_tank extends Migration { + override name = 'Migration20200101033500_how_full_this_stop_fills_the_tank'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'fill_percent', `fill_percent INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101033600_create_school_holiday_countries.ts b/server/src/db/migrations/Migration20200101033600_create_school_holiday_countries.ts new file mode 100644 index 000000000..f02f4d097 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033600_create_school_holiday_countries.ts @@ -0,0 +1,36 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 216 (`db/migrations.ts`). + */ +export class Migration20200101033600_create_school_holiday_countries extends Migration { + override name = 'Migration20200101033600_create_school_holiday_countries'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS school_holiday_countries ( + code TEXT PRIMARY KEY, name TEXT NOT NULL + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS school_holiday_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + country TEXT NOT NULL REFERENCES school_holiday_countries(code), + name TEXT NOT NULL COLLATE NOCASE, revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(country, name) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS school_holiday_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + region_id INTEGER NOT NULL REFERENCES school_holiday_regions(id) ON DELETE CASCADE, + name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL, + CHECK (end_date >= start_date) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_school_holiday_periods_region ON school_holiday_periods(region_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033700_day_assignments_add_end_day.ts b/server/src/db/migrations/Migration20200101033700_day_assignments_add_end_day.ts new file mode 100644 index 000000000..79a868080 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033700_day_assignments_add_end_day.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 217 (`db/migrations.ts`). + */ +export class Migration20200101033700_day_assignments_add_end_day extends Migration { + override name = 'Migration20200101033700_day_assignments_add_end_day'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'end_day', `end_day INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101033800_create_roadtrip_day_boundaries.ts b/server/src/db/migrations/Migration20200101033800_create_roadtrip_day_boundaries.ts new file mode 100644 index 000000000..bfff4f9ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101033800_create_roadtrip_day_boundaries.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 218 (`db/migrations.ts`). + */ +export class Migration20200101033800_create_roadtrip_day_boundaries extends Migration { + override name = 'Migration20200101033800_create_roadtrip_day_boundaries'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS roadtrip_day_boundaries ( + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL CHECK (day_number BETWEEN 1 AND 366), + from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), + PRIMARY KEY (trip_id, day_number) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101033900_create_roadtrip_preferences.ts b/server/src/db/migrations/Migration20200101033900_create_roadtrip_preferences.ts new file mode 100644 index 000000000..691c5e469 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033900_create_roadtrip_preferences.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; +import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared'; + +/** + * Legacy migration step 219 (`db/migrations.ts`). + * + * Per-trip road-trip preferences, seeded from whatever the trip owner had set + * globally so an existing trip keeps behaving the way it did. + */ +export class Migration20200101033900_create_roadtrip_preferences extends Migration { + override name = 'Migration20200101033900_create_roadtrip_preferences'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (trip_id, key))`, + ); + for (const key of ROADTRIP_PREFERENCE_KEYS) { + await this.execute( + `INSERT OR IGNORE INTO roadtrip_preferences (trip_id, key, value) SELECT t.id, s.key, s.value FROM trips t JOIN settings s ON s.user_id = t.user_id WHERE s.key = ? AND s.value IS NOT NULL`, + [key], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101034000_a_settle_up_payment_s_date_was.ts b/server/src/db/migrations/Migration20200101034000_a_settle_up_payment_s_date_was.ts new file mode 100644 index 000000000..01e3bd0a8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034000_a_settle_up_payment_s_date_was.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 220 (`db/migrations.ts`). + * + * A settle-up payment's date was silently `created_at` (when it was recorded), + * not editable like a regular expense's `expense_date`. Add the same split: + * settled_at is the calendar day the transfer actually happened, independent + * of when someone got around to logging it. NULL on legacy rows and rows + * whose caller didn't set it; the read side falls back to created_at's date. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101034000_a_settle_up_payment_s_date_was extends Migration { + override name = 'Migration20200101034000_a_settle_up_payment_s_date_was'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_settlements', 'settled_at', `settled_at TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034100_amap.ts b/server/src/db/migrations/Migration20200101034100_amap.ts new file mode 100644 index 000000000..5ff3e190b --- /dev/null +++ b/server/src/db/migrations/Migration20200101034100_amap.ts @@ -0,0 +1,29 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 221 (`db/migrations.ts`). + * + * Amap (高德) as a keyed places provider. + * + * `users.amap_api_key` has the same shape and role as maps_api_key: encrypted + * with apiKeyCrypto, and the last step of the resolver after the operator env + * var and the instance-wide app_settings row. Nullable with no default, because + * "this install does not use Amap" is the correct state for almost everybody. + * Nothing is backfilled: a Google key is not an Amap key, and the two are chosen + * by the places_provider setting, not by which column happens to be populated. + * + * `places.amap_poi_id` is the provider id a place was found by, beside + * google_place_id and osm_id. Amap ids are bare strings shaped like Google ones, + * so the column holds them with the `amap:` prefix the maps domain uses + * everywhere, and a place keeps opening against Amap after the admin switches + * provider. + */ +export class Migration20200101034100_amap extends Migration { + override name = 'Migration20200101034100_amap'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'amap_api_key', `amap_api_key TEXT`); + await addColumnIfMissing(this, 'places', 'amap_poi_id', `amap_poi_id TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034200_dawarich_connection.ts b/server/src/db/migrations/Migration20200101034200_dawarich_connection.ts new file mode 100644 index 000000000..af41085a3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034200_dawarich_connection.ts @@ -0,0 +1,33 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 222 (`db/migrations.ts`). + * + * Dawarich connection (#2279). Its own table rather than more `users` columns, + * which is where the AirTrail connection lives: that was the right call while + * AirTrail was the only integration of this shape, and this is the second. It + * also carries sync state (cursor, last error, probed capabilities) that has no + * business sitting on the identity row. Credentials for a personal location + * archive stay strictly apart from anything shared on a trip. + */ +export class Migration20200101034200_dawarich_connection extends Migration { + override name = 'Migration20200101034200_dawarich_connection'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS dawarich_connections ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + url TEXT, + api_key TEXT, + allow_insecure_tls INTEGER NOT NULL DEFAULT 0, + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_sync_at TEXT, + last_sync_state TEXT NOT NULL DEFAULT 'never', + last_sync_error TEXT, + capabilities TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101034300_reviewed_visit_suggestions.ts b/server/src/db/migrations/Migration20200101034300_reviewed_visit_suggestions.ts new file mode 100644 index 000000000..a523bedf6 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034300_reviewed_visit_suggestions.ts @@ -0,0 +1,58 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 223 (`db/migrations.ts`). + * + * Reviewed visit suggestions (#2279). One row per (user, Dawarich visit id) — + * the UNIQUE is what makes a repeated sync idempotent instead of duplicating + * everything it already imported. + * `source_hash` exists because a Dawarich visit carries no `updated_at`: a + * rename or a re-detection is only visible as a different hash of the fields + * TREK shows. `source_missing_at` exists because deleting a visit removes it + * from the API rather than tombstoning it, so absence from a full re-read of + * the same window is the only signal — and it is recorded, not acted on, + * because an entry the user already accepted and edited is theirs now. + */ +export class Migration20200101034300_reviewed_visit_suggestions extends Migration { + override name = 'Migration20200101034300_reviewed_visit_suggestions'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS dawarich_visit_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source_visit_id TEXT NOT NULL, + trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, + name TEXT NOT NULL, + lat REAL, + lng REAL, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + duration_minutes INTEGER NOT NULL DEFAULT 0, + local_date TEXT NOT NULL, + source_status TEXT NOT NULL DEFAULT 'suggested', + confidence REAL, + confidence_band TEXT, + country_code TEXT, + state TEXT NOT NULL DEFAULT 'new', + target TEXT, + accepted_place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + accepted_journal_entry_id INTEGER, + accepted_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, + matched_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, + source_hash TEXT NOT NULL, + accepted_hash TEXT, + source_missing_at TEXT, + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, source_visit_id) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_user_state ON dawarich_visit_suggestions(user_id, state)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_trip ON dawarich_visit_suggestions(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101034400_bucket_list_entries_can_now_be_ticked.ts b/server/src/db/migrations/Migration20200101034400_bucket_list_entries_can_now_be_ticked.ts new file mode 100644 index 000000000..dc95e0d36 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034400_bucket_list_entries_can_now_be_ticked.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 224 (`db/migrations.ts`). + * + * Bucket-list entries can now be ticked off (#2279). `visited_source` records + * who decided — a hand-ticked wish and one confirmed from a recording read the + * same on the map otherwise, and re-running a scan must not touch the first. + */ +export class Migration20200101034400_bucket_list_entries_can_now_be_ticked extends Migration { + override name = 'Migration20200101034400_bucket_list_entries_can_now_be_ticked'; + + override async up(): Promise { + await addColumnIfMissing(this, 'bucket_list', 'visited_at', `visited_at TEXT`); + await addColumnIfMissing(this, 'bucket_list', 'visited_source', `visited_source TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034500_where_an_atlas_country_came_from.ts b/server/src/db/migrations/Migration20200101034500_where_an_atlas_country_came_from.ts new file mode 100644 index 000000000..c081309f2 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034500_where_an_atlas_country_came_from.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 225 (`db/migrations.ts`). + * + * Where an Atlas country came from (#2279). Everything that exists today was + * marked by hand, so 'manual' is the correct backfill rather than a guess; + * countries confirmed out of a recording are written as 'dawarich' and the + * Atlas can say so. Unmarking still deletes the row either way. + */ +export class Migration20200101034500_where_an_atlas_country_came_from extends Migration { + override name = 'Migration20200101034500_where_an_atlas_country_came_from'; + + override async up(): Promise { + await addColumnIfMissing(this, 'visited_countries', 'source', `source TEXT NOT NULL DEFAULT 'manual'`); + } +} diff --git a/server/src/db/migrations/Migration20200101034600_per_key_read_scopes_for_api_v1.ts b/server/src/db/migrations/Migration20200101034600_per_key_read_scopes_for_api_v1.ts new file mode 100644 index 000000000..5269436ad --- /dev/null +++ b/server/src/db/migrations/Migration20200101034600_per_key_read_scopes_for_api_v1.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 226 (`db/migrations.ts`). + * + * Per-key read scopes for /api/v1 (#2279). The `include` parameter picks which + * sections a *response* carries; it has never restricted what a key may read, + * and handing an integration a key that reads every trip because it wanted day + * notes is the thing that needed fixing. + * + * `scope_mode` is an explicit flag rather than "NULL means everything": a + * sentinel here would mean a bug that drops the scopes column silently grants + * full access. Every existing key is 'all', so nothing that works today stops + * working — the restriction is opt-in at mint time. + */ +export class Migration20200101034600_per_key_read_scopes_for_api_v1 extends Migration { + override name = 'Migration20200101034600_per_key_read_scopes_for_api_v1'; + + override async up(): Promise { + await addColumnIfMissing(this, 'mcp_tokens', 'scope_mode', `scope_mode TEXT NOT NULL DEFAULT 'all'`); + await addColumnIfMissing(this, 'mcp_tokens', 'api_scopes', `api_scopes TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034700_where_a_place_came_from_when_it.ts b/server/src/db/migrations/Migration20200101034700_where_a_place_came_from_when_it.ts new file mode 100644 index 000000000..4a1435ee8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034700_where_a_place_came_from_when_it.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 227 (`db/migrations.ts`). + * + * Where a place came from, when it did not come from a person typing it + * (#2279). Only 'dawarich' writes it today; NULL is every place anyone has + * ever added by hand, which is what it should stay. + * A column rather than a lookup through dawarich_visit_suggestions: the mark + * has to survive the suggestion being deleted, and a place list would + * otherwise join an integration's table to render a name. + */ +export class Migration20200101034700_where_a_place_came_from_when_it extends Migration { + override name = 'Migration20200101034700_where_a_place_came_from_when_it'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'source', `source TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034800_provenance_for_a_day_stop_that_a.ts b/server/src/db/migrations/Migration20200101034800_provenance_for_a_day_stop_that_a.ts new file mode 100644 index 000000000..c03d60186 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034800_provenance_for_a_day_stop_that_a.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 228 (`db/migrations.ts`). + * + * Provenance for a day stop that a lodging booking put there rather than the + * traveller: it carries the stay's id, so moving or deleting the booking can + * move or delete exactly that stop and never one somebody placed by hand. + * Every row that already exists stays NULL: those were planned by hand, and a + * booking must not start claiming ownership of them. The step below adds the + * missing stops instead, which is a different thing from claiming old ones. + */ +export class Migration20200101034800_provenance_for_a_day_stop_that_a extends Migration { + override name = 'Migration20200101034800_provenance_for_a_day_stop_that_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'accommodation_id', `accommodation_id INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101034900_give_every_stay_booked_before_this_release.ts b/server/src/db/migrations/Migration20200101034900_give_every_stay_booked_before_this_release.ts new file mode 100644 index 000000000..394387b1f --- /dev/null +++ b/server/src/db/migrations/Migration20200101034900_give_every_stay_booked_before_this_release.ts @@ -0,0 +1,26 @@ +import { attachStayStopsToCheckInDay } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 229 (`db/migrations.ts`). + * + * Give every stay booked before this release the day stop it would get today. + * + * Road trip mode builds its drive out of day_assignments alone, so a hotel + * booked in Days mode was invisible there and the traveller had to add the same + * place a second time by hand. New bookings get the stop as they are written; + * without this step the fix would only ever apply to trips planned after the + * upgrade, and the trips people already have would stay broken. + * + * Skipped on purpose: a stay whose place is gone (place_id is ON DELETE SET + * NULL, and the booking form writes stays that never had one), and a place the + * traveller already planned for that day, whose row stays theirs and unmarked. + */ +export class Migration20200101034900_give_every_stay_booked_before_this_release extends Migration { + override name = 'Migration20200101034900_give_every_stay_booked_before_this_release'; + + override async up(): Promise { + const placed = await attachStayStopsToCheckInDay(this); + if (placed > 0) console.log(`[DB] Put ${placed} booked night(s) on their check-in day`); + } +} diff --git a/server/src/db/migrations/Migration20200101035000_the_same_sweep_once_more_for_the.ts b/server/src/db/migrations/Migration20200101035000_the_same_sweep_once_more_for_the.ts new file mode 100644 index 000000000..c74345154 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035000_the_same_sweep_once_more_for_the.ts @@ -0,0 +1,27 @@ +import { attachStayStopsToCheckInDay } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 230 (`db/migrations.ts`). + * + * The same sweep once more, for the nights the first one could not have seen. + * + * A migration runs while the old container is still answering: a booking written + * in those seconds is written by code that knows nothing about the day stop, and + * lands behind the sweep that would have given it one. One did, on the test + * instance, out of ten. There is nothing to be done about that window, but there + * is something to be done about what falls into it, and a booking that never got + * its stop is invisible to the drive with no way back short of saving it again. + * + * Safe to repeat: the same NOT EXISTS decides it, so a stay that already has its + * stop is passed over, and one whose place the traveller planned by hand keeps + * that row unclaimed. + */ +export class Migration20200101035000_the_same_sweep_once_more_for_the extends Migration { + override name = 'Migration20200101035000_the_same_sweep_once_more_for_the'; + + override async up(): Promise { + const placed = await attachStayStopsToCheckInDay(this); + if (placed > 0) console.log(`[DB] Caught up ${placed} booked night(s) missed during the upgrade`); + } +} diff --git a/server/src/db/migrations/Migration20200101035100_a_suggestion_the_traveller_has_waved_away.ts b/server/src/db/migrations/Migration20200101035100_a_suggestion_the_traveller_has_waved_away.ts new file mode 100644 index 000000000..94173bda8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035100_a_suggestion_the_traveller_has_waved_away.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 231 (`db/migrations.ts`). + * + * A suggestion the traveller has waved away. + * Skeletons are real rows, and the trip sync decides what to create by asking + * which source places already have one. Deleting a dismissed suggestion would + * therefore bring it straight back on the next sync. So it stays, marked, and + * drops out of every read instead — which also leaves a way back. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101035100_a_suggestion_the_traveller_has_waved_away extends Migration { + override name = 'Migration20200101035100_a_suggestion_the_traveller_has_waved_away'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'dismissed', `dismissed INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101035200_the_country_an_entry_happened_in_resolved.ts b/server/src/db/migrations/Migration20200101035200_the_country_an_entry_happened_in_resolved.ts new file mode 100644 index 000000000..cc2f92d7c --- /dev/null +++ b/server/src/db/migrations/Migration20200101035200_the_country_an_entry_happened_in_resolved.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 232 (`db/migrations.ts`). + * + * The country an entry happened in, resolved once from its coordinates. + * For the flag on the timeline card. Resolved on write rather than on read + * because the answer never changes and the polygon test should not run on + * every render of every entry. + */ +export class Migration20200101035200_the_country_an_entry_happened_in_resolved extends Migration { + override name = 'Migration20200101035200_the_country_an_entry_happened_in_resolved'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'country_code', `country_code TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101035300_which_of_the_optional_entry_fields_a.ts b/server/src/db/migrations/Migration20200101035300_which_of_the_optional_entry_fields_a.ts new file mode 100644 index 000000000..494f30d2e --- /dev/null +++ b/server/src/db/migrations/Migration20200101035300_which_of_the_optional_entry_fields_a.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 233 (`db/migrations.ts`). + * + * Which of the optional entry fields a journey uses. + * + * Mood, weather and the pros/cons list are the three things that make the editor + * feel like a form. Not everybody journals that way, and a journey kept by one + * person for their family should be able to put them away without the fields + * being taken from everybody else (discussion #2299). + * + * DEFAULT 1: every existing journey keeps all three, which is what it had. + */ +export class Migration20200101035300_which_of_the_optional_entry_fields_a extends Migration { + override name = 'Migration20200101035300_which_of_the_optional_entry_fields_a'; + + override async up(): Promise { + for (const column of ['show_verdict', 'show_mood', 'show_weather']) { + await addColumnIfMissing(this, 'journeys', column, `${column} INTEGER NOT NULL DEFAULT 1`); + } + } +} diff --git a/server/src/db/migrations/Migration20200101035400_let_go_of_a_booking_the_day.ts b/server/src/db/migrations/Migration20200101035400_let_go_of_a_booking_the_day.ts new file mode 100644 index 000000000..f809b0a53 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035400_let_go_of_a_booking_the_day.ts @@ -0,0 +1,39 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 234 (`db/migrations.ts`). + * + * Let go of a booking the day stop can no longer reach. + * day_assignments.accommodation_id was added as a bare INTEGER, and the only + * code that ever clears it looks the stay up by id. A stay can also vanish + * without anybody asking: day_accommodations.end_day_id is ON DELETE CASCADE, + * so shortening a trip past a booking's last night deletes the booking while + * the stop on its first night survives, now pointing at nothing. Days hides any + * stop that carries an accommodation_id, so the hotel drops out of the day list + * on every surface while the route still drives to it, and the id is + * AUTOINCREMENT, so nothing will ever come along and free the row. + * A trigger rather than a column rebuild: day_assignments is referenced by two + * cascading tables of its own, and SQLite fires this even when the stay went + * down with a foreign-key cascade. + */ +export class Migration20200101035400_let_go_of_a_booking_the_day extends Migration { + override name = 'Migration20200101035400_let_go_of_a_booking_the_day'; + + override up(): void { + this.addSql(` + UPDATE day_assignments SET accommodation_id = NULL + WHERE accommodation_id IS NOT NULL + AND accommodation_id NOT IN (SELECT id FROM day_accommodations) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_accommodation_id ON day_assignments(accommodation_id)`); + + this.addSql(` + CREATE TRIGGER IF NOT EXISTS trg_release_stop_on_stay_delete + AFTER DELETE ON day_accommodations + BEGIN + UPDATE day_assignments SET accommodation_id = NULL WHERE accommodation_id = OLD.id; + END + `); + } +} diff --git a/server/src/db/migrations/Migration20200101035500_a_journal_skeleton_belongs_to_a_day.ts b/server/src/db/migrations/Migration20200101035500_a_journal_skeleton_belongs_to_a_day.ts new file mode 100644 index 000000000..a7e8650db --- /dev/null +++ b/server/src/db/migrations/Migration20200101035500_a_journal_skeleton_belongs_to_a_day.ts @@ -0,0 +1,55 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 235 (`db/migrations.ts`). + * + * A journal skeleton belongs to a day, not just to a place (#2329). + * + * The sync engine keyed a skeleton by `source_place_id` alone, so the same place + * standing on two days — the city you land in at dusk and walk through the next + * morning, the hotel you sleep in three nights — produced one entry on the first + * of them and nothing on the rest. There was nowhere to put the second evening's + * photographs but the first evening's entry. + * + * The assignment row, not the day, is the missing half of that key: moving a + * stop to another day updates `day_assignments.day_id` in place, so keying on + * the assignment lets an entry follow the move the way it always has, while + * still telling two days of the same place apart. + * + * Deliberately no REFERENCES clause. Unassigning a stop has to leave the id + * stale rather than NULL, because reconciliation reads a key that no longer + * matches as "this stop left the plan" — which is what happened — and a NULL + * would be indistinguishable from a row this migration could not resolve. The id + * is safe to dangle: `day_assignments.id` is AUTOINCREMENT, so it is never + * handed out twice. + * + * Existing rows are backfilled to the place's earliest assignment, which is the + * one the old engine would have picked, so reconciliation matches what is + * already there instead of writing a duplicate beside it. + */ +export class Migration20200101035500_a_journal_skeleton_belongs_to_a_day extends Migration { + override name = 'Migration20200101035500_a_journal_skeleton_belongs_to_a_day'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'source_assignment_id', `source_assignment_id INTEGER`); + + // `source_assignment_id IS NULL` keeps the replay from re-resolving a row + // that has since followed its stop to another day. + await this.execute(` + UPDATE journey_entries + SET source_assignment_id = ( + SELECT da.id + FROM day_assignments da + JOIN days d ON d.id = da.day_id + WHERE da.place_id = journey_entries.source_place_id + ORDER BY d.day_number ASC, d.date ASC, da.order_index ASC, da.id ASC + LIMIT 1 + ) + WHERE source_place_id IS NOT NULL AND source_assignment_id IS NULL + `); + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_journey_entries_source_assignment ON journey_entries(source_place_id, source_assignment_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101035600_document_providers_part_1_of_3_the.ts b/server/src/db/migrations/Migration20200101035600_document_providers_part_1_of_3_the.ts new file mode 100644 index 000000000..aca1edbcd --- /dev/null +++ b/server/src/db/migrations/Migration20200101035600_document_providers_part_1_of_3_the.ts @@ -0,0 +1,69 @@ +import { DOCUMENT_PROVIDERS, DOCUMENT_PROVIDER_FIELDS } from '../document-provider-seed'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 236 (`db/migrations.ts`). + * + * Document providers, part 1 of 3: the registry (#214). + * + * Deliberately its own pair of tables rather than a `kind` column on + * `photo_providers`. The client filters on `type === 'photo_provider'`, the + * Journey cascade runs `UPDATE photo_providers SET enabled = 0` with no WHERE + * clause, and migrations are append-only. Reusing those tables would change the + * behaviour of three existing paths, which is exactly what the + * no-breaking-changes rule forbids. The field columns follow + * `photo_provider_fields` except for `settings_key` and `payload_key`. Those map + * a photo field onto a settings key and a request key; a document field is + * stored under its own `field_key` in one JSON column, and the connect form + * takes its fields from /docsync/providers rather than from the generic settings + * form, so nothing would ever read them. + * + * The rows come from the same constants `seeds.ts` uses, so a fresh install and + * an upgraded one agree. + */ +export class Migration20200101035600_document_providers_part_1_of_3_the extends Migration { + override name = 'Migration20200101035600_document_providers_part_1_of_3_the'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS document_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT DEFAULT 'FileText', + enabled INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0 + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS document_provider_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + label TEXT NOT NULL, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + hint TEXT, + required INTEGER DEFAULT 0, + secret INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + UNIQUE(provider_id, field_key) + ) + `); + + for (const p of DOCUMENT_PROVIDERS) { + await this.execute( + `INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)`, + [p.id, p.name, p.description, p.icon, p.sort_order], + ); + } + for (const f of DOCUMENT_PROVIDER_FIELDS) { + await this.execute( + `INSERT OR IGNORE INTO document_provider_fields + (provider_id, field_key, label, input_type, placeholder, hint, required, secret, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.sort_order], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101035700_document_providers_part_2_of_3_the.ts b/server/src/db/migrations/Migration20200101035700_document_providers_part_2_of_3_the.ts new file mode 100644 index 000000000..19c8c43e7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035700_document_providers_part_2_of_3_the.ts @@ -0,0 +1,93 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 237 (`db/migrations.ts`). + * + * Document providers, part 2 of 3: the connection, and the trip binding. + * The connection carries a `trip_id`, and that is the one place this design + * departs from every integration already in the repo. Immich, Synology + * Photos, AirTrail and Dawarich all hang off a user, and even + * `trip_album_links` carries a `user_id`; photos become visible to the rest + * of a trip only through an opt-in `shared` flag. None of that can satisfy + * "everyone on the trip sees the same documents": it would make a + * document's visibility depend on whose credentials fetched it. So the trip + * admin binds the trip once, the server talks to the provider under that + * single identity, and TREK's own membership decides who sees what. + * `owner_user_id` stays separate from `trip_id` so the credential holder is + * always explicit: when that person leaves the trip the binding goes to + * `orphaned` rather than silently continuing to use an ex-member's token. + * Secrets live in one encrypted JSON blob instead of per-provider columns: + * a sixth provider then needs no migration, and the key rotation in + * scripts/migrate-encryption.ts stays one line instead of a field list that + * someone will forget, and a forgotten column does not survive a rotation. + */ +export class Migration20200101035700_document_providers_part_2_of_3_the extends Migration { + override name = 'Migration20200101035700_document_providers_part_2_of_3_the'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS document_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + base_url TEXT NOT NULL, + secrets TEXT, + settings TEXT NOT NULL DEFAULT '{}', + allow_insecure_tls INTEGER NOT NULL DEFAULT 0, + capabilities TEXT, + last_probe_at TEXT, + last_probe_state TEXT NOT NULL DEFAULT 'never', + last_probe_error TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, provider_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_document_connections_trip ON document_connections(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_document_connections_owner ON document_connections(owner_user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_document_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + connection_id INTEGER NOT NULL REFERENCES document_connections(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL, + remote_scope_key TEXT NOT NULL, + remote_root_id TEXT, + remote_root_path TEXT, + remote_label TEXT NOT NULL DEFAULT '', + direction TEXT NOT NULL DEFAULT 'both', + delete_policy TEXT NOT NULL DEFAULT 'unlink', + conflict_policy TEXT NOT NULL DEFAULT 'manual', + sync_enabled INTEGER NOT NULL DEFAULT 1, + webhook_token TEXT, + webhook_secret TEXT, + webhook_subscription_id TEXT, + remote_cursor TEXT, + last_sync_at TEXT, + last_sync_state TEXT NOT NULL DEFAULT 'never', + last_sync_error TEXT, + failure_count INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, connection_id, remote_scope_key) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_document_links_trip ON trip_document_links(trip_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_trip_document_links_due ON trip_document_links(sync_enabled, next_attempt_at)`, + ); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_trip_document_links_token + ON trip_document_links(webhook_token) WHERE webhook_token IS NOT NULL + `); + } +} diff --git a/server/src/db/migrations/Migration20200101035800_document_providers_part_3_of_3_the.ts b/server/src/db/migrations/Migration20200101035800_document_providers_part_3_of_3_the.ts new file mode 100644 index 000000000..e957e2a00 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035800_document_providers_part_3_of_3_the.ts @@ -0,0 +1,74 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 238 (`db/migrations.ts`). + * + * Document providers, part 3 of 3: the pairing and its sync state. + * `content_sha256` and `pushed_sha256` are two columns on purpose. The + * first is the bytes both sides last agreed on, the second is what TREK + * itself last uploaded. Collapsing them into one is precisely the mistake + * that builds an echo loop: a webhook fires for TREK's own write, the core + * cannot tell it from a stranger's edit, and the file bounces. + * `remote_missing_at` records that something vanished upstream instead of + * acting on it, the rule Dawarich already follows with `source_missing_at`. + * An unmounted share answers with an empty listing, and reading that as + * "everything was deleted" would empty a trip. + * `file_id ON DELETE SET NULL` keeps a tombstone behind after a document is + * permanently deleted in TREK, so the next run does not cheerfully download + * it again. + */ +export class Migration20200101035800_document_providers_part_3_of_3_the extends Migration { + override name = 'Migration20200101035800_document_providers_part_3_of_3_the'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS document_sync_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + link_id INTEGER NOT NULL REFERENCES trip_document_links(id) ON DELETE CASCADE, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES trip_files(id) ON DELETE SET NULL, + trek_doc_uid TEXT NOT NULL, + remote_id TEXT, + remote_name TEXT, + remote_version TEXT, + remote_size INTEGER, + remote_modified_at TEXT, + content_sha256 TEXT, + pushed_sha256 TEXT, + state TEXT NOT NULL DEFAULT 'pending', + error_code TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT, + remote_missing_at TEXT, + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + synced_at TEXT + ) + `); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_remote + ON document_sync_items(link_id, remote_id) WHERE remote_id IS NOT NULL + `); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_file + ON document_sync_items(link_id, file_id) WHERE file_id IS NOT NULL + `); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_uid + ON document_sync_items(link_id, trek_doc_uid) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_document_sync_items_trip_state ON document_sync_items(trip_id, state)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_document_sync_items_hash ON document_sync_items(link_id, content_sha256)`, + ); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_document_sync_items_due ON document_sync_items(link_id, next_attempt_at)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101035900_document_providers_when_trek_itself_put_a.ts b/server/src/db/migrations/Migration20200101035900_document_providers_when_trek_itself_put_a.ts new file mode 100644 index 000000000..a7f21dc09 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035900_document_providers_when_trek_itself_put_a.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 239 (`db/migrations.ts`). + * + * Document providers: when TREK itself put a provider copy in the bin. + * + * A file deleted in TREK under the `trash` policy takes its provider copy with + * it. Taken back out of TREK's trash, it has to go up again; a copy somebody + * else deleted in the meantime must not. Both leave the same gap in a listing, + * so the difference is written down when TREK acts rather than guessed at later. + * + * No backfill from the binding's current policy: that policy may not be the one + * the deletion ran under, and reading it back is the retroactive mistake this + * column exists to avoid. A row left NULL is treated like a copy somebody else + * deleted, which flags it instead of uploading it. + */ +export class Migration20200101035900_document_providers_when_trek_itself_put_a extends Migration { + override name = 'Migration20200101035900_document_providers_when_trek_itself_put_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'document_sync_items', 'remote_trashed_at', `remote_trashed_at TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101040000_trips_longer_than_a_year_lost_every.ts b/server/src/db/migrations/Migration20200101040000_trips_longer_than_a_year_lost_every.ts new file mode 100644 index 000000000..a71a694e9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040000_trips_longer_than_a_year_lost_every.ts @@ -0,0 +1,74 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** A trip whose day rows were clipped at the old 365-day limit. */ +interface ClippedTrip { + id: number; + start_date: string; + end_date: string; + span: number; +} + +/** The date `n` days after `start`, as YYYY-MM-DD. */ +function dayAfter(start: string, n: number): string { + const [y, m, d] = start.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d) + n * 86400000).toISOString().slice(0, 10); +} + +/** + * Legacy migration step 240 (`db/migrations.ts`). + * + * Trips longer than a year lost every day past the 365th: generateDays clipped + * the day rows at the old limit while the trip kept its full end date, so the + * last days had a date but nothing to plan on (#2403). The limit is 999 now, and + * this gives the affected trips their missing days. + * + * Only a range whose dated days still run unbroken from the start date is + * extended; a trip that was re-dated by hand or lost a day in the middle is left + * as it is. Dateless days that still hold content stay behind the dated ones, + * where generateDays keeps them. The two-phase renumbering is the same dance + * generateDays does around UNIQUE(trip_id, day_number). + */ +export class Migration20200101040000_trips_longer_than_a_year_lost_every extends Migration { + override name = 'Migration20200101040000_trips_longer_than_a_year_lost_every'; + + override async up(): Promise { + const trips = (await this.execute(` + SELECT id, start_date, end_date, + CAST(julianday(end_date) - julianday(start_date) + 1 AS INTEGER) AS span + FROM trips + WHERE start_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + AND end_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + AND julianday(end_date) - julianday(start_date) + 1 BETWEEN 366 AND 999 + `)) as ClippedTrip[]; + + for (const trip of trips) { + const rows = (await this.execute(`SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number`, [ + trip.id, + ])) as { id: number; date: string | null }[]; + + const dated = rows.filter((r) => r.date); + if (dated.length >= trip.span) continue; + if (dated.some((r, i) => r.date !== dayAfter(trip.start_date, i))) continue; + const dateless = rows.filter((r) => !r.date); + + // Phase one: park every row on a negative number so the renumbering below + // cannot collide with UNIQUE(trip_id, day_number). + for (const [i, r] of rows.entries()) { + await this.execute(`UPDATE days SET day_number = ? WHERE id = ?`, [-(i + 1), r.id]); + } + for (const [i, r] of dated.entries()) { + await this.execute(`UPDATE days SET day_number = ? WHERE id = ?`, [i + 1, r.id]); + } + for (let i = dated.length; i < trip.span; i++) { + await this.execute(`INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)`, [ + trip.id, + i + 1, + dayAfter(trip.start_date, i), + ]); + } + for (const [i, r] of dateless.entries()) { + await this.execute(`UPDATE days SET day_number = ? WHERE id = ?`, [trip.span + i + 1, r.id]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts b/server/src/db/migrations/Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts new file mode 100644 index 000000000..384993a6b --- /dev/null +++ b/server/src/db/migrations/Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts @@ -0,0 +1,35 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 241 (`db/migrations.ts`). + * + * The road-trip day boundaries carried the old trip limit as a CHECK on + * day_number. The limit lives in the contract now (MAX_TRIP_DAYS), so the + * table keeps only the floor. SQLite cannot alter a CHECK, hence the + * rebuild; nothing references the table, so the rows are simply copied. + */ +export class Migration20200101040100_the_road_trip_day_boundaries_carried_the extends Migration { + override name = 'Migration20200101040100_the_road_trip_day_boundaries_carried_the'; + + override up(): void { + this.addSql(` + CREATE TABLE roadtrip_day_boundaries_new ( + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL CHECK (day_number >= 1), + from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), + PRIMARY KEY (trip_id, day_number) + ) + `); + + this.addSql(` + INSERT INTO roadtrip_day_boundaries_new + SELECT trip_id, day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries + `); + + this.addSql(`DROP TABLE roadtrip_day_boundaries`); + + this.addSql(`ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries`); + } +} diff --git a/server/src/db/migrations/Migration20200101040200_trek_photo_cache_meta_cache_key_not_null.ts b/server/src/db/migrations/Migration20200101040200_trek_photo_cache_meta_cache_key_not_null.ts new file mode 100644 index 000000000..57d11840b --- /dev/null +++ b/server/src/db/migrations/Migration20200101040200_trek_photo_cache_meta_cache_key_not_null.ts @@ -0,0 +1,41 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Plan 4 Task 8a program carry — `trek_photo_cache_meta.cache_key` was + * declared a bare `TEXT PRIMARY KEY` (Migration20200101014900), which SQLite + * does NOT implicitly make `NOT NULL` the way it does for an `INTEGER + * PRIMARY KEY` rowid alias — a genuine, if surprising, SQLite quirk (3e's R5 + * Task 0 finding). Every statement that writes this column + * (`TrekPhotoCacheMetaRepository.upsertMeta`'s `INSERT OR REPLACE`, its only + * writer) has always bound a real `string`, never `null` — this migration + * tightens the schema to match the code's actual invariant, not a behaviour + * change. SQLite has no `ALTER COLUMN ... SET NOT NULL`, so the column is + * added via the standard rebuild-table recipe (the same shape + * Migration20200101020000 uses in the opposite direction). + */ +export class Migration20200101040200_trek_photo_cache_meta_cache_key_not_null extends Migration { + override name = 'Migration20200101040200_trek_photo_cache_meta_cache_key_not_null'; + + override up(): void { + this.addSql(` + CREATE TABLE trek_photo_cache_meta_new ( + cache_key TEXT PRIMARY KEY NOT NULL, + content_type TEXT NOT NULL DEFAULT 'image/jpeg', + fetched_at INTEGER NOT NULL + ) + `); + + this.addSql(` + INSERT INTO trek_photo_cache_meta_new + SELECT cache_key, content_type, fetched_at + FROM trek_photo_cache_meta + WHERE cache_key IS NOT NULL + `); + + this.addSql(`DROP TABLE trek_photo_cache_meta`); + + this.addSql(`ALTER TABLE trek_photo_cache_meta_new RENAME TO trek_photo_cache_meta`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101040300_a_booked_night_finally_took_the_seat_a_new.ts b/server/src/db/migrations/Migration20200101040300_a_booked_night_finally_took_the_seat_a_new.ts new file mode 100644 index 000000000..bac62e0f7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040300_a_booked_night_finally_took_the_seat_a_new.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations'; +import type { SqliteConnection } from '@mikro-orm/sqlite'; +import { reseatBookedNights } from '../reseat-booked-nights'; + +/** + * Legacy migration step 242 (`db/migrations.ts`). + * + * Seat every booked night where its check-in says, the way a night booked + * today is seated (`night-seat.ts`'s `seatAmong`, which + * `AccommodationsService` applies) — see `reseat-booked-nights.ts`'s own + * header for the full rule. Every trip planned before that ordering change + * keeps the old, wrong order until somebody edits its check-in; this brings + * them forward the same way step 229/230 (`Migration20200101034900`) brought + * the day stop itself forward for older bookings. + * + * Plan 4 Task 5c's finding: unlike ruling 7's assumption, nothing in the + * numbered chain ever called `reseatBookedNights` — the two migrations near + * the end that touch booked nights both call `attachStayStopsToCheckInDay` + * instead (a different legacy step). This is the numbered migration that was + * missing; an install upgrading through the chain without it never got this + * one-time reorder. + * + * Frozen, called rather than reimplemented (ruling 7's spirit: don't rewrite + * a step onto the ORM's query builder, since that risks producing a + * different result than the one already shipped). `reseatBookedNights` wants + * the synchronous better-sqlite3 API directly, not `this.execute()`, so this + * reaches it through `getNativeClient()` — the same physical connection/ + * transaction `this.addSql()` would use, so it still runs inside the + * migration's transaction exactly as the function's own docstring assumes. + */ +export class Migration20200101040300_a_booked_night_finally_took_the_seat_a_new extends Migration { + override name = 'Migration20200101040300_a_booked_night_finally_took_the_seat_a_new'; + + override async up(): Promise { + const connection = this.driver.getConnection() as SqliteConnection; + const db = await connection.getNativeClient(); + const seated = reseatBookedNights(db); + if (seated > 0) console.log(`[DB] Reseated ${seated} booked night(s) to their check-in day`); + } +} diff --git a/server/src/db/migrations/Migration20200101040400_immich_learns_the_switch_synology_airtrail_and.ts b/server/src/db/migrations/Migration20200101040400_immich_learns_the_switch_synology_airtrail_and.ts new file mode 100644 index 000000000..d402552ae --- /dev/null +++ b/server/src/db/migrations/Migration20200101040400_immich_learns_the_switch_synology_airtrail_and.ts @@ -0,0 +1,39 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 243 (`db/migrations.ts`). + * + * Immich learns the switch Synology, AirTrail and Dawarich already have: a + * server behind a self-signed certificate can be trusted per user (#2475). + * Off by default, and only 1 counts as on. + * + * The two settings rows are the toggle for it and the auto-upload toggle + * step 113 (`Migration20200101015300`) meant to add. That one only ran + * where the Immich provider row already existed, which on a fresh install it + * never did (the seeders run after the migrations), so fresh installs never + * showed the upload toggle. Both rows are in `PhotoProviderSeeder` as well for + * the same reason; here they reach the installs that already have the provider + * row. Re-runnable. + */ +export class Migration20200101040400_immich_learns_the_switch_synology_airtrail_and extends Migration { + override name = 'Migration20200101040400_immich_learns_the_switch_synology_airtrail_and'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'users', + 'immich_allow_insecure_tls', + `immich_allow_insecure_tls INTEGER NOT NULL DEFAULT 0`, + ); + const immich = await this.execute(`SELECT 1 AS present FROM photo_providers WHERE id = 'immich' LIMIT 1`); + if (immich.length === 0) return; + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) + VALUES + ('immich', 'immich_allow_insecure_tls', 'skipSSLVerification', 'checkbox', NULL, NULL, 0, 0, 'allow_insecure_tls', 'allow_insecure_tls', 2), + ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, NULL, 0, 0, 'auto_upload', 'auto_upload', 5) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101040500_a_place_that_moves_takes_its_atlas.ts b/server/src/db/migrations/Migration20200101040500_a_place_that_moves_takes_its_atlas.ts new file mode 100644 index 000000000..dab03538b --- /dev/null +++ b/server/src/db/migrations/Migration20200101040500_a_place_that_moves_takes_its_atlas.ts @@ -0,0 +1,32 @@ +import { createPlaceRegionsFollowPlaceTrigger } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 244 (`db/migrations.ts`). + * + * A place that moves takes its Atlas country with it (#2527). + * + * place_regions caches the country and region Atlas resolved from a place's + * coordinates and address, and nothing re-derives a row that is already there. + * Correcting a place's location left the old row in charge, so Atlas, the + * dashboard stats and the journey stats kept counting the country the place had + * just left. + * + * A trigger rather than a delete in the places code, because the row is derived + * from the place and every writer of lat, lng or address has to let go of it: + * the place editor, update_place, the plugin RPC, the import enrichment and its + * address backfill, and whatever comes next. The WHEN clause matters because + * every place edit writes lat, lng and address back whether they changed or + * not, and renaming a place must not throw away a good row. The next Atlas load + * resolves the place where it is now. + * + * This is 244 on main (4.3.3) as well, so it sits here, ahead of Web Push. + * Step 246 (`Migration20200101040700`) creates the same trigger once more. + */ +export class Migration20200101040500_a_place_that_moves_takes_its_atlas extends Migration { + override name = 'Migration20200101040500_a_place_that_moves_takes_its_atlas'; + + override async up(): Promise { + await createPlaceRegionsFollowPlaceTrigger(this); + } +} diff --git a/server/src/db/migrations/Migration20200101040600_web_push_one_row_per_browser_a.ts b/server/src/db/migrations/Migration20200101040600_web_push_one_row_per_browser_a.ts new file mode 100644 index 000000000..2d1fd672b --- /dev/null +++ b/server/src/db/migrations/Migration20200101040600_web_push_one_row_per_browser_a.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 245 (`db/migrations.ts`). + * + * Web Push (#894): one row per browser a user switched push on in. + * + * The endpoint is the push service URL that browser handed out, so it is + * unique: a browser shared by two accounts belongs to whoever subscribed on it + * last. p256dh and auth are the browser's keys for the RFC 8291 message + * encryption. vapid_public_key is the server key the subscription was made + * against, so a changed key pair shows up as a mismatch the sender can clean + * up, rather than as a push service refusing every message. failure_count and + * last_success_at are bookkeeping for the sender; the rows go with the user. + * + * Web Push was 244 before the #2527 trigger took that slot to match main. A + * 4.3.3 install is already at 244 with the trigger and gets the table here; an + * instance that ran this at 244 replays it as a no-op. + */ +export class Migration20200101040600_web_push_one_row_per_browser_a extends Migration { + override name = 'Migration20200101040600_web_push_one_row_per_browser_a'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + vapid_public_key TEXT NOT NULL, + user_agent TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_success_at TEXT, + failure_count INTEGER NOT NULL DEFAULT 0 + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101040700_the_2527_trigger_once_more_for_an.ts b/server/src/db/migrations/Migration20200101040700_the_2527_trigger_once_more_for_an.ts new file mode 100644 index 000000000..2eb177264 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040700_the_2527_trigger_once_more_for_an.ts @@ -0,0 +1,18 @@ +import { createPlaceRegionsFollowPlaceTrigger } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 246 (`db/migrations.ts`). + * + * The #2527 trigger once more (step 244, `Migration20200101040500`), for an + * instance that ran Web Push at 244 before the trigger took that slot. Such an + * instance never runs 244 again, so it gets the trigger here. Everywhere else it + * already exists and this is a no-op. + */ +export class Migration20200101040700_the_2527_trigger_once_more_for_an extends Migration { + override name = 'Migration20200101040700_the_2527_trigger_once_more_for_an'; + + override async up(): Promise { + await createPlaceRegionsFollowPlaceTrigger(this); + } +} diff --git a/server/src/db/migrations/Migration20200101040800_a_stop_kept_on_the_day_but.ts b/server/src/db/migrations/Migration20200101040800_a_stop_kept_on_the_day_but.ts new file mode 100644 index 000000000..e15201ec9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040800_a_stop_kept_on_the_day_but.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 247 (`db/migrations.ts`). + * + * A stop kept on the day but left out of its route (#2532): shown as a pin, not + * driven to. 0 routes it like every stop before this existed. + */ +export class Migration20200101040800_a_stop_kept_on_the_day_but extends Migration { + override name = 'Migration20200101040800_a_stop_kept_on_the_day_but'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'route_excluded', `route_excluded INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101040900_how_many_of_an_item_are_packed.ts b/server/src/db/migrations/Migration20200101040900_how_many_of_an_item_are_packed.ts new file mode 100644 index 000000000..30a49a271 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040900_how_many_of_an_item_are_packed.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 248 (`db/migrations.ts`). + * + * How many of an item are packed so far (#2296), for "7 of 10 shirts". NULL is + * the plain checkbox every item has had, and `checked` stays the answer to + * "done?". + */ +export class Migration20200101040900_how_many_of_an_item_are_packed extends Migration { + override name = 'Migration20200101040900_how_many_of_an_item_are_packed'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'packed_quantity', `packed_quantity INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101041000_a_bucket_list_wish_for_one_state.ts b/server/src/db/migrations/Migration20200101041000_a_bucket_list_wish_for_one_state.ts new file mode 100644 index 000000000..6a03f9aca --- /dev/null +++ b/server/src/db/migrations/Migration20200101041000_a_bucket_list_wish_for_one_state.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 249 (`db/migrations.ts`). + * + * A bucket-list wish for one state or province rather than the whole country + * (#1901). NULL keeps every existing wish a country wish. + */ +export class Migration20200101041000_a_bucket_list_wish_for_one_state extends Migration { + override name = 'Migration20200101041000_a_bucket_list_wish_for_one_state'; + + override async up(): Promise { + await addColumnIfMissing(this, 'bucket_list', 'region_code', `region_code TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101041100_half_company_holidays_0_5_covers_the.ts b/server/src/db/migrations/Migration20200101041100_half_company_holidays_0_5_covers_the.ts new file mode 100644 index 000000000..66564df31 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041100_half_company_holidays_0_5_covers_the.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 250 (`db/migrations.ts`). + * + * Half company holidays (#2439): 0.5 covers the morning or afternoon and leaves + * the other half open for a half vacation day. 1 is what every holiday was. + */ +export class Migration20200101041100_half_company_holidays_0_5_covers_the extends Migration { + override name = 'Migration20200101041100_half_company_holidays_0_5_covers_the'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_company_holidays', 'fraction', `fraction REAL NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101041200_a_journey_entry_still_being_written.ts b/server/src/db/migrations/Migration20200101041200_a_journey_entry_still_being_written.ts new file mode 100644 index 000000000..86cf6ae75 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041200_a_journey_entry_still_being_written.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 251 (`db/migrations.ts`). + * + * A journey entry still being written (#696): contributors see it, the public + * share page does not. Its own flag, since `visibility` defaults to 'private' on + * every entry and filtering on it would empty every shared journey. + */ +export class Migration20200101041200_a_journey_entry_still_being_written extends Migration { + override name = 'Migration20200101041200_a_journey_entry_still_being_written'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'is_draft', `is_draft INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101041300_two_narrower_ways_to_share_a_trip.ts b/server/src/db/migrations/Migration20200101041300_two_narrower_ways_to_share_a_trip.ts new file mode 100644 index 000000000..ac80d740c --- /dev/null +++ b/server/src/db/migrations/Migration20200101041300_two_narrower_ways_to_share_a_trip.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 252 (`db/migrations.ts`). + * + * Two narrower ways to share a trip (#1712): only the travel (flights, trains, + * stays) without the day's activities, and without the place photos. Both off + * keeps every existing link exactly as it was. + */ +export class Migration20200101041300_two_narrower_ways_to_share_a_trip extends Migration { + override name = 'Migration20200101041300_two_narrower_ways_to_share_a_trip'; + + override async up(): Promise { + await addColumnIfMissing(this, 'share_tokens', 'share_travel_only', `share_travel_only INTEGER NOT NULL DEFAULT 0`); + await addColumnIfMissing(this, 'share_tokens', 'share_hide_images', `share_hide_images INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101041400_a_note_on_a_settle_up_payment.ts b/server/src/db/migrations/Migration20200101041400_a_note_on_a_settle_up_payment.ts new file mode 100644 index 000000000..a03175ae5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041400_a_note_on_a_settle_up_payment.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 253 (`db/migrations.ts`). + * + * A note on a settle-up payment (#2340), as expenses already have one: "paid + * back in cash at the airport". Null on every existing payment. + */ +export class Migration20200101041400_a_note_on_a_settle_up_payment extends Migration { + override name = 'Migration20200101041400_a_note_on_a_settle_up_payment'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_settlements', 'note', `note TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101041500_google_api_calls_per_utc_day.ts b/server/src/db/migrations/Migration20200101041500_google_api_calls_per_utc_day.ts new file mode 100644 index 000000000..28bf008ab --- /dev/null +++ b/server/src/db/migrations/Migration20200101041500_google_api_calls_per_utc_day.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 254 (`db/migrations.ts`). + * + * Google API calls per UTC day (#1582), so the admin's daily ceiling has + * something to count against. One row a day, nothing about who searched. + */ +export class Migration20200101041500_google_api_calls_per_utc_day extends Migration { + override name = 'Migration20200101041500_google_api_calls_per_utc_day'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS google_api_usage (day TEXT PRIMARY KEY, calls INTEGER NOT NULL DEFAULT 0)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101041600_packing_templates_remember_what_an_item_weighs.ts b/server/src/db/migrations/Migration20200101041600_packing_templates_remember_what_an_item_weighs.ts new file mode 100644 index 000000000..b82ecf561 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041600_packing_templates_remember_what_an_item_weighs.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 255 (`db/migrations.ts`). + * + * Packing templates remember what an item weighs, how many of it go along and + * which bag it lives in (#1131), so applying one does not mean typing all of + * that in again. The bag is kept by name: bags belong to a trip. + */ +export class Migration20200101041600_packing_templates_remember_what_an_item_weighs extends Migration { + override name = 'Migration20200101041600_packing_templates_remember_what_an_item_weighs'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_template_items', 'weight_grams', `weight_grams INTEGER`); + await addColumnIfMissing(this, 'packing_template_items', 'quantity', `quantity INTEGER NOT NULL DEFAULT 1`); + await addColumnIfMissing(this, 'packing_template_items', 'bag_name', `bag_name TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101041700_a_journey_s_state_as_its_owner.ts b/server/src/db/migrations/Migration20200101041700_a_journey_s_state_as_its_owner.ts new file mode 100644 index 000000000..61b04b718 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041700_a_journey_s_state_as_its_owner.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 256 (`db/migrations.ts`). + * + * A journey's state as its owner sets it (#762): draft, live or completed. NULL + * keeps the state derived from the linked trips' dates, which is what every + * journey had so far; a journey with no trip could only ever be a draft. + */ +export class Migration20200101041700_a_journey_s_state_as_its_owner extends Migration { + override name = 'Migration20200101041700_a_journey_s_state_as_its_owner'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journeys', 'status_override', `status_override TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101041800_a_place_s_e_mail_and_its.ts b/server/src/db/migrations/Migration20200101041800_a_place_s_e_mail_and_its.ts new file mode 100644 index 000000000..a2d8f376b --- /dev/null +++ b/server/src/db/migrations/Migration20200101041800_a_place_s_e_mail_and_its.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 257 (`db/migrations.ts`). + * + * A place's e-mail and its own opening hours, typed in by hand (#2472): the + * search fills in what it knows, and these are for everything it does not. + */ +export class Migration20200101041800_a_place_s_e_mail_and_its extends Migration { + override name = 'Migration20200101041800_a_place_s_e_mail_and_its'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'email', `email TEXT`); + await addColumnIfMissing(this, 'places', 'opening_hours', `opening_hours TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101041900_a_journey_that_puts_an_entry_on.ts b/server/src/db/migrations/Migration20200101041900_a_journey_that_puts_an_entry_on.ts new file mode 100644 index 000000000..372588c51 --- /dev/null +++ b/server/src/db/migrations/Migration20200101041900_a_journey_that_puts_an_entry_on.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 258 (`db/migrations.ts`). + * + * A journey that puts an entry on the map where its first photo was taken + * (#1003). Off unless the owner turns it on: an entry somebody placed by hand is + * never moved, but an entry left without a place on purpose should not grow one + * either just because a picture was added. + */ +export class Migration20200101041900_a_journey_that_puts_an_entry_on extends Migration { + override name = 'Migration20200101041900_a_journey_that_puts_an_entry_on'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journeys', 'photo_location', `photo_location INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101042000_tours.ts b/server/src/db/migrations/Migration20200101042000_tours.ts new file mode 100644 index 000000000..585a65e51 --- /dev/null +++ b/server/src/db/migrations/Migration20200101042000_tours.ts @@ -0,0 +1,71 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Tours: a facet on `places` for hikes and other single-day routes. + * + * `tours` is keyed on the owning place, so deleting the place drops the facet + * and, through it, the ordered `tour_waypoints` the route editor saved. + * `tour_types` is the controlled vocabulary the facet points at; the `hike` + * row is reference data that FK needs, so it is written here rather than left + * to a seeder. + * + * Every statement is guarded: a test instance that ran the pre-ORM Tours + * branch already holds these tables and its rows must survive. That branch + * also created `idx_tour_waypoints_place`, which duplicates the autoindex + * behind `UNIQUE(place_id, sequence)`, so it is dropped to converge on the + * fresh-install shape. + */ +export class Migration20200101042000_tours extends Migration { + override name = 'Migration20200101042000_tours'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS tour_types ( + key TEXT PRIMARY KEY, + label_key TEXT NOT NULL, + icon TEXT NOT NULL, + color TEXT NOT NULL, + routing_profile TEXT, + is_sport INTEGER NOT NULL DEFAULT 1, + enabled INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + + this.addSql(` + INSERT OR IGNORE INTO tour_types (key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order) + VALUES ('hike', 'tourTypes.hike', 'Mountain', '#16a34a', 'pedestrian', 1, 1, 0) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS tours ( + place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, + tour_type TEXT NOT NULL REFERENCES tour_types(key), + distance REAL, + elevation_gain REAL, + elevation_loss REAL, + duration REAL, + difficulty TEXT, + wanderer_ref TEXT, + match_confidence REAL, + tour_group_id INTEGER, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + max_hiking_difficulty INTEGER NOT NULL DEFAULT 2 CHECK(max_hiking_difficulty BETWEEN 1 AND 6) + ) + `); + this.addSql('CREATE INDEX IF NOT EXISTS idx_tours_tour_type ON tours(tour_type)'); + + this.addSql(` + CREATE TABLE IF NOT EXISTS tour_waypoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES tours(place_id) ON DELETE CASCADE, + lat REAL NOT NULL CHECK(lat >= -90 AND lat <= 90), + lng REAL NOT NULL CHECK(lng >= -180 AND lng <= 180), + role TEXT NOT NULL CHECK(role IN ('start', 'via', 'end')), + sequence INTEGER NOT NULL CHECK(sequence >= 0), + UNIQUE(place_id, sequence) + ) + `); + this.addSql('DROP INDEX IF EXISTS idx_tour_waypoints_place'); + } +} diff --git a/server/src/db/nul-safe-sqlite-platform.ts b/server/src/db/nul-safe-sqlite-platform.ts new file mode 100644 index 000000000..bdc080ab4 --- /dev/null +++ b/server/src/db/nul-safe-sqlite-platform.ts @@ -0,0 +1,109 @@ +import { SqlitePlatform } from '@mikro-orm/sql'; + +/** + * Program rule 22 (`docs/superpowers/plans/2026-09-21-orm-migration-program.md`), + * surfaced by Plan 3c Task 4 review M1: MikroORM 7 never sends the driver a + * parameterised statement. `AbstractSqlConnection.execute` calls + * `platform.formatQuery(sql, params)` (`@mikro-orm/core`'s `Platform.formatQuery`) + * and hands better-sqlite3 the fully INLINED SQL text — `formatQuery` walks the + * `?` placeholders and splices in `this.quoteValue(param)` for each one, so + * every bound value this ORM ever writes is a literal in the statement string, + * not a bound parameter the driver escapes separately. `SqlitePlatform`'s own + * `quoteValue` (`@mikro-orm/sql`'s `AbstractSqlPlatform.quoteValue`, for + * anything that isn't a `Date`) delegates to `escape`, which wraps a string in + * single quotes and doubles embedded `'` — the standard SQL literal shape. + * + * A `\u0000` inside that string survives the JS-level quoting untouched (it + * is not `'`, so nothing escapes it), but the C string this literal eventually + * becomes IS NUL-terminated: SQLite's own tokenizer reads the statement text + * as a NUL-terminated buffer, so it stops scanning at the embedded `\0` — + * inside the still-open `'…'` literal, which is exactly why the failure mode + * is always "unterminated string", never anything an attacker could turn into + * an injection (verified in the Task 4 review: every NUL statement in this + * class fails closed with a syntax error, 500, never a different query). + * + * The legacy raw-SQL layer never had this problem: `better-sqlite3` binds `?` + * parameters as real, separately-transmitted values (no inlining, no NUL + * truncation), so a NUL byte in a user string round-tripped byte-for-byte — + * `GET /api/trips/:id/places?search=%00` was 200 and a place named + * `"nul\u0000name"` created (201) with its name intact. Once a route's + * statement moves off `DatabaseService`'s raw bind onto anything this ORM + * inlines (a QB read, `em.insert`, `nativeUpdate` — every one of them, + * regardless of domain), that same input 500s instead, byte-for-byte across + * every domain: this is corollary to rule 22, not specific to any one plan. + * + * The fix restores the round-trip AT THE PLATFORM, once, program-wide: a + * string containing `\u0000` renders not as one `'…'` literal but as a + * parenthesised `||` (SQL string concatenation) chain, splicing in SQLite's + * builtin `char(0)` — which returns a one-character string holding the NUL + * codepoint — everywhere the original string held one. `char()` is a core + * SQLite function (no extension, no `PRAGMA`), and `||` binds tighter than + * every arithmetic/comparison/logical operator that could otherwise appear + * around a bound value, so `(...)`'s parens are redundant for a scalar + * comparison — kept anyway so this expression composes safely as an + * IN-list element or the operand of anything else `escape()`'s caller + * splices it into (`AbstractSqlPlatform.escape` recurses through arrays + * with `value.map(v => this.escape(v)).join(', ')`, so a NUL inside one + * array element must stay a self-contained expression). Splitting on + * `\u0000` and re-quoting each piece with the platform's own `escape` (via + * `super.escape`) reuses the exact same `'`-doubling every non-NUL string + * already goes through, so a NUL next to a quote or a backslash (neither + * of which SQLite string literals treat specially) round-trips exactly as + * it did before this override existed. + * + * Deliberately scoped to `typeof value === 'string'` for the NUL half above: + * that IS the M1 fix, not a general re-implementation of `escape`/ + * `quoteValue`. + * + * **Rule 22 extension (Plan 3c Task 9, close-out review A H1):** non-finite + * numbers hit the SAME inlining mechanism, and rule 21's `toRowId` guards do + * NOT fence every caller off from it — `toRowId` is what a CONVERTED + * service calls to validate an id BEFORE it reaches a repository, but two + * call sites (`PlacesService.verifyTripAccess`, `RealtimeGateway.handleJoin`) + * still did `Number(untrusted)` with no finite check and handed the result + * straight to `TripsRepository.findAccessible`'s raw `andWhere('t.id = ?', + * [trip_id])` bind. Traced against the installed `@mikro-orm/sql` 7.2.1 + * source: `AbstractSqlPlatform.quoteValue` (`sql/AbstractSqlPlatform.js:94`) + * special-cases only a raw fragment and a plain object (JSON-stringified); + * everything else — including every `number` — falls to `this.escape(value)`. + * `SqlitePlatform.escape` (`sql/dialects/sqlite/SqlitePlatform.js:116-133`) + * DOES special-case numbers, but not usefully: `typeof value === 'number' || + * typeof value === 'bigint'` returns `'' + value` — plain string + * concatenation, which for a non-finite value gives `'NaN'`, `'Infinity'` or + * `'-Infinity'`, an UNQUOTED bareword spliced straight into the SQL text + * (finite numbers render as an ordinary numeric literal this way and are + * fine; only the three non-finite values break). SQLite's parser reads that + * bareword as a column reference (`no such column: NaN`/`Infinity`) and + * throws, a 500 where the legacy raw bind never had the problem: + * `better-sqlite3` accepts a JS `number` directly and binds it as SQLite's + * own `REAL`/`NULL` value, never as text — verified empirically against the + * installed driver (not assumed): `db.prepare('select typeof(?)').get(NaN)` + * reports `'null'` (better-sqlite3 refuses `NaN` as a bindable REAL and + * silently binds SQL `NULL` instead), while `db.prepare('select + * typeof(?)').get(Infinity)` reports `'real'` and round-trips as the REAL + * value `Infinity` — SQLite itself has no `Infinity` literal, but its + * `REAL` parser saturates an out-of-range exponent to positive/negative + * infinity (double-precision overflow), and `9e999`/`-9e999` verified to + * produce that exact value (`select 9e999` → `Infinity`), so it is the + * literal spelling that reproduces the driver's own bound value byte-for- + * byte, not an approximation. Overriding `escape` (not `quoteValue`, which + * only special-cases a raw fragment/plain object before delegating here + * anyway) catches every number this platform ever renders, program-wide, + * the same fail-safe shape as the NUL fix above — rule 21's `toRowId` + * guards remain the PRIMARY defence (a validated id never reaches this + * branch), and this is the platform's own backstop for the callers that do + * not yet guard. + */ +export class NulSafeSqlitePlatform extends SqlitePlatform { + override escape(value: unknown): string { + if (typeof value === 'string' && value.includes('\u0000')) { + const literals = value.split('\u0000').map(part => super.escape(part)); + return `(${literals.join(' || char(0) || ')})`; + } + if (typeof value === 'number' && !Number.isFinite(value)) { + if (Number.isNaN(value)) return 'NULL'; + return value > 0 ? '9e999' : '-9e999'; + } + return super.escape(value); + } +} diff --git a/server/src/db/orm-driver.ts b/server/src/db/orm-driver.ts new file mode 100644 index 000000000..7dcda9f7c --- /dev/null +++ b/server/src/db/orm-driver.ts @@ -0,0 +1,101 @@ +import { getRawConnection } from './database'; +import type { Configuration } from '@mikro-orm/core'; +import type { SqlitePlatform } from '@mikro-orm/sql'; +import { SqliteConnection, SqliteDriver } from '@mikro-orm/sqlite'; +import { NulSafeSqlitePlatform } from './nul-safe-sqlite-platform'; + +import type Database from 'better-sqlite3'; +import { SqliteDialect, SqliteDriver as KyselySqliteDriver, type Dialect, type SqliteDialectConfig } from 'kysely'; + +/** + * The stock Kysely SQLite driver's `destroy()` unconditionally does + * `db.close()`. MikroORM's own `close()` (`AbstractSqlConnection.close()`) + * always calls the Kysely client's `destroy()` too, regardless of the `force` + * flag it was given, and it caches that client behind a private field — so a + * `SqliteConnection` subclass has no hook to intercept just that one call. + * The fix has to live at the Kysely layer: hand MikroORM a driver identical to + * the stock one except `destroy()` is a no-op, so closing the ORM never closes + * a handle somebody else owns. + */ +class NonClosingSqliteDriver extends KyselySqliteDriver { + override async destroy(): Promise { + // The handle's owner (whoever `getHandle` below reads from) closes it. + } +} + +/** + * A SQLite driver bound to a better-sqlite3 handle somebody else owns. + * + * Forced by the test harness: `database.ts` hands each vitest worker + * `:memory:`, and in better-sqlite3 an in-memory database belongs to its + * *connection* — two connections to `:memory:` are two unrelated databases. + * MikroORM's stock `SqliteConnection.createKyselyDialect()` always does + * `new Database(dbName)`, so it would migrate a database nothing else in the + * process can see. Sharing the handle also keeps the single-writer property the + * app has always had: one handle, one WAL. + * + * `getHandle` is called on every (re)connect rather than once: a backup restore + * closes the handle and opens a new one, and Kysely caches whatever it is given + * for the life of its client, so `reinitialize()` closes the connection and the + * next `connect()` lands here again with the new handle. + */ +export function createBoundSqliteDriver(getHandle: () => Database.Database): typeof SqliteDriver { + class BoundSqliteConnection extends SqliteConnection { + override createKyselyDialect(): Dialect { + const handle = getHandle(); + const config: SqliteDialectConfig = { database: handle }; + // `SqliteConnection.getNativeClient()` reads this private field, but only + // ever sets it from the base class's own `createKyselyDialect()` — which + // this override replaces entirely, so it never runs. Capturing it here + // too is what lets a migration reach this exact synchronous + // better-sqlite3 handle via `getNativeClient()` in production, not just + // under the unbound test driver (`createMigrationOrm()` in + // tests/helpers/migration-step.ts, whose stock SqliteConnection sets it + // unmodified). Needed by migrations that call a frozen, hand-written + // step directly instead of through `this.execute()` — e.g. + // Migration20200101040300, which calls reseat-booked-nights.ts. + (this as unknown as { database: Database.Database }).database = handle; + // Delegate the compiler/adapter/introspector to a throwaway stock dialect + // (stateless factories); only `createDriver()` needs the non-closing swap. + const stock = new SqliteDialect(config); + return { + createDriver: () => new NonClosingSqliteDriver(config), + createQueryCompiler: () => stock.createQueryCompiler(), + createAdapter: () => stock.createAdapter(), + createIntrospector: (db) => stock.createIntrospector(db), + }; + } + } + + return class BoundSqliteDriver extends SqliteDriver { + constructor(config: Configuration) { + super(config); + // The base constructor already built a stock SqliteConnection; replace it + // before anything connects. `connection` is protected on AbstractSqlDriver + // and SqliteDriver's constructor hardcodes the class, so there is no + // supported hook for supplying it. + (this as unknown as { connection: SqliteConnection }).connection = new BoundSqliteConnection(config); + // Program rule 22 (NulSafeSqlitePlatform's own docstring): `SqliteDriver`'s + // constructor hardcodes `new SqlitePlatform()` too, with no config hook to + // supply a different one. `DatabaseDriver.platform` is a plain, reassignable + // field — `DatabaseDriver.setMetadata()` (called once, during + // `MikroORM.init()`, always after this constructor returns) is what actually + // propagates it to the connection via `connection.setPlatform(this.platform)`, + // so replacing it here, synchronously, is in time for every later read. + (this as unknown as { platform: SqlitePlatform }).platform = new NulSafeSqlitePlatform(); + } + }; +} + +/** + * The production driver: bound to the connection `db/database.ts` owns. + * + * Wrapped in a closure rather than passed as `getRawConnection` directly: + * `createBoundSqliteDriver` doesn't call its argument until something actually + * connects, but merely *reading* the `getRawConnection` binding here would — + * this module is evaluated at import time, and a test that mocks + * `db/database` with a partial replacement (most do, via `buildDbMock` or a + * hand-rolled object) throws on that read even when the test never touches + * the database at all. + */ +export const SharedSqliteDriver = createBoundSqliteDriver(() => getRawConnection()); diff --git a/server/src/db/orm.ts b/server/src/db/orm.ts new file mode 100644 index 000000000..d744febce --- /dev/null +++ b/server/src/db/orm.ts @@ -0,0 +1,104 @@ +import mikroOrmConfig from '../mikro-orm.config'; +import { registerReinitializeHook, runDemoSeed } from './database'; +import { migrateToHead } from './legacy-baseline'; +import { withRequestContext } from '../nest/database/request-context'; +import type { AnyEntity, EntityClass, EntityManager, EntitySchema, IDatabaseDriver, MikroORM } from '@mikro-orm/core'; +import type { Migrator } from '@mikro-orm/migrations'; +import { MikroORM as SqliteMikroORM } from '@mikro-orm/sqlite'; + +/** + * Schema ownership at startup. + * + * This replaces `createTables()` → `runMigrations()` → `runSeeds()`, which used + * to run synchronously inside `database.ts::initDb()` at module load. The + * migrator is async, so the work moved to `buildApp()`, which is the first place + * in the boot with an await and still runs before anything reads the database + * (`bootstrap.ts` resolves SettingsService right after). + */ + +/** + * Whatever MikroORM instance this process holds. + * + * Widened on the entity list because Nest's `MikroORM` token resolves to the + * class's own `readonly` form while a direct `init()` yields the mutable one; + * nothing here reads that parameter. + */ +type AnyOrm = MikroORM< + IDatabaseDriver, + EntityManager, + readonly (string | EntityClass | EntitySchema)[] +>; + +/** Migrate to head, then seed. Safe to call again — both halves are idempotent. */ +export async function runSchemaBootstrap(orm: AnyOrm): Promise { + const migrator = orm.config.getExtension('@mikro-orm/migrator') as Migrator; + // A database the retired positional runner migrated has no migrator rows yet; + // what it already has is recorded in the same transaction as the run, or the + // run would replay the whole history over it (see legacy-baseline.ts). Every + // other database just gets its pending migrations. + await migrateToHead(orm.em.getConnection(), migrator); + + const defaultSeeder = orm.config.get('seeder').defaultSeeder; + if (defaultSeeder) await orm.seeder.seedString(defaultSeeder); + + // Demo data depends on the seeded categories (hard-coded ids under an FK), so + // it can only run once the seeders above have. + // + // Plan 3c Task 0b (R9): wrapping the call HERE, the one place + // `runSchemaBootstrap` already has `orm` in scope, covers BOTH of + // `runDemoSeed`'s real call sites at once (`bootstrap.ts`'s initial boot + // and `attachOrm`'s restore hook below, both of which only ever reach + // `runDemoSeed` through this function) with no second wrapper to keep in + // sync. + // + // Plan 3i Task 3: `runDemoSeed` (and `seedDemoData` beneath it, converted + // onto `DemoRepository`) is `async` now — this call is awaited so a + // rejection surfaces here rather than becoming an unhandled promise + // rejection, and so the seed genuinely completes before + // `runSchemaBootstrap` (and therefore `buildApp()`/this hook's own + // restore path) returns to its caller, the same completion guarantee the + // fully-synchronous version had by construction. + await withRequestContext(orm, () => runDemoSeed()); +} + +/** + * Binds the ORM to this process's connection lifecycle. + * + * A restore swaps the SQLite file and reopens the raw handle; Kysely caches + * whatever handle it was given, so without this the ORM would keep talking to a + * closed connection. Closing and reconnecting sends the driver back through + * `createKyselyDialect()`, which picks up the new handle — and the restored file + * may be an older backup, so it gets migrated forward too. + */ +export function attachOrm(orm: AnyOrm): void { + registerReinitializeHook(async () => { + const connection = orm.em.getConnection(); + // The handle is owned and closed by `database.ts`; the bound driver's + // `destroy()` is a no-op (see `orm-driver.ts`), so this close never reaches it. + await connection.close(true); + await connection.connect(); + await runSchemaBootstrap(orm); + }); +} + +/** + * An ORM instance outside Nest, for the vitest global setup that builds the + * schema snapshot. Production uses the container-owned one from + * `MikroOrmModule.forRoot()` instead of creating a second. + */ +export function createStandaloneOrm(): Promise { + return SqliteMikroORM.init({ + ...mikroOrmConfig, + // Entity discovery is skipped deliberately. Migrations and seeders are raw + // SQL and need no metadata, and the global setup that calls this runs as ESM + // in vitest's main process, where discovering `*.entity.ts` fails on Node's + // extensionless import resolution. + entities: [], + entitiesTs: [], + discovery: { warnWhenNoEntities: false }, + // The migrator's per-migration progress log is production boot output; the + // global setup already reports the snapshot in one line of its own, and a + // failing migration still throws. + migrations: { ...mikroOrmConfig.migrations, silent: true }, + }); +} diff --git a/server/src/db/repositories/Addons.repository.ts b/server/src/db/repositories/Addons.repository.ts new file mode 100644 index 000000000..ff490979f --- /dev/null +++ b/server/src/db/repositories/Addons.repository.ts @@ -0,0 +1,93 @@ +import type { Addons, AddonConfig } from '../entities/Addons.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** An `addons` row as the API emits it. */ +export interface AddonRow { + id: string | null; + name: string; + description: string | null; + type: string; + icon: string | null; + enabled: boolean | null; + config: AddonConfig | null; + sort_order: number | null; +} + +const _addonRowKeys: AssertRowKeys = true; + +export class AddonsRepository extends TrekRepository { + /** + * `SELECT enabled FROM addons WHERE id = ?` + * + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — `_shared/trek-repository.ts`; supersedes Plan 3a's I1 + * "`refresh: true` on every PK-only `findOne`" — see + * `Users.repository.ts`'s class-level docstring and + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): a + * throwaway forked context always sees a write on the same id inside the + * same request (`admin.service.ts`'s still-raw `UPDATE addons SET enabled + * = ?` on the same connection), and the entity never lands in the + * request's identity map. + */ + async isEnabled(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['enabled'] }); + return !!row?.enabled; + } + + /** + * `SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 + * ORDER BY sort_order` — this reads every scalar column (not just the + * legacy statement's five), because `AddonsService.list()` already + * explicitly picks the five fields it forwards to the client; narrowing the + * query itself would need a second, `AssertRowKeys`-incompatible row type + * for no parity benefit. + */ + async listEnabled(): Promise { + const rows = await this.find({ enabled: true }, { orderBy: { sort_order: 'asc' } }); + return rows.map((row) => toRow(row) as AddonRow); + } + + // --------------------------------------------------------------------- + // Plan 3i Task 1 (`AdminService` — AD26/AD30/AD33/AD36/AD39, admin's + // addon-shelf listing + config/enabled writes). + // --------------------------------------------------------------------- + + /** + * AD26 (`admin.service.ts#listAddons`) — `SELECT * FROM addons ORDER BY + * sort_order, id`, UNFILTERED (unlike {@link listEnabled}'s `WHERE + * enabled = 1`) — admin's own catalog listing, filtered afterward in JS + * for managed-instance hiding. + */ + async listAllOrdered(): Promise { + const rows = await this.find({}, { orderBy: { sort_order: 'asc', id: 'asc' } }); + return rows.map((row) => toRow(row) as AddonRow); + } + + /** AD30/AD39 (`admin.service.ts#updateAddon`'s pre-write read and post-TX re-select, byte-identical text at both sites) — `SELECT * FROM addons WHERE id = ?`. */ + async findById(id: string): Promise { + const row = await this.findOne({ id }); + return row ? (toRow(row) as AddonRow) : null; + } + + /** AD33 (`admin.service.ts#updateAddon`, inside `uow.transactional`) — `UPDATE addons SET enabled = ? WHERE id = ?`. */ + async setEnabled(id: string, enabled: boolean): Promise { + await this.nativeUpdate({ id }, { enabled: !!enabled }); + } + + /** + * AD36 (`admin.service.ts#updateAddon`, inside `uow.transactional`) — + * `UPDATE addons SET config = ? WHERE id = ?`. The LLM-parsing addon's API + * key is already encrypted by `AdminService` + * (`prepareLlmAddonConfigForWrite`) before this call — this repository + * never touches the crypto, only persists whatever JSON blob it is + * handed (3e R6's encrypted-column precedent: the service encrypts, the + * repository passes the ciphertext through untouched). `config` is a + * `p.json()` column — MikroORM serializes the object to the stored TEXT + * column itself, the same JSON text the legacy statement's hand-called + * `JSON.stringify` produced. + */ + async setConfig(id: string, config: AddonConfig): Promise { + await this.nativeUpdate({ id }, { config }); + } +} diff --git a/server/src/db/repositories/AppSettings.repository.ts b/server/src/db/repositories/AppSettings.repository.ts new file mode 100644 index 000000000..025efd893 --- /dev/null +++ b/server/src/db/repositories/AppSettings.repository.ts @@ -0,0 +1,124 @@ +import type { AppSettings } from '../entities/AppSettings.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** An `app_settings` row as the API emits it. */ +export interface AppSettingsRow { + key: string | null; + value: string | null; +} + +const _appSettingsRowKeys: AssertRowKeys = true; + +export class AppSettingsRepository extends TrekRepository { + /** + * `SELECT value FROM app_settings WHERE key = ?` + * + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — `_shared/trek-repository.ts`; supersedes Plan 3a's I1 + * "`refresh: true` on every PK-only `findOne`" — see + * `Users.repository.ts`'s class-level docstring for the full mechanism and + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): the read + * is answered from a throwaway forked context that is cleared afterwards, + * so a raw/native write or `deleteValue` on the same key inside the same + * request is always seen, and the entity never lands in the request's + * identity map to become a stale pending change at a later `flush()`. + */ + async getValue(key: string): Promise { + const row = await this.findOne({ key }, { fields: ['value'] }); + return row?.value ?? null; + } + + /** + * `SELECT key, value FROM app_settings WHERE key IN (?, ?, ...)` — a + * NULL-valued row is silently dropped from the returned Map, matching the + * legacy callers, which never distinguished "row present with a NULL + * value" from "no row" (both read as absent). + */ + async getValues(keys: string[]): Promise> { + const rows = await this.find({ key: { $in: keys } }); + const values = new Map(); + for (const row of rows) { + if (row.key != null && row.value != null) values.set(row.key, row.value); + } + return values; + } + + /** + * Upsert ruling: `app_settings` (`Migration20200101000000_baseline_schema.ts`) + * is `CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT)` and no + * later migration adds a column or a timestamp — the table has never carried + * anything beyond `key`/`value`. Every legacy call site across settings, + * addons and permissions is either `INSERT OR REPLACE INTO app_settings + * (key, value) VALUES (?, ?)` or `INSERT INTO app_settings (key, value) + * VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value` — + * with no other column whose reset by REPLACE could be observable, both + * spellings are exactly `em.upsert`'s job, with the sole unique field + * (`key`, the primary key) as the conflict target. + * + * The one difference from `INSERT OR REPLACE` — verified, unobservable + * here — is the row's implicit SQLite rowid: REPLACE deletes and + * reinserts (rowid moves), `em.upsert`'s `ON CONFLICT ... DO UPDATE` + * keeps the original row (rowid stays). `app_settings` has no `INTEGER + * PRIMARY KEY`, no `AUTOINCREMENT`, no trigger and no foreign key + * referencing it, so nothing here reads the rowid — but the next table + * converted off `INSERT OR REPLACE` that does have one of those needs its + * own check, not an assumption from this comment. + */ + async setValue(key: string, value: string): Promise { + await this.upsert({ key, value }, { onConflictFields: ['key'], onConflictAction: 'merge' }); + } + + /** + * `SELECT key, value FROM app_settings WHERE key LIKE ?` — the caller + * passes the bare prefix; this appends the trailing `%` itself, matching + * every legacy call site (`'perm_%'`, `'default_user_setting_%'`). + * + * Escaping: neither the legacy `LIKE 'prefix%'` literals nor `$like`'s + * parameter binding here escape `%`/`_` in the prefix — both are plain + * SQL LIKE, where a bound parameter's `%`/`_` are wildcards exactly like a + * literal's are. The repository test proves the two behave identically for + * a prefix that itself contains `%`/`_`. + */ + async findByKeyPrefix(prefix: string): Promise { + const rows = await this.find({ key: { $like: `${prefix}%` } }); + return rows.map((row) => toRow(row) as AppSettingsRow); + } + + /** `DELETE FROM app_settings WHERE key = ?` */ + async deleteValue(key: string): Promise { + return this.nativeDelete({ key }); + } + + /** + * `SELECT COUNT(*) AS n FROM app_settings WHERE key IN (?, ?, ...)` (Plan + * 3i, storage's SR1 — `StorageRegistryService#seedFromFileOnce`'s + * seed-once boot guard). Counts every ROW whose key matches, including one + * whose `value` is NULL — unlike `getValues()`, which drops a NULL-valued + * row from its Map, this mirrors the legacy statement exactly: no WHERE on + * `value`, so a present-but-NULL row still counts as "already seeded." + */ + async countKeysPresent(keys: string[]): Promise { + return this.count({ key: { $in: keys } }); + } + + /** + * `INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)` (Plan + * 3i, storage's SS1 — `StorageStatsService#scan`). The SAME "one row per + * key" write as `setValue()`'s `ON CONFLICT(key) DO UPDATE` upsert, but a + * genuinely DIFFERENT legacy SQL text (R4/R5's ruling: `app_settings` has + * two distinct upsert dialects across this program's converted call + * sites — preserve both as separately-named methods, never merged into one + * with a boolean/dialect flag, even where — as here — their observable + * behavior on this table is identical). Per `setValue()`'s own docstring, + * the only real difference between the two dialects (REPLACE deletes and + * reinserts, moving the SQLite rowid; `ON CONFLICT ... DO UPDATE` keeps the + * original row) is unobservable on `app_settings` (no `INTEGER PRIMARY + * KEY`/`AUTOINCREMENT`/trigger/FK references it) — this method exists so + * the repository's public surface keeps naming the legacy text 1:1, not + * because the two need different runtime behavior on this table. + */ + async upsertOrReplace(key: string, value: string): Promise { + await this.upsert({ key, value }, { onConflictFields: ['key'], onConflictAction: 'merge' }); + } +} diff --git a/server/src/db/repositories/AssignmentParticipants.repository.ts b/server/src/db/repositories/AssignmentParticipants.repository.ts new file mode 100644 index 000000000..983cdd695 --- /dev/null +++ b/server/src/db/repositories/AssignmentParticipants.repository.ts @@ -0,0 +1,162 @@ +import type { AssignmentParticipants } from '../entities/AssignmentParticipants.entity'; +import { coalesce, columnRef } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** One assignment's participant, from `QueryHelpersService.loadParticipantsByAssignmentIds` (QH3). */ +export interface ParticipantForAssignmentRow { + assignment_id: number; + user_id: number; + username: string; + avatar: string | null; +} + +/** + * One assignment's participant, from AS2/AS15/AS31 + * (`AssignmentsService.getAssignmentWithPlace`/`getParticipants`/ + * `setParticipants`) — narrower than `ParticipantForAssignmentRow` above + * (no `assignment_id` — the legacy statement never selects it) and with + * `COALESCE(display_name, username)` folded into `username`. + */ +export interface ParticipantRow { + user_id: number; + username: string; + avatar: string | null; +} + +export class AssignmentParticipantsRepository extends TrekRepository { + /** + * `SELECT ap.assignment_id, ap.user_id, u.username, u.avatar + * FROM assignment_participants ap JOIN users u ON ap.user_id = u.id + * WHERE ap.assignment_id IN (${…})` (`query-helpers.service.ts:83-86`, QH3). + * + * Deliberately **no** `COALESCE(display_name, username)` — unlike + * `AssignmentsService.getParticipants` (AS15), which does. The two + * participant projections genuinely disagree on the wire (plan3c inventory + * §1 QH3) and must stay that way; do not "fix" this one to match AS15. + * + * Empty `assignmentIds` short-circuits before any query, matching the + * legacy service method. + */ + async listForAssignments(assignmentIds: number[]): Promise { + if (assignmentIds.length === 0) return []; + // `ap.assignment` selects the plain FK scalar as `assignment_id` (never + // separately joined here). `ap.user` IS separately joined (to `u`), so + // its id is pulled explicitly off the join instead (`u.id as user_id`) + // — see `PlaceRatingsRepository.listForPlaces`'s docstring for why + // selecting a relation property that is ALSO an active join's target + // renames the column onto the joined entity's own primary key instead. + return this.qb('ap') + .join('ap.user', 'u') + .select(['ap.assignment', 'u.id as user_id', 'u.username', 'u.avatar']) + .where({ 'ap.assignment': { $in: assignmentIds } }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 3 (`AssignmentsService`) — appended after Task 1's own + // `listForAssignments` above, per this task's coordination note (this file + // may already carry Task 1's uncommitted method; both are kept, and the + // whole file is committed under Task 3's message, noted in its report). + // --------------------------------------------------------------------------- + + /** + * AS2/AS15/AS31 (`AssignmentsService.getAssignmentWithPlace`/ + * `getParticipants`/`setParticipants`'s post-write re-select) — `SELECT + * ap.user_id, COALESCE(u.display_name, u.username) AS username, u.avatar + * FROM assignment_participants ap JOIN users u ON ap.user_id = u.id WHERE + * ap.assignment_id = ?`. A SEPARATE method from `listForAssignments` + * (QH3) above — the inventory's §18.10 finding: the two participant + * projections genuinely disagree on the wire (this one COALESCEs + * `display_name` over `username`, QH3 does not) and must stay that way, + * never unified into one "shared row shaper". Also a NARROWER column set + * than QH3's own row: the legacy AS2/AS15/AS31 statement never selects + * `assignment_id` at all (unlike QH3, which does) — `ParticipantRow` below + * is deliberately its own type, not `ParticipantForAssignmentRow`, so a + * caller can never accidentally see a key this statement doesn't produce. + */ + async listWithDisplayName(assignment_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.qb('ap') + .join('ap.user', 'u') + .select(['u.id as user_id', coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.avatar']) + .where({ 'ap.assignment': assignment_id }) + .execute('all', false); + } + + /** AS29 — `DELETE FROM assignment_participants WHERE assignment_id = ?`. */ + async deleteForAssignment(assignment_id: number): Promise { + await this.nativeDelete({ assignment: assignment_id }); + } + + /** + * AS30 — `INSERT OR IGNORE INTO assignment_participants (assignment_id, + * user_id) VALUES (?, ?)`, run once per id. `upsertMany` with + * `onConflictAction: 'ignore'` on the table's real unique key + * (`UNIQUE(assignment_id, user_id)`, + * `Migration20200101002400_create_assignment_participants` — verified + * against the migration, not assumed), named by entity property + * (`assignment`/`user`, `OauthConsentsRepository.upsertGrant`'s precedent + * for a relation-backed `onConflictFields` list). The caller only ever + * passes a non-empty, already-roster-scoped array; the empty-array guard + * here is defensive, matching the service's own guard. + * + * **Two differences from the legacy per-row loop (Task 3 review M1, + * absorbed in Task 4), both benign today, documented so they stay that + * way on purpose:** + * 1. **Narrower failure mode.** `OR IGNORE` swallowed *any* constraint + * violation on a row; `ON CONFLICT (assignment_id, user_id) DO + * NOTHING` swallows only THAT unique index, and one row that violates + * a DIFFERENT constraint aborts the whole multi-row statement rather + * than being skipped. Unreachable today — AS28 roster-scopes the ids + * before this call, and the caller has already gated the assignment + * itself — but a concurrent delete of the assignment mid-request would + * now 500 where the legacy loop silently no-opped that one row. + * 2. **One extra statement.** `upsertMany` issues a reload `SELECT` after + * the insert (`generated SQL, captured: insert … on conflict (…) do + * nothing returning id` then `select … from assignment_participants + * as a0 where (…)`) that the legacy loop never ran. Harmless — the + * reloaded rows are fully hydrated and unchanged, so the enclosing + * `em.transactional`'s closing flush emits nothing for them — but it + * is a genuine extra round-trip and briefly populates the identity map + * inside the write transaction, worth knowing if a future change here + * ever needs to reason about statement counts. + */ + async insertIgnore(assignment_id: number, user_ids: number[]): Promise { + if (user_ids.length === 0) return; + await this.upsertMany( + user_ids.map((user_id) => ({ assignment: assignment_id, user: user_id })), + { onConflictFields: ['assignment', 'user'], onConflictAction: 'ignore' }, + ); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP53 (`trips.service.ts::copy`'s participants read) — `SELECT ap.* FROM + * assignment_participants ap JOIN day_assignments da ON da.id = + * ap.assignment_id JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?`, + * the two-hop join scoping participant rows to a trip. Only `assignment_id`/ + * `user_id` are returned — the legacy `ap.*`'s own `id` column is never + * used by the copy loop, only the two FK values it re-inserts (TP54, via + * {@link insertIgnore} above, once per row). + * + * `ap.assignment` is ALSO the target of this query's own `.join('ap.assignment', + * 'da')` — selecting it bare here would resolve through the join and emit + * `da__id`, not the FK scalar (`DayAssignmentsRepository + * .assignmentWithPlaceSelect`'s `category_id` docstring documents the same + * trap) — `columnRef` selects the literal `assignment_id` column instead. + * `ap.user` is NOT separately joined here, so it selects fine as `user_id` + * (`TripsRepository.getOwnerId`'s `'t.user'` precedent). + */ + async listForTrip(trip_id: number | string): Promise<{ assignment_id: number; user_id: number }[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('ap') + .join('ap.assignment', 'da') + .join('da.day', 'd') + .select([columnRef(platform, 'ap.assignment_id').as('assignment_id'), 'ap.user']) + .where('d.trip_id = ?', [trip_id]) + .execute<{ assignment_id: number; user_id: number }[]>('all', false); + } +} diff --git a/server/src/db/repositories/AuditLog.repository.ts b/server/src/db/repositories/AuditLog.repository.ts new file mode 100644 index 000000000..2ed85642c --- /dev/null +++ b/server/src/db/repositories/AuditLog.repository.ts @@ -0,0 +1,128 @@ +import { AuditLog } from '../entities/AuditLog.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * An `audit_log` row as the API emits it (Plan 3i's `admin.service.ts` + * projection reads it via a `LEFT JOIN users`, not this shape — this is the + * bare table row). + */ +export interface AuditLogRow { + id: number; + created_at: string | null; + user_id: number | null; + action: string; + resource: string | null; + details: string | null; + ip: string | null; +} + +const _auditLogRowKeys: AssertRowKeys = true; + +/** The column set `insertEntry` writes; `id`/`created_at` are generated. */ +export type NewAuditLogRow = Omit; + +/** + * AD22's joined projection (`admin.service.ts#getAuditLog`): `a.id, + * a.created_at, a.user_id, u.username, u.email as user_email, a.action, + * a.resource, a.details, a.ip` from `audit_log a LEFT JOIN users u ON + * u.id = a.user_id`. Distinct from `AuditLogRow` (the bare-table shape + * `insertEntry` writes) — a deleted/never-existing user leaves `username`/ + * `user_email` both null (the `LEFT JOIN`, not an `INNER JOIN`), the row + * itself is still returned. The `created_at` ISO-suffix normalization and + * the `details` JSON-parse-with-fallback stay in `AdminService` — this + * repository returns the raw joined row untouched, same boundary the + * class-level docs elsewhere in this program draw between repository and + * service. + */ +export interface AuditLogPageRow { + id: number; + created_at: string | null; + user_id: number | null; + username: string | null; + user_email: string | null; + action: string; + resource: string | null; + details: string | null; + ip: string | null; +} + +/** Kysely shape for the `listPage` join — only the columns it actually selects. */ +interface AuditLogKyselyDB { + audit_log: AuditLogRow; + users: { id: number; username: string; email: string }; +} + +export class AuditLogRepository extends TrekRepository { + /** + * The column set of the legacy INSERT: + * `INSERT INTO audit_log (user_id, action, resource, details, ip) + * VALUES (?, ?, ?, ?, ?)`. + * + * Best-effort, fire-and-forget, exactly like the caller + * (`AuditService.writeAudit`, wrapped in an outer try/catch that never + * throws): no `refresh`, nothing the caller reads — MikroORM appends + * `RETURNING id, created_at` for the defaults, but `insertEntry` resolves + * `void`. + * + * A native `em.insert()`, not `create()` + `flush()`: `flush()` commits + * the *whole* unit of work of the request's `EntityManager`, not just this + * row — with ~98 call sites for `writeAudit`, one of them firing before + * every MCP tool handler runs, an unrelated pending change elsewhere in + * the same request would be committed early by an audit write, and the + * outer try/catch that makes this best-effort would swallow whatever that + * flush raised. `em.insert()` fires a single native INSERT with no side + * effects on the context/identity map — the same fire-and-forget + * semantics as the legacy `dbs.run(INSERT …)` autocommit statement. + * + * Named `insertEntry`, not `insert`: `EntityRepository` (`@mikro-orm/core`) + * already declares a native `insert(data, options?): Promise>` + * (bypassing `create`/`persist`/`flush`), which is a `tsc`-verified + * incompatible override for a `Promise` method of the same name — the + * same reason `Days.repository.ts`/`DayNotes.repository.ts` name their + * inserts `createDay`/`createNote` rather than `create`. + */ + async insertEntry(entry: NewAuditLogRow): Promise { + await this.insert({ + user: entry.user_id, + action: entry.action, + resource: entry.resource, + details: entry.details, + ip: entry.ip, + }); + } + + /** + * AD22 (`admin.service.ts#getAuditLog`) — `SELECT a.id, a.created_at, + * a.user_id, u.username, u.email as user_email, a.action, a.resource, + * a.details, a.ip FROM audit_log a LEFT JOIN users u ON u.id = a.user_id + * ORDER BY a.id DESC LIMIT ? OFFSET ?`. Kysely, not the ORM's own + * QueryBuilder — the same `CollabMessagesRepository#joinedQuery` shape + * every other flat-projection join in this program uses: `LEFT JOIN` + * keeps a deleted-user's row (both `username`/`user_email` come back + * null rather than dropping the row), and the projection is nine flat + * scalars, not a hydrated `Users` entity. + */ + async listPage(limit: number, offset: number): Promise { + return await this.kysely() + .selectFrom('audit_log as a') + .leftJoin('users as u', 'u.id', 'a.user_id') + .select(['a.id', 'a.created_at', 'a.user_id', 'u.username', 'u.email as user_email', 'a.action', 'a.resource', 'a.details', 'a.ip']) + .orderBy('a.id', 'desc') + .limit(limit) + .offset(offset) + .execute(); + } + + /** + * AD23 (`admin.service.ts#getAuditLog`) — `SELECT COUNT(*) as c FROM + * audit_log`. `TrekRepository#count` (no filter) already renders the + * equivalent `COUNT(*)` over the whole table; named explicitly here (a + * thin `super.count()` pass-through) so the reserved AD23 call site has + * its own documented anchor rather than admin's service code calling the + * inherited method by its generic name. + */ + override async count(): Promise { + return await super.count(); + } +} diff --git a/server/src/db/repositories/BucketList.repository.ts b/server/src/db/repositories/BucketList.repository.ts new file mode 100644 index 000000000..d560ce5e0 --- /dev/null +++ b/server/src/db/repositories/BucketList.repository.ts @@ -0,0 +1,333 @@ +import { lowerTrim, lowerTrimParam } from '../dialect/sql-functions'; +import type { BucketList } from '../entities/BucketList.entity'; +import { presenceSet } from './_shared/presence-set'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `bucket_list` — every scalar column, `AT30`/`AT33`/`AT34`'s `SELECT *` shape. */ +export interface BucketListRow { + id: number; + user_id: number; + name: string; + lat: number | null; + lng: number | null; + country_code: string | null; + notes: string | null; + created_at: string | null; + target_date: string | null; + visited_at: string | null; + visited_source: string | null; + region_code: string | null; +} + +/** + * Three separate table shapes for the SAME `bucket_list` table (the + * `VisitedCountriesRepository`'s own docstring explains why one shared + * shape doesn't work for both reads and writes): the full row for + * {@link listForUser}/{@link findById}/{@link findForUser}'s `SELECT *`; + * the exact 8-column legacy `INSERT` list for {@link insertItem} (`id`, + * `created_at`, `visited_at`, `visited_source` are never bound — + * `created_at` via the schema's `DEFAULT CURRENT_TIMESTAMP`, the rest via + * SQLite's ordinary "omitted nullable column ⇒ NULL"); `id`/`user_id` plus + * the six settable columns for {@link update}/{@link deleteForUser}, which + * only ever `.set()`/`.where()` — never `.values()` — so no insert-only + * column needs excluding there. + */ +interface BucketListReadKyselyDB { + bucket_list: BucketListRow; +} +interface BucketListInsertKyselyDB { + bucket_list: { user_id: number; name: string; lat: number | null; lng: number | null; country_code: string | null; notes: string | null; target_date: string | null; region_code: string | null }; +} +interface BucketListWriteKyselyDB { + bucket_list: { id: number; user_id: number; name: string; notes: string | null; lat: number | null; lng: number | null; country_code: string | null; target_date: string | null }; +} +/** The narrow `bucket_list` shape Plan 3h Task 3's visited-tick writes (DWS9/13/15) need. */ +interface BucketListVisitKyselyDB { + bucket_list: { id: number; user_id: number; visited_at: string | null; visited_source: string | null }; +} + +/** The six columns `findDuplicate`'s #1898 dedup identity compares (`AT31`). */ +export interface BucketListIdentity { + name: string; + lat: number | null; + lng: number | null; + country_code: string | null; + target_date: string | null; +} + +/** + * `bucket_list` — a user's travel wishlist (Plan 3f Task 1, atlas). See + * {@link findDuplicate}'s own docstring for the #1898 dedup identity; + * {@link update}'s for the `?? null` vs `|| null` lat/lng-of-zero fix this + * repository preserves rather than regresses. + */ +export class BucketListRepository extends TrekRepository { + private readDb() { + return this.kysely(); + } + + private insertDb() { + return this.kysely(); + } + + private writeDb() { + return this.kysely(); + } + + private visitDb() { + return this.kysely(); + } + + /** AT30 (`bucketList`) — `SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC`. */ + async listForUser(userId: number): Promise { + return await this.readDb().selectFrom('bucket_list').selectAll().where('user_id', '=', userId).orderBy('created_at', 'desc').execute(); + } + + /** + * AT31 (`findDuplicateBucketItem`, #1898) — `SELECT id FROM bucket_list + * WHERE user_id = ? AND lower(trim(name)) = lower(trim(?)) AND + * country_code IS ? AND target_date IS ? AND lat IS ? AND lng IS ? AND id + * IS NOT ? LIMIT 1`. Every nullable comparison (`country_code`/ + * `target_date`/`lat`/`lng`/`id`) is a bound-parameter `IS`/`IS NOT` in + * the legacy statement — NULL-safe on BOTH sides, so "no coordinates" + * matches "no coordinates" instead of SQL's usual `NULL = NULL` ⇒ false. + * MikroORM's own filter-object compiler reproduces this exactly with NO + * raw SQL needed: a bare `{ col: value }` condition renders `col = ?` + * when `value` is non-null and the LITERAL `col is null` when `value` is + * null (`QueryBuilderHelper.js`'s `processObjectSubCondition`, `const op = + * cond[key] === null ? 'is' : '='`), re-evaluated FRESH on every call + * (never a cached prepared statement, unlike the legacy `?`-bound `IS`) — + * so passing `key.country_code`/`key.target_date`/`key.lat`/`key.lng` + * straight through, whatever their value, reproduces the legacy + * NULL-safe match on every one of the four columns. `id: { $ne: excludeId + * ?? null }` gets the identical treatment for `$ne` specifically + * (`getOperatorReplacement`'s `value[op] === null` branch): `excludeId ?? + * null` renders `id is not null` (always true — `id` is never null) when + * `create` calls with no id to exclude, and `id != ?` (bound) when + * `update` passes its own row's id — the same two branches the legacy + * `IS NOT ?` bound with a possibly-null parameter produced. + * `lowerTrim`/`lowerTrimParam` (Plan 3f Task 0, R9) reproduce + * `lower(trim(name)) = lower(trim(?))` as a filter KEY, the + * `SchoolHolidayRegionsRepository.findIdByNameCI`/`collateNoCase` + * precedent for a dialect helper spread into a `findOne` filter object. + * Proven against all-NULL AND all-set fixture rows by a dedicated parity + * test (`bucket-list-duplicate.parity.test.ts` — see this task's report). + */ + async findDuplicate(userId: number, key: BucketListIdentity, excludeId: number | null): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { + user: userId, + [lowerTrim(platform, 'name')]: lowerTrimParam(platform, key.name), + country_code: key.country_code, + target_date: key.target_date, + lat: key.lat, + lng: key.lng, + id: { $ne: excludeId ?? null }, + }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + /** AT32 (`createBucketItem`) — `INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes, target_date, region_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`. Returns the new row's id. */ + async insertItem(row: { + user_id: number; + name: string; + lat: number | null; + lng: number | null; + country_code: string | null; + notes: string | null; + target_date: string | null; + region_code: string | null; + }): Promise { + const result = await this.insertDb().insertInto('bucket_list').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** AT33 (`createBucketItem`'s post-insert re-select, NOT user-scoped — the legacy statement's own shape) — `SELECT * FROM bucket_list WHERE id = ?`. */ + async findById(id: number | string): Promise { + return await this.readDb().selectFrom('bucket_list').selectAll().where('id', '=', id as number).executeTakeFirst(); + } + + /** AT34/AT36/AT37 (`updateBucketItem`'s guard, its post-update re-select, `deleteBucketItem`'s guard — identical text, three call sites) — `SELECT * FROM bucket_list WHERE id = ? AND user_id = ?`. */ + async findForUser(id: number | string, userId: number): Promise { + return await this.readDb().selectFrom('bucket_list').selectAll().where('id', '=', id as number).where('user_id', '=', userId).executeTakeFirst(); + } + + /** + * AT35 (`updateBucketItem`) — the 6-column `UPDATE bucket_list SET name = + * COALESCE(?, name), notes = CASE WHEN ? THEN ? ELSE notes END, lat = + * CASE WHEN ? THEN ? ELSE lat END, lng = CASE WHEN ? THEN ? ELSE lng END, + * country_code = CASE WHEN ? THEN ? ELSE country_code END, target_date = + * CASE WHEN ? THEN ? ELSE target_date END WHERE id = ? AND user_id = ?`. + * `name` keeps the legacy `COALESCE(?, name)` TRUTHY-wins semantics (the + * caller resolves `present = !!value`, same as + * `PackingBagsRepository.update`'s `name`/`color`); the other five are + * true presence sentinels (`present = data.field !== undefined`), already + * resolved by the SERVICE per `_shared/presence-set.ts`'s own contract — + * this repository writes exactly the value it is handed, `?? null` never + * `|| null` (the class docstring's lat/lng-of-zero fix: the SERVICE binds + * `data.lat ?? null`, not `data.lat || null`, so an explicit `0` survives). + * An empty write short-circuits before the `UPDATE`, the + * `PackingBagsRepository`/`BudgetItems`/`TodoItems` precedent for + * `presenceSet` returning `{}`. + */ + async update( + id: number | string, + userId: number, + write: { + name?: readonly [present: boolean, value: string]; + notes?: readonly [present: boolean, value: string | null]; + lat?: readonly [present: boolean, value: number | null]; + lng?: readonly [present: boolean, value: number | null]; + country_code?: readonly [present: boolean, value: string | null]; + target_date?: readonly [present: boolean, value: string | null]; + }, + ): Promise { + const data = presenceSet<{ + name: string; + notes: string | null; + lat: number | null; + lng: number | null; + country_code: string | null; + target_date: string | null; + }>(write); + if (Object.keys(data).length === 0) return; + await this.writeDb().updateTable('bucket_list').set(data).where('id', '=', id as number).where('user_id', '=', userId).execute(); + } + + /** AT38 (`deleteBucketItem`) — `DELETE FROM bucket_list WHERE id = ? AND user_id = ?`. */ + async deleteForUser(id: number | string, userId: number): Promise { + await this.writeDb().deleteFrom('bucket_list').where('id', '=', id as number).where('user_id', '=', userId).execute(); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 3 (`DawarichSuggestionsService`/`DawarichSyncService`) — + // additive. Cross-domain reads/writes from the Dawarich integration (3h) + // into this 3f-owned table. + // --------------------------------------------------------------------------- + + /** + * DWS9 — `acceptAsBucketTick`: `UPDATE bucket_list SET visited_at = ?, + * visited_source = 'dawarich' WHERE id = ? AND user_id = ?`, + * unconditional (DWS8's own `findForUser`-shaped guard read already + * confirmed the row exists and belongs to the caller before this write). + */ + async markVisited(id: number, userId: number, visitedAt: string): Promise { + await this.visitDb() + .updateTable('bucket_list') + .set({ visited_at: visitedAt, visited_source: 'dawarich' }) + .where('id', '=', id) + .where('user_id', '=', userId) + .execute(); + } + + /** + * DWS12 — `scanBucketList`: `SELECT id, name, lat, lng, visited_at FROM + * bucket_list WHERE user_id = ? ORDER BY (visited_at IS NOT NULL), + * created_at DESC, id DESC`. The boolean-expression `ORDER BY` key is + * portable SQL — no dialect helper needed. + */ + async listForScan(userId: number): Promise<{ id: number; name: string; lat: number | null; lng: number | null; visited_at: string | null }[]> { + return await this.readDb() + .selectFrom('bucket_list') + .select(['id', 'name', 'lat', 'lng', 'visited_at']) + .where('user_id', '=', userId) + .orderBy((eb) => eb('visited_at', 'is not', null)) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc') + .execute(); + } + + /** + * DWS13 — `confirmBucketVisits` (looped inside the SERVICE's own + * `uow.transactional` block): `UPDATE bucket_list SET visited_at = ?, + * visited_source = 'dawarich' WHERE id = ? AND user_id = ? AND visited_at + * IS NULL` — conditional, won't clobber an existing tick. Returns the + * affected-row count so the caller can total how many were actually + * updated. + */ + async markVisitedIfUnset(id: number, userId: number, visitedAt: string): Promise { + const result = await this.visitDb() + .updateTable('bucket_list') + .set({ visited_at: visitedAt, visited_source: 'dawarich' }) + .where('id', '=', id) + .where('user_id', '=', userId) + .where('visited_at', 'is', null) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0); + } + + /** DWS15 — `clearBucketVisit`: `UPDATE bucket_list SET visited_at = NULL, visited_source = NULL WHERE id = ? AND user_id = ?`. Returns whether a row was actually touched. */ + async clearVisited(id: number, userId: number): Promise { + const result = await this.visitDb() + .updateTable('bucket_list') + .set({ visited_at: null, visited_source: null }) + .where('id', '=', id) + .where('user_id', '=', userId) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; + } + + /** + * DSY10 — `dawarich-sync.service.ts::matchBucketList`'s bounding-box + * pre-filter: `SELECT id, lat, lng FROM bucket_list WHERE user_id = ? AND + * lat IS NOT NULL AND lng IS NOT NULL AND lat BETWEEN ? AND ? AND lng + * BETWEEN ? AND ?`. A rough box only — the real distance test (haversine) + * stays in the SERVICE, in JS. Typed `lat`/`lng` nullable to match the + * column's own schema type; the `IS NOT NULL` conditions guarantee they + * are never actually null in a returned row. + */ + async listInBoundingBox( + userId: number, + latMin: number, + latMax: number, + lngMin: number, + lngMax: number, + ): Promise<{ id: number; lat: number | null; lng: number | null }[]> { + return await this.readDb() + .selectFrom('bucket_list') + .select(['id', 'lat', 'lng']) + .where('user_id', '=', userId) + .where('lat', 'is not', null) + .where('lng', 'is not', null) + .where('lat', '>=', latMin) + .where('lat', '<=', latMax) + .where('lng', '>=', lngMin) + .where('lng', '<=', lngMax) + .execute(); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (`public-api.service.ts::listBucketList`) — additive. + // --------------------------------------------------------------------------- + + /** + * `SELECT name, lat, lng, country_code, notes, target_date FROM bucket_list + * WHERE user_id = ? ORDER BY created_at DESC, id DESC`. Deliberately NOT + * {@link listForUser} (which `SELECT *`s and orders by `created_at DESC` + * only, no `id` tiebreak, for the Atlas UI's own list) — a genuinely + * different legacy statement on the same table (rule 2), not a variant + * worth merging. + */ + async listForPublicApi(userId: number): Promise { + return await this.readDb() + .selectFrom('bucket_list') + .select(['name', 'lat', 'lng', 'country_code', 'notes', 'target_date']) + .where('user_id', '=', userId) + .orderBy('created_at', 'desc') + .orderBy('id', 'desc') + .execute(); + } +} + +/** {@link BucketListRepository.listForPublicApi}'s projection. */ +export interface PublicApiBucketListItemRow { + name: string; + lat: number | null; + lng: number | null; + country_code: string | null; + notes: string | null; + target_date: string | null; +} diff --git a/server/src/db/repositories/BudgetCategoryOrder.repository.ts b/server/src/db/repositories/BudgetCategoryOrder.repository.ts new file mode 100644 index 000000000..da79c722a --- /dev/null +++ b/server/src/db/repositories/BudgetCategoryOrder.repository.ts @@ -0,0 +1,74 @@ +import type { BudgetCategoryOrder } from '../entities/BudgetCategoryOrder.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +interface BudgetCategoryOrderKyselyDB { + budget_category_order: { trip_id: number; category: string; sort_order: number }; +} + +/** + * `budget_category_order` — a composite-PK `(trip_id, category)` table (no + * surrogate `id`, per the entity's `[PrimaryKeyProp]?: ['trip', 'category']` + * — `BudgetItemContributors`-class shape, R5). Kysely throughout: the + * `ON CONFLICT(trip_id, category) DO UPDATE` upsert (BG84) has a real + * two-column conflict target Kysely expresses directly, and every other + * write here is a plain `INSERT OR IGNORE`/`INSERT` the entity's composite + * key would otherwise force through `em.upsert`'s narrower single-target + * form. + */ +export class BudgetCategoryOrderRepository extends TrekRepository { + /** BG22/BG41 — `SELECT 1 FROM budget_category_order WHERE trip_id = ? AND category = ?`. */ + async exists(trip_id: number | string, category: string): Promise { + const row = await this.kysely() + .selectFrom('budget_category_order') + .select((eb) => eb.val(1).as('one')) + .where('trip_id', '=', trip_id as number) + .where('category', '=', category) + .executeTakeFirst(); + return row !== undefined; + } + + /** BG23/BG42 — `SELECT MAX(sort_order) as max FROM budget_category_order WHERE trip_id = ?`. */ + async maxSortOrder(trip_id: number | string): Promise { + const row = await this.kysely() + .selectFrom('budget_category_order') + .select((eb) => eb.fn.max('sort_order').as('max')) + .where('trip_id', '=', trip_id as number) + .executeTakeFirst(); + return row?.max ?? null; + } + + /** BG24/BG43 — `INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?,?,?)`. */ + async insertIgnore(trip_id: number | string, category: string, sort_order: number): Promise { + await this.kysely() + .insertInto('budget_category_order') + .values({ trip_id: trip_id as number, category, sort_order }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + /** BG84 (`reorderCategories`) — `INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?,?,?) ON CONFLICT(trip_id,category) DO UPDATE SET sort_order = excluded.sort_order`, one row per call (the legacy `prepare(...).run(...)` loop). */ + async upsertSortOrder(trip_id: number | string, category: string, sort_order: number): Promise { + await this.kysely() + .insertInto('budget_category_order') + .values({ trip_id: trip_id as number, category, sort_order }) + .onConflict((oc) => oc.columns(['trip_id', 'category']).doUpdateSet({ sort_order })) + .execute(); + } + + /** TP74 (`TripsService.copy`) — `SELECT category, sort_order FROM budget_category_order WHERE trip_id = ?`. */ + async listForTrip(trip_id: number | string): Promise<{ category: string; sort_order: number }[]> { + return await this.kysely() + .selectFrom('budget_category_order') + .select(['category', 'sort_order']) + .where('trip_id', '=', trip_id as number) + .execute(); + } + + /** TP75 (`TripsService.copy`) — `INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?,?,?)`, plain (not `OR IGNORE` — the copy's own trip_id has no existing rows yet). */ + async insertCopy(trip_id: number | string, category: string, sort_order: number): Promise { + await this.kysely() + .insertInto('budget_category_order') + .values({ trip_id: trip_id as number, category, sort_order }) + .execute(); + } +} diff --git a/server/src/db/repositories/BudgetItemMembers.repository.ts b/server/src/db/repositories/BudgetItemMembers.repository.ts new file mode 100644 index 000000000..aaef58bd5 --- /dev/null +++ b/server/src/db/repositories/BudgetItemMembers.repository.ts @@ -0,0 +1,182 @@ +import type { BudgetItemMembers } from '../entities/BudgetItemMembers.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `budget_item_members` row — every scalar column, incl. the two `persist(false)` relation mirrors. */ +export interface BudgetItemMemberRow { + id: number; + budget_item_id: number; + user_id: number; + paid: number; + amount: number | null; +} + +const _budgetItemMemberRowKeys: AssertRowKeys = true; + +/** BG1/BG11's joined projection — the member row plus the display fields `avatarUrl()` needs. */ +export interface BudgetItemMemberWithUserRow { + user_id: number; + paid: number; + amount: number | null; + username: string; + avatar: string | null; +} + +interface BudgetItemMembersKyselyDB { + budget_item_members: BudgetItemMemberRow; + users: { id: number; username: string; display_name: string | null; avatar: string | null }; +} + +/** The insert-only shape — `id` is autoincrement and omitted from `.values()` below, matching `FileLinksWriteKyselyDB`'s precedent. */ +interface BudgetItemMembersWriteKyselyDB { + budget_item_members: { budget_item_id: number; user_id: number; paid: number; amount: number | null }; +} + +/** + * `budget_item_members` — who splits a budget item, and whether they've + * paid their share. Kysely throughout: `budget_item_id`/`user_id` are + * `persist(false)` relation mirrors (the program-wide trap), and the + * `INSERT OR IGNORE` write has no `em.upsert`-expressible single conflict + * target that also matches every legacy column combination (some calls omit + * `amount`, some pin it `NULL`) — Kysely's `ON CONFLICT DO NOTHING` is the + * exact SQLite equivalent, matching the legacy statement. + */ +export class BudgetItemMembersRepository extends TrekRepository { + /** BG1 — `SELECT bm.user_id, bm.paid, bm.amount, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_members bm JOIN users u WHERE bm.budget_item_id = ?`. */ + async listForItem(budget_item_id: number): Promise { + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('users as u', 'u.id', 'bm.user_id') + .select(['bm.user_id', 'bm.paid', 'bm.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bm.budget_item_id', '=', budget_item_id) + .execute(); + } + + /** BG11 (`listBudgetItems`'s member batch) — same projection as {@link listForItem}, `budget_item_id IN (dynamic)`, plus the item id for the caller to bucket rows by. */ + async listForItems(budget_item_ids: number[]): Promise<(BudgetItemMemberWithUserRow & { budget_item_id: number })[]> { + if (budget_item_ids.length === 0) return []; + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('users as u', 'u.id', 'bm.user_id') + .select(['bm.budget_item_id', 'bm.user_id', 'bm.paid', 'bm.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bm.budget_item_id', 'in', budget_item_ids) + .execute(); + } + + /** BG58 (`updateMembers`'s pre-image read) — `SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?`. */ + async listUserPaid(budget_item_id: number): Promise<{ user_id: number; paid: number }[]> { + return await this.kysely() + .selectFrom('budget_item_members') + .select(['user_id', 'paid']) + .where('budget_item_id', '=', budget_item_id) + .execute(); + } + + /** TP62 (`TripsService.copy`'s bare member read) — `SELECT bm.* FROM budget_item_members bm JOIN budget_items b ON b.id = bm.budget_item_id WHERE b.trip_id = ?`. */ + async listRawForTrip(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('budget_items as b', 'b.id', 'bm.budget_item_id') + .select(['bm.id', 'bm.budget_item_id', 'bm.user_id', 'bm.paid', 'bm.amount']) + .where('b.trip_id', '=', trip_id as number) + .execute(); + } + + /** BG73 (`calculateSettlement`'s member read) — the `budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)` subquery, re-expressed as a join (rule 23) — same result set. No `paid` column — unlike {@link listForItem}, this legacy statement never selected it. */ + async listForTripWithUsers(trip_id: number | string): Promise<{ budget_item_id: number; user_id: number; amount: number | null; username: string; avatar: string | null }[]> { + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('budget_items as b', 'b.id', 'bm.budget_item_id') + .innerJoin('users as u', 'u.id', 'bm.user_id') + .select(['bm.budget_item_id', 'bm.user_id', 'bm.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('b.trip_id', '=', trip_id as number) + .execute(); + } + + /** + * BG94 (`getPerPersonSummary`, #2525) — `SELECT bm.budget_item_id, + * bm.user_id, bm.amount, bm.paid, COALESCE(u.display_name, u.username) AS + * username, u.avatar FROM budget_item_members bm JOIN budget_items bi ON + * bm.budget_item_id = bi.id JOIN users u ON bm.user_id = u.id WHERE + * bi.trip_id = ?`. Like {@link listForTripWithUsers}, plus `paid`. + */ + async listForTripWithUsersAndPaid(trip_id: number | string): Promise<(BudgetItemMemberWithUserRow & { budget_item_id: number })[]> { + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('budget_items as bi', 'bi.id', 'bm.budget_item_id') + .innerJoin('users as u', 'u.id', 'bm.user_id') + .select(['bm.budget_item_id', 'bm.user_id', 'bm.amount', 'bm.paid', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bi.trip_id', '=', trip_id as number) + .execute(); + } + + /** BG64 (`removeUserFromBudgetItems`) — `SELECT DISTINCT budget_item_id FROM budget_item_members WHERE user_id = ?`. */ + async listItemIdsForUser(user_id: number): Promise { + const rows = await this.kysely() + .selectFrom('budget_item_members') + .select('budget_item_id') + .distinct() + .where('user_id', '=', user_id) + .execute(); + return rows.map(r => r.budget_item_id); + } + + /** BG65 — `DELETE FROM budget_item_members WHERE user_id = ?`. */ + async deleteForUser(user_id: number): Promise { + await this.kysely().deleteFrom('budget_item_members').where('user_id', '=', user_id).execute(); + } + + /** BG66 (`removeUserFromBudgetItems`'s per-item recount, looped) — `SELECT COUNT(*) AS count FROM budget_item_members WHERE budget_item_id = ?`. */ + async countForItem(budget_item_id: number): Promise { + const row = await this.kysely() + .selectFrom('budget_item_members') + .select((eb) => eb.fn.countAll().as('count')) + .where('budget_item_id', '=', budget_item_id) + .executeTakeFirst(); + return row?.count ?? 0; + } + + /** BG70 (`toggleMemberPaid`'s re-select) — `SELECT bm.user_id, bm.paid, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_members bm JOIN users u WHERE bm.budget_item_id = ? AND bm.user_id = ?`. No `amount` column — unlike {@link listForItem}, this legacy statement never selected it. */ + async findMemberWithUser(budget_item_id: number, user_id: number): Promise<{ user_id: number; paid: number; username: string; avatar: string | null } | undefined> { + return await this.kysely() + .selectFrom('budget_item_members as bm') + .innerJoin('users as u', 'u.id', 'bm.user_id') + .select(['bm.user_id', 'bm.paid', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bm.budget_item_id', '=', budget_item_id) + .where('bm.user_id', '=', user_id) + .executeTakeFirst(); + } + + /** BG69 — `UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?`. */ + async setPaid(budget_item_id: number, user_id: number, paid: number): Promise { + await this.kysely() + .updateTable('budget_item_members') + .set({ paid }) + .where('budget_item_id', '=', budget_item_id) + .where('user_id', '=', user_id) + .execute(); + } + + /** BG35/BG38/BG59 — `DELETE FROM budget_item_members WHERE budget_item_id = ?` (replace-all before a re-insert). */ + async deleteForItem(budget_item_id: number): Promise { + await this.kysely().deleteFrom('budget_item_members').where('budget_item_id', '=', budget_item_id).execute(); + } + + /** + * BG26/BG27/BG36/BG39/BG60/TP63 — `INSERT OR IGNORE INTO + * budget_item_members (budget_item_id, user_id, paid, amount) VALUES + * (?,?,?,?)`, one row per call (the legacy `prepare(...).run(...)` loop — + * the service calls this once per member, matching the legacy statement + * text exactly for every call site: some pin `paid` to 0 and pass a real + * or `NULL` amount, one (`updateMembers`) passes an existing `paid` flag + * with no `amount` column at all — `paid`/`amount` default here to the + * 0/`NULL` shape the omitting call sites bound literally). + */ + async insertIgnore(row: { budget_item_id: number; user_id: number; paid?: number; amount?: number | null }): Promise { + await this.kysely() + .insertInto('budget_item_members') + .values({ budget_item_id: row.budget_item_id, user_id: row.user_id, paid: row.paid ?? 0, amount: row.amount ?? null }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } +} diff --git a/server/src/db/repositories/BudgetItemPayers.repository.ts b/server/src/db/repositories/BudgetItemPayers.repository.ts new file mode 100644 index 000000000..19d3ad0d9 --- /dev/null +++ b/server/src/db/repositories/BudgetItemPayers.repository.ts @@ -0,0 +1,95 @@ +import type { BudgetItemPayers } from '../entities/BudgetItemPayers.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `budget_item_payers` row — every scalar column, incl. the two `persist(false)` relation mirrors. */ +export interface BudgetItemPayerRow { + id: number; + budget_item_id: number; + user_id: number; + amount: number; +} + +const _budgetItemPayerRowKeys: AssertRowKeys = true; + +/** BG2/BG12's joined projection. */ +export interface BudgetItemPayerWithUserRow { + user_id: number; + amount: number; + username: string; + avatar: string | null; +} + +interface BudgetItemPayersKyselyDB { + budget_item_payers: BudgetItemPayerRow; + users: { id: number; username: string; display_name: string | null; avatar: string | null }; +} + +/** The insert-only shape — `id` is autoincrement and omitted from `.values()` below, matching `FileLinksWriteKyselyDB`'s precedent. */ +interface BudgetItemPayersWriteKyselyDB { + budget_item_payers: { budget_item_id: number; user_id: number; amount: number }; +} + +/** + * `budget_item_payers` — who actually paid a budget item, and how much. + * Kysely throughout, same reasoning as `BudgetItemMembersRepository`'s class + * docstring (`persist(false)` relation mirrors, an `INSERT OR IGNORE` with + * no single `em.upsert` conflict target). + */ +export class BudgetItemPayersRepository extends TrekRepository { + /** BG2 — `SELECT bp.user_id, bp.amount, COALESCE(u.display_name, u.username) AS username, u.avatar FROM budget_item_payers bp JOIN users u WHERE bp.budget_item_id = ?`. */ + async listForItem(budget_item_id: number): Promise { + return await this.kysely() + .selectFrom('budget_item_payers as bp') + .innerJoin('users as u', 'u.id', 'bp.user_id') + .select(['bp.user_id', 'bp.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bp.budget_item_id', '=', budget_item_id) + .execute(); + } + + /** BG12 (`listBudgetItems`'s payer batch) — same projection, `budget_item_id IN (dynamic)`, plus the item id for the caller to bucket rows by. */ + async listForItems(budget_item_ids: number[]): Promise<(BudgetItemPayerWithUserRow & { budget_item_id: number })[]> { + if (budget_item_ids.length === 0) return []; + return await this.kysely() + .selectFrom('budget_item_payers as bp') + .innerJoin('users as u', 'u.id', 'bp.user_id') + .select(['bp.budget_item_id', 'bp.user_id', 'bp.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('bp.budget_item_id', 'in', budget_item_ids) + .execute(); + } + + /** TP64 (`TripsService.copy`'s bare payer read) — `SELECT bp.* FROM budget_item_payers bp JOIN budget_items b ON b.id = bp.budget_item_id WHERE b.trip_id = ?`. */ + async listRawForTrip(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('budget_item_payers as bp') + .innerJoin('budget_items as b', 'b.id', 'bp.budget_item_id') + .select(['bp.id', 'bp.budget_item_id', 'bp.user_id', 'bp.amount']) + .where('b.trip_id', '=', trip_id as number) + .execute(); + } + + /** BG74 (`calculateSettlement`'s payer read) — the `budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?)` subquery, re-expressed as a join (rule 23) — same result set. */ + async listForTripWithUsers(trip_id: number | string): Promise<(BudgetItemPayerWithUserRow & { budget_item_id: number })[]> { + return await this.kysely() + .selectFrom('budget_item_payers as bp') + .innerJoin('budget_items as b', 'b.id', 'bp.budget_item_id') + .innerJoin('users as u', 'u.id', 'bp.user_id') + .select(['bp.budget_item_id', 'bp.user_id', 'bp.amount', (eb) => eb.fn.coalesce('u.display_name', 'u.username').as('username'), 'u.avatar']) + .where('b.trip_id', '=', trip_id as number) + .execute(); + } + + /** BG4 — `DELETE FROM budget_item_payers WHERE budget_item_id = ?` (replace-all before a re-insert). */ + async deleteForItem(budget_item_id: number): Promise { + await this.kysely().deleteFrom('budget_item_payers').where('budget_item_id', '=', budget_item_id).execute(); + } + + /** BG5/TP65 — `INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?,?,?)`, one row per call (the legacy `prepare(...).run(...)` loop). */ + async insertIgnore(row: { budget_item_id: number; user_id: number; amount: number }): Promise { + await this.kysely() + .insertInto('budget_item_payers') + .values({ budget_item_id: row.budget_item_id, user_id: row.user_id, amount: row.amount }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } +} diff --git a/server/src/db/repositories/BudgetItems.repository.ts b/server/src/db/repositories/BudgetItems.repository.ts new file mode 100644 index 000000000..0d7f1decf --- /dev/null +++ b/server/src/db/repositories/BudgetItems.repository.ts @@ -0,0 +1,542 @@ +import type { BudgetItems } from '../entities/BudgetItems.entity'; +import { columnRef } from '../dialect/sql-functions'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; +import { presenceSet } from './_shared/presence-set'; + +/** A bare `budget_items` row — every scalar column, incl. the four `persist(false)` relation mirrors (the program-wide trap this class goes through Kysely to avoid). */ +export interface BudgetItemRow { + id: number; + trip_id: number; + category: string; + name: string; + total_price: number; + persons: number | null; + days: number | null; + note: string | null; + sort_order: number | null; + created_at: string | null; + paid_by_user_id: number | null; + expense_date: string | null; + reservation_id: number | null; + currency: string | null; + exchange_rate: number; + ticket_json: string | null; + place_id: number | null; +} + +const _budgetItemRowKeys: AssertRowKeys = true; + +/** BG3/BG13's receipt projection (`loadItemReceipts`). */ +export interface BudgetReceiptRow { + id: number; + filename: string; + original_name: string; + file_size: number | null; + mime_type: string | null; + trip_id: number; +} + +/** BG7's receipt-link projection (`unlinkReceipts`'s pre-read). */ +export interface BudgetReceiptLinkRow { + id: number; + file_id: number; + reservation_id: number | null; + assignment_id: number | null; + place_id: number | null; +} + +/** BG88's projection: what a row needs to be converted into the trip currency. */ +export interface BudgetItemMoneyRow { + id: number; + category: string; + total_price: number; + currency: string | null; + exchange_rate: number; +} + +interface BudgetKyselyDB { + budget_items: BudgetItemRow; + budget_item_members: { id: number; budget_item_id: number; user_id: number; paid: number; amount: number | null }; + budget_category_order: { trip_id: number; category: string; sort_order: number }; + users: { id: number; username: string; display_name: string | null; avatar: string | null }; + trip_files: { id: number; filename: string; original_name: string; file_size: number | null; mime_type: string | null; trip_id: number; deleted_at: string | null; created_at: string | null }; + file_links: { id: number; file_id: number; reservation_id: number | null; assignment_id: number | null; place_id: number | null; budget_item_id: number | null; created_at: string | null }; +} + +/** + * The insert-only shape for `create` (BG25) — omits `id`/`created_at` + * (autoincrement/`DEFAULT CURRENT_TIMESTAMP`, matching the legacy column + * list) and `paid_by_user_id` (never written by `BudgetService` — R11/§17 + * surprise 7, only `UserCleanupService`'s UC5 touches it). + */ +interface BudgetItemsInsertKyselyDB { + budget_items: { + trip_id: number | string; category: string; name: string; total_price: number; currency: string | null; + exchange_rate: number; persons: number | null; days: number | null; note: string | null; ticket_json: string | null; + sort_order: number; expense_date: string | null; reservation_id: number | null; place_id: number | null; + }; +} + +/** The insert-only shape for `insertCopy` (TP61) — a distinct column set/order from `create`'s, carrying `paid_by_user_id` verbatim (§17). */ +interface BudgetItemsCopyInsertKyselyDB { + budget_items: { + trip_id: number | string; category: string; name: string; total_price: number; persons: number | null; days: number | null; + note: string | null; sort_order: number | null; reservation_id: number | null; currency: string | null; + exchange_rate: number; expense_date: string | null; ticket_json: string | null; paid_by_user_id: number | null; + }; +} + +/** + * `budget_items` — the expense rows themselves, plus this domain's own view + * of its receipts (a join across `trip_files`/`file_links`, the same way + * `BudgetSettlements.SETTLEMENT_SELECT` joins `users`). Kysely throughout: + * `trip_id`/`paid_by_user_id`/`reservation_id`/`place_id` are all + * `persist(false)` relation mirrors (§16) — the program-wide bare-`select` + * drop trap `TripFilesRepository`'s class docstring names, avoided the same + * way here. + */ +export class BudgetItemsRepository extends TrekRepository { + /** + * FL4 (`FilesService.findForeignLinkTarget`'s budget-expense branch, R12) + * — kept from Task 1, unchanged. + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('b') + .select([columnRef(platform, 'b.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + /** + * BG10 (`listBudgetItems`) — `SELECT bi.* FROM budget_items bi LEFT JOIN + * budget_category_order bco ON bco.trip_id = bi.trip_id AND bco.category = + * bi.category WHERE bi.trip_id = ? ORDER BY COALESCE(bco.sort_order, + * 999999) ASC, bi.sort_order ASC`. + */ + async listWithCategoryOrder(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('budget_items as bi') + .leftJoin('budget_category_order as bco', (join) => join.onRef('bco.trip_id', '=', 'bi.trip_id').onRef('bco.category', '=', 'bi.category')) + .selectAll('bi') + .where('bi.trip_id', '=', trip_id as number) + .orderBy((eb) => eb.fn.coalesce('bco.sort_order', eb.val(999999)), 'asc') + .orderBy('bi.sort_order', 'asc') + .execute(); + } + + /** BG14 — `SELECT currency FROM budget_items WHERE id = ? AND trip_id = ?`. `id: number` (Plan 4 Task 8b, U6 — the program's gate-level id parsing carry: its one caller, `freezeForeignRate`, is only reached with `BudgetController.update`'s `toRowId`-parsed id). */ + async getCurrency(id: number, trip_id: number | string): Promise { + const row = await this.kysely().selectFrom('budget_items').select('currency').where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + return row?.currency; + } + + /** BG31/BG32/BG57 — `SELECT * FROM budget_items WHERE id = ? AND trip_id = ?`, the full-row trip-scoping guard. `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}; `trip_id` stays `number | string`, a separate, still-accepted carry. */ + async findInTrip(id: number, trip_id: number | string): Promise { + return await this.kysely().selectFrom('budget_items').selectAll().where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** BG50/BG68 — `SELECT id FROM budget_items WHERE id = ? AND trip_id = ?`, the id-only trip-scoping guard. `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */ + async existsInTrip(id: number, trip_id: number | string): Promise<{ id: number } | undefined> { + return await this.kysely().selectFrom('budget_items').select('id').where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** BG52 (`deleteBudgetItem`'s pre-image) — `SELECT id, reservation_id FROM budget_items WHERE id = ? AND trip_id = ?`. `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */ + async findForDelete(id: number, trip_id: number | string): Promise<{ id: number; reservation_id: number | null } | undefined> { + return await this.kysely().selectFrom('budget_items').select(['id', 'reservation_id']).where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** BG30/BG49/BG51/BG63 — `SELECT * FROM budget_items WHERE id = ?`, the post-write re-select (no trip filter — the caller already knows `id` is in-scope, having just written it). `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */ + async findById(id: number): Promise { + return await this.kysely().selectFrom('budget_items').selectAll().where('id', '=', id).executeTakeFirst(); + } + + /** BG21 — `SELECT MAX(sort_order) as max FROM budget_items WHERE trip_id = ?`. */ + async maxSortOrder(trip_id: number | string): Promise { + const row = await this.kysely().selectFrom('budget_items').select((eb) => eb.fn.max('sort_order').as('max')).where('trip_id', '=', trip_id as number).executeTakeFirst(); + return row?.max ?? null; + } + + /** + * BG25 (`createBudgetItem`) — `INSERT INTO budget_items (14 cols) VALUES + * (?×14)`. Returns the new row's id (BG30's re-select key). Named + * `insertItem`, not `create`/`insert` — both collide with + * `TrekRepository`/`EntityRepository`'s own same-named methods (a + * different signature: an unpersisted-entity builder, not an INSERT). + */ + async insertItem(row: { + trip_id: number | string; category: string; name: string; total_price: number; currency: string | null; + exchange_rate: number; persons: number | null; days: number | null; note: string | null; ticket_json: string | null; + sort_order: number; expense_date: string | null; reservation_id: number | null; place_id: number | null; + }): Promise { + const result = await this.kysely().insertInto('budget_items').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** + * TP61 (`TripsService.copy`) — `INSERT INTO budget_items (trip_id, + * category, name, total_price, persons, days, note, sort_order, + * reservation_id, currency, exchange_rate, expense_date, ticket_json, + * paid_by_user_id) VALUES (?×14)`, a distinct column set/order from + * {@link create}'s — carries `paid_by_user_id` verbatim (§17, the "dead" + * column survives a copy unexamined, matching legacy exactly). + */ + async insertCopy(row: { + trip_id: number | string; category: string; name: string; total_price: number; persons: number | null; days: number | null; + note: string | null; sort_order: number | null; reservation_id: number | null; currency: string | null; + exchange_rate: number; expense_date: string | null; ticket_json: string | null; paid_by_user_id: number | null; + }): Promise { + const result = await this.kysely().insertInto('budget_items').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** + * BG33 (`updateBudgetItem`) — the 13-column presence-sentinel `UPDATE` + * (the last two, `reservation_id`/`place_id`, link an expense to a booking + * or a place, #2084) + * (R11's helper — {@link presenceSet}; land here first, Tasks 3/4/5 copy + * this shape). `category`/`name` keep the legacy `COALESCE(?, col)` + * truthy-wins semantics (the caller passes `present = !!value`, matching + * `data.category || null`'s fall-through-on-falsy exactly); every other + * column is a true presence sentinel (`present = data.field !== undefined`). + */ + /** `id: number` — same Plan 4 Task 8b (U6) narrowing as {@link getCurrency} (its one caller, `updateBudgetItem`, is only reached with a `toRowId`-parsed id). */ + async update(id: number, write: { + category?: readonly [present: boolean, value: string]; + name?: readonly [present: boolean, value: string]; + total_price?: readonly [present: boolean, value: number]; + currency?: readonly [present: boolean, value: string | null]; + exchange_rate?: readonly [present: boolean, value: number]; + persons?: readonly [present: boolean, value: number | null]; + days?: readonly [present: boolean, value: number | null]; + note?: readonly [present: boolean, value: string | null]; + ticket_json?: readonly [present: boolean, value: string | null]; + sort_order?: readonly [present: boolean, value: number]; + expense_date?: readonly [present: boolean, value: string | null]; + reservation_id?: readonly [present: boolean, value: number | null]; + place_id?: readonly [present: boolean, value: number | null]; + }): Promise { + const data = presenceSet<{ + category: string; name: string; total_price: number; currency: string | null; exchange_rate: number; + persons: number | null; days: number | null; note: string | null; ticket_json: string | null; + sort_order: number; expense_date: string | null; reservation_id: number | null; place_id: number | null; + }>(write); + if (Object.keys(data).length === 0) return; + await this.kysely().updateTable('budget_items').set(data).where('id', '=', id).execute(); + } + + /** BG6/BG34 — `UPDATE budget_items SET total_price = ? WHERE id = ?`. */ + async setTotalPrice(id: number | string, total_price: number): Promise { + await this.kysely().updateTable('budget_items').set({ total_price }).where('id', '=', id as number).execute(); + } + + /** BG37/BG40/BG61/BG62 — `UPDATE budget_items SET persons = ? WHERE id = ?` (`persons: null` is BG62's clear branch). */ + async setPersons(id: number | string, persons: number | null): Promise { + await this.kysely().updateTable('budget_items').set({ persons }).where('id', '=', id as number).execute(); + } + + /** BG83 (`reorderItems`, looped) — `UPDATE budget_items SET sort_order = ? WHERE id = ? AND trip_id = ?`. */ + async setSortOrder(id: number | string, trip_id: number | string, sort_order: number): Promise { + await this.kysely().updateTable('budget_items').set({ sort_order }).where('id', '=', id as number).where('trip_id', '=', trip_id as number).execute(); + } + + /** BG53 — `DELETE FROM budget_items WHERE id = ?` (no trip scoping in the statement itself — relies on the caller's prior {@link findForDelete} gate, matching legacy). `id: number`, same Plan 4 Task 8b narrowing as {@link getCurrency}. */ + async deleteById(id: number): Promise { + await this.kysely().deleteFrom('budget_items').where('id', '=', id).execute(); + } + + /** RS49 — `DELETE FROM budget_items WHERE id IN (dynamic)`, every expense the booking carried (#2084); a bound list, no interpolated SQL. */ + async deleteByIds(ids: number[]): Promise { + if (ids.length === 0) return; + await this.kysely().deleteFrom('budget_items').where('id', 'in', ids).execute(); + } + + /** BG72 (`calculateSettlement`) — `SELECT * FROM budget_items WHERE trip_id = ?`. */ + async listAllForTrip(trip_id: number | string): Promise { + return await this.kysely().selectFrom('budget_items').selectAll().where('trip_id', '=', trip_id as number).execute(); + } + + /** + * `share.service.ts:391` SH14 (`getSharedTripData`'s share_budget read) — + * `SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC` + * (unlike BG72's `listAllForTrip` above, which has no `ORDER BY`). + */ + async listPublicForShare(trip_id: number | string): Promise { + return await this.kysely().selectFrom('budget_items').selectAll().where('trip_id', '=', trip_id as number).orderBy('category', 'asc').execute(); + } + + /** + * BG88 (`tripTotals`, `getPerPersonSummary`) — `SELECT id, category, + * total_price, currency, exchange_rate FROM budget_items WHERE trip_id = ? + * ORDER BY id`. Replaces the BG71 aggregate (#2525): every row is now + * converted into the trip currency in the service before anything is added. + */ + async listMoneyRows(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('budget_items') + .select(['id', 'category', 'total_price', 'currency', 'exchange_rate']) + .where('trip_id', '=', trip_id as number) + .orderBy('id', 'asc') + .execute(); + } + + /** + * BG89 (`ratesForTripTotals`) — `SELECT 1 FROM budget_items WHERE trip_id = ? + * AND currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? AND + * (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1) LIMIT 1`. + */ + async hasUnfrozenForeign(trip_id: number | string, trip_currency: string): Promise { + const row = await this.kysely() + .selectFrom('budget_items') + .select('id') + .where('trip_id', '=', trip_id as number) + .where('currency', 'is not', null) + .where('currency', '!=', '') + .where((eb) => eb(eb.fn('upper', ['currency']), '!=', trip_currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .limit(1) + .executeTakeFirst(); + return row !== undefined; + } + + /** + * BG90's `budget_items` half (`freezeMissingRates`) — `SELECT UPPER(currency) + * AS cur FROM budget_items WHERE trip_id = ? AND currency IS NOT NULL AND + * currency != '' AND UPPER(currency) != ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`. + * The legacy statement UNIONed this with the settlements half; `DISTINCT` + * here stands in for the dedupe the UNION did, and the caller merges, dedupes + * and sorts the two lists. + */ + async listUnfrozenForeignCurrencies(trip_id: number | string, trip_currency: string): Promise { + const rows = await this.kysely() + .selectFrom('budget_items') + .select((eb) => eb.fn('upper', ['currency']).as('cur')) + .distinct() + .where('trip_id', '=', trip_id as number) + .where('currency', 'is not', null) + .where('currency', '!=', '') + .where((eb) => eb(eb.fn('upper', ['currency']), '!=', trip_currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + return rows.map(r => r.cur); + } + + /** BG91's `budget_items` half (`freezeMissingRates`) — `SELECT id FROM budget_items WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`. */ + async listUnfrozenIdsForCurrency(trip_id: number | string, currency: string): Promise { + const rows = await this.kysely() + .selectFrom('budget_items') + .select('id') + .where('trip_id', '=', trip_id as number) + .where((eb) => eb(eb.fn('upper', ['currency']), '=', currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + return rows.map(r => r.id); + } + + /** BG92's `budget_items` half (`freezeMissingRates`) — `UPDATE budget_items SET exchange_rate = ? WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`, the compare-and-set write. */ + async freezeUnfrozenForCurrency(trip_id: number | string, currency: string, exchange_rate: number): Promise { + await this.kysely() + .updateTable('budget_items') + .set({ exchange_rate }) + .where('trip_id', '=', trip_id as number) + .where((eb) => eb(eb.fn('upper', ['currency']), '=', currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + } + + /** BG93 (`resyncReservationPrice`) — `SELECT total_price, currency FROM budget_items WHERE trip_id = ? AND reservation_id = ? ORDER BY id`, every expense linked to one booking (#2084). */ + async listLinkedToReservation(trip_id: number | string, reservation_id: number): Promise<{ total_price: number | null; currency: string | null }[]> { + return await this.kysely() + .selectFrom('budget_items') + .select(['total_price', 'currency']) + .where('trip_id', '=', trip_id as number) + .where('reservation_id', '=', reservation_id) + .orderBy('id', 'asc') + .execute(); + } + + /** BG17's `budget_items` half — `UPDATE budget_items SET currency = ? WHERE trip_id = ? AND (currency IS NULL OR currency = '')`. */ + async pinCurrency(trip_id: number | string, prev_currency: string): Promise { + await this.kysely() + .updateTable('budget_items') + .set({ currency: prev_currency }) + .where('trip_id', '=', trip_id as number) + .where((eb) => eb.or([eb('currency', 'is', null), eb('currency', '=', '')])) + .execute(); + } + + /** BG18's `budget_items` half — `SELECT DISTINCT currency AS cur FROM budget_items WHERE trip_id = ? AND currency IS NOT NULL`. */ + async listDistinctCurrencies(trip_id: number | string): Promise { + const rows = await this.kysely().selectFrom('budget_items').select('currency').distinct().where('trip_id', '=', trip_id as number).where('currency', 'is not', null).execute(); + return rows.map(r => r.currency as string); + } + + /** BG19's `budget_items` half — `UPDATE budget_items SET exchange_rate = ? WHERE trip_id = ? AND currency = ?`. */ + async setExchangeRateForCurrency(trip_id: number | string, currency: string, exchange_rate: number): Promise { + await this.kysely().updateTable('budget_items').set({ exchange_rate }).where('trip_id', '=', trip_id as number).where('currency', '=', currency).execute(); + } + + // --------------------------------------------------------------------------- + // Receipts — budget's own view across `trip_files`/`file_links` (owned by + // Plan 3e Task 1, consumed here the same way `Reservations`/`Places` are + // consumed elsewhere: a cross-table Kysely read/write within the domain + // that needs the join, not a new method on the owning task's repository — + // Task 1's landed `TripFilesRepository`/`FileLinksRepository` do not carry + // the item-scoped shapes this domain needs (see the task report's + // deviation note). + // --------------------------------------------------------------------------- + + /** BG3 (`loadItemReceipts`) — `SELECT f.id, f.filename, f.original_name, f.file_size, f.mime_type, f.trip_id FROM trip_files f JOIN file_links fl ON fl.file_id = f.id WHERE f.deleted_at IS NULL AND fl.budget_item_id = ? ORDER BY f.created_at ASC`. */ + async listReceipts(budget_item_id: number | string): Promise { + return await this.kysely() + .selectFrom('trip_files as f') + .innerJoin('file_links as fl', 'fl.file_id', 'f.id') + .select(['f.id', 'f.filename', 'f.original_name', 'f.file_size', 'f.mime_type', 'f.trip_id']) + .where('f.deleted_at', 'is', null) + .where('fl.budget_item_id', '=', budget_item_id as number) + .orderBy('f.created_at', 'asc') + .execute(); + } + + /** BG13 (`listBudgetItems`'s receipt batch) — same projection as {@link listReceipts} plus `fl.budget_item_id`, `fl.budget_item_id IN (dynamic)`. */ + async listReceiptsForItems(budget_item_ids: number[]): Promise<(BudgetReceiptRow & { budget_item_id: number })[]> { + if (budget_item_ids.length === 0) return []; + return await this.kysely() + .selectFrom('trip_files as f') + .innerJoin('file_links as fl', 'fl.file_id', 'f.id') + .select(['f.id', 'f.filename', 'f.original_name', 'f.file_size', 'f.mime_type', 'f.trip_id', 'fl.budget_item_id']) + .where('f.deleted_at', 'is', null) + .where('fl.budget_item_id', 'in', budget_item_ids) + .orderBy('f.created_at', 'asc') + .execute(); + } + + /** BG7 (`unlinkReceipts`'s pre-read) — `SELECT fl.id, fl.file_id, fl.reservation_id, fl.assignment_id, fl.place_id FROM file_links fl JOIN trip_files f ON f.id = fl.file_id WHERE fl.budget_item_id = ? AND f.deleted_at IS NULL`. */ + async listReceiptLinks(budget_item_id: number | string): Promise { + return await this.kysely() + .selectFrom('file_links as fl') + .innerJoin('trip_files as f', 'f.id', 'fl.file_id') + .select(['fl.id', 'fl.file_id', 'fl.reservation_id', 'fl.assignment_id', 'fl.place_id']) + .where('fl.budget_item_id', '=', budget_item_id as number) + .where('f.deleted_at', 'is', null) + .execute(); + } + + /** BG8 (`unlinkReceipts`) — `UPDATE file_links SET budget_item_id = NULL WHERE id = ?`. */ + async clearLinkBudgetRef(link_id: number): Promise { + await this.kysely().updateTable('file_links').set({ budget_item_id: null }).where('id', '=', link_id).execute(); + } + + /** BG9 (`unlinkReceipts`) — `DELETE FROM file_links WHERE id = ?`. */ + async deleteLink(link_id: number): Promise { + await this.kysely().deleteFrom('file_links').where('id', '=', link_id).execute(); + } + + /** BG29/BG44 — `SELECT id FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NULL`, the receipt id's trip-scoping guard (security-critical). */ + async findTripFile(file_id: number, trip_id: number | string): Promise<{ id: number } | undefined> { + return await this.kysely() + .selectFrom('trip_files') + .select('id') + .where('id', '=', file_id) + .where('trip_id', '=', trip_id as number) + .where('deleted_at', 'is', null) + .executeTakeFirst(); + } + + /** BG28/BG45 — `INSERT OR IGNORE INTO file_links (file_id, budget_item_id) VALUES (?, ?)`. */ + async insertReceiptLink(file_id: number, budget_item_id: number | string): Promise { + await this.kysely<{ file_links: { file_id: number; budget_item_id: number } }>() + .insertInto('file_links') + .values({ file_id, budget_item_id: budget_item_id as number }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + /** BG46 (`updateBudgetItem`'s `adopt`) — `UPDATE file_links SET budget_item_id = ? WHERE id = ?`, re-parenting a spare link row instead of inserting a duplicate. */ + async adoptSpareLink(link_id: number, budget_item_id: number | string): Promise { + await this.kysely().updateTable('file_links').set({ budget_item_id: budget_item_id as number }).where('id', '=', link_id).execute(); + } + + /** BG47 — `SELECT 1 FROM file_links WHERE file_id = ? AND budget_item_id = ?`, the "already this item's receipt" short-circuit. */ + async linkAlreadyExists(file_id: number, budget_item_id: number | string): Promise { + const row = await this.kysely() + .selectFrom('file_links') + .select((eb) => eb.val(1).as('one')) + .where('file_id', '=', file_id) + .where('budget_item_id', '=', budget_item_id as number) + .executeTakeFirst(); + return row !== undefined; + } + + /** BG48 — `SELECT id FROM file_links WHERE file_id = ? AND budget_item_id IS NULL LIMIT 1` (`spare`), drives {@link adoptSpareLink}'s adopt-vs-insert branch. */ + async findSpareLink(file_id: number): Promise<{ id: number } | undefined> { + return await this.kysely().selectFrom('file_links').select('id').where('file_id', '=', file_id).where('budget_item_id', 'is', null).limit(1).executeTakeFirst(); + } + + // --------------------------------------------------------------------------- + // Survivors — AC41/PL15/PL18/PL22/RS48/49/51-54/UC5 (R7). Converted here so + // `accommodations.service.ts`/`places.service.ts`/`reservations.service.ts`/ + // `auth/user-cleanup.service.ts` gain a single repository method call in + // place of the raw statement, never a new SQL string of their own. + // --------------------------------------------------------------------------- + + /** + * AC41 (`deleteAccommodation`) — `SELECT id FROM budget_items WHERE reservation_id = ?`, + * every row: a booking can hold several expenses now (#2084), and the delete takes + * them all. + */ + async listIdsByReservation(reservation_id: number): Promise<{ id: number }[]> { + return await this.kysely().selectFrom('budget_items').select('id').where('reservation_id', '=', reservation_id).execute(); + } + + /** PL15 (`linkedExpenseIds`) — `SELECT id FROM budget_items WHERE trip_id = ? AND place_id IN (dynamic)`. */ + async listIdsForPlaces(trip_id: number | string, place_ids: (number | string)[]): Promise { + if (place_ids.length === 0) return []; + const rows = await this.kysely() + .selectFrom('budget_items') + .select('id') + .where('trip_id', '=', trip_id as number) + .where('place_id', 'in', place_ids as number[]) + .execute(); + return rows.map(r => r.id); + } + + /** PL18/PL22 — `DELETE FROM budget_items WHERE trip_id = ? AND place_id = ?`. */ + async deleteForPlace(trip_id: number | string, place_id: number | string): Promise { + await this.kysely().deleteFrom('budget_items').where('trip_id', '=', trip_id as number).where('place_id', '=', place_id as number).execute(); + } + + /** RS52/RS53 — `SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?`. */ + async findIdByReservationInTrip(trip_id: number | string, reservation_id: number | string): Promise<{ id: number } | undefined> { + return await this.kysely().selectFrom('budget_items').select('id').where('trip_id', '=', trip_id as number).where('reservation_id', '=', reservation_id as number).executeTakeFirst(); + } + + /** + * RS48/RS51 and the RS52 guard (`linkedBudgetItems`) — `SELECT id, category + * FROM budget_items WHERE trip_id = ? AND reservation_id = ?`, every row: a + * booking can hold several expenses now (#2084), so the delete takes them + * all, the type change re-files each one still on the derived category, and + * the price field is left alone once more than one is linked. + */ + async listIdAndCategoryByReservation(trip_id: number | string, reservation_id: number | string): Promise<{ id: number; category: string }[]> { + return await this.kysely() + .selectFrom('budget_items') + .select(['id', 'category']) + .where('trip_id', '=', trip_id as number) + .where('reservation_id', '=', reservation_id as number) + .orderBy('id', 'asc') + .execute(); + } + + /** RS54 — `UPDATE budget_items SET reservation_id = ? WHERE id = ?` (binds the reservation id verbatim — a string in the legacy call site, matching parity). */ + async setReservationId(id: number | string, reservation_id: number | string): Promise { + await this.kysely().updateTable('budget_items').set({ reservation_id: reservation_id as number }).where('id', '=', id as number).execute(); + } + + /** UC5 (`UserCleanupService.cleanupUserReferences`) — `UPDATE budget_items SET paid_by_user_id = NULL WHERE paid_by_user_id = ?`. */ + async clearPaidByUser(user_id: number): Promise { + await this.kysely().updateTable('budget_items').set({ paid_by_user_id: null }).where('paid_by_user_id', '=', user_id).execute(); + } +} diff --git a/server/src/db/repositories/BudgetSettlements.repository.ts b/server/src/db/repositories/BudgetSettlements.repository.ts new file mode 100644 index 000000000..6fe0e9bc8 --- /dev/null +++ b/server/src/db/repositories/BudgetSettlements.repository.ts @@ -0,0 +1,231 @@ +import type { BudgetSettlements } from '../entities/BudgetSettlements.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; +import { presenceSet } from './_shared/presence-set'; + +/** A bare `budget_settlements` row — every scalar column, incl. the `persist(false)` relation mirrors. */ +export interface BudgetSettlementRow { + id: number; + trip_id: number; + from_user_id: number; + to_user_id: number; + amount: number; + created_at: string | null; + created_by_user_id: number | null; + currency: string | null; + exchange_rate: number; + settled_at: string | null; + note: string | null; +} + +const _budgetSettlementRowKeys: AssertRowKeys = true; + +/** `SETTLEMENT_SELECT`'s joined projection (BG76/77). */ +export interface BudgetSettlementWithUsersRow extends BudgetSettlementRow { + from_username: string; + from_avatar: string | null; + to_username: string; + to_avatar: string | null; +} + +interface BudgetSettlementsKyselyDB { + budget_settlements: BudgetSettlementRow; + users: { id: number; username: string; display_name: string | null; avatar: string | null }; +} + +/** + * The insert-only shape — `id`/`created_at` are autoincrement/`DEFAULT + * CURRENT_TIMESTAMP` and omitted from `.values()` below (the legacy + * statement's own column list omits them too), matching the + * `FileLinksWriteKyselyDB`/`FileLinksRepository` precedent: a separate + * interface rather than making those two columns optional on + * {@link BudgetSettlementRow}, which would also loosen every SELECT + * method's inferred type. + */ +interface BudgetSettlementsWriteKyselyDB { + budget_settlements: { + trip_id: number; from_user_id: number; to_user_id: number; amount: number; + currency: string | null; exchange_rate: number; settled_at: string | null; note: string | null; created_by_user_id: number | null; + }; +} + +/** + * `budget_settlements` — recorded settle-up transfers. Kysely throughout, + * same `persist(false)` reasoning as its sibling budget repositories. + */ +export class BudgetSettlementsRepository extends TrekRepository { + private joinedQuery() { + return this.kysely() + .selectFrom('budget_settlements as s') + .innerJoin('users as fu', 'fu.id', 's.from_user_id') + .innerJoin('users as tu', 'tu.id', 's.to_user_id') + .select([ + 's.id', 's.trip_id', 's.from_user_id', 's.to_user_id', 's.amount', 's.currency', 's.exchange_rate', + 's.created_at', 's.settled_at', 's.note', 's.created_by_user_id', + (eb) => eb.fn.coalesce('fu.display_name', 'fu.username').as('from_username'), 'fu.avatar as from_avatar', + (eb) => eb.fn.coalesce('tu.display_name', 'tu.username').as('to_username'), 'tu.avatar as to_avatar', + ]); + } + + /** BG76 (`listSettlements`) — `SETTLEMENT_SELECT WHERE s.trip_id = ? ORDER BY s.created_at DESC, s.id DESC`. */ + async listForTrip(trip_id: number | string): Promise { + return await this.joinedQuery() + .where('s.trip_id', '=', trip_id as number) + .orderBy('s.created_at', 'desc') + .orderBy('s.id', 'desc') + .execute(); + } + + /** + * BG77 (`getSettlement`) — `SETTLEMENT_SELECT WHERE s.trip_id = ? AND s.id + * = ?`, the targeted single-row read. `id: number` (Plan 4 Task 8b, U6 — + * the program's gate-level id parsing carry: `BudgetController + * .updateSettlement`/`.deleteSettlement` parse `:settlementId` once via + * `toRowId` and thread the number down; `trip_id` stays `number | string`, + * a separate, still-accepted carry). + */ + async findWithUsers(id: number, trip_id: number | string): Promise { + return await this.joinedQuery() + .where('s.trip_id', '=', trip_id as number) + .where('s.id', '=', id) + .executeTakeFirst(); + } + + /** BG79/BG81 — `SELECT id FROM budget_settlements WHERE id = ? AND trip_id = ?`, the trip-scoping existence guard. `id: number`, same Plan 4 Task 8b narrowing as {@link findWithUsers}. */ + async findGuard(id: number, trip_id: number | string): Promise<{ id: number } | undefined> { + return await this.kysely() + .selectFrom('budget_settlements') + .select('id') + .where('id', '=', id) + .where('trip_id', '=', trip_id as number) + .executeTakeFirst(); + } + + /** + * BG78 — `INSERT INTO budget_settlements (trip_id, from_user_id, + * to_user_id, amount, currency, exchange_rate, settled_at, note, + * created_by_user_id) VALUES (?×9)`. Named `insertSettlement`, not + * `insert` — collides with `TrekRepository`'s own same-named method. + */ + async insertSettlement(row: { + trip_id: number | string; from_user_id: number; to_user_id: number; amount: number; + currency: string | null; exchange_rate: number; settled_at: string | null; note: string | null; created_by_user_id: number | null; + }): Promise { + const result = await this.kysely() + .insertInto('budget_settlements') + .values({ + trip_id: row.trip_id as number, from_user_id: row.from_user_id, to_user_id: row.to_user_id, amount: row.amount, + currency: row.currency, exchange_rate: row.exchange_rate, settled_at: row.settled_at, note: row.note, created_by_user_id: row.created_by_user_id, + }) + .executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** + * BG80 (`applySettlementUpdate`) — the 7-column presence-sentinel `UPDATE` + * (`from_user_id`/`to_user_id`/`amount` unconditional, `currency`/ + * `exchange_rate`/`settled_at`/`note` presence-gated) — R11's helper, the same + * shape `BudgetItemsRepository.update` (BG33) lands. + */ + /** `id: number`, same Plan 4 Task 8b narrowing as {@link findWithUsers} (its one caller, `applySettlementUpdate`, is only reached with a `toRowId`-parsed id). */ + async update(id: number, write: { + from_user_id: number; to_user_id: number; amount: number; + currency?: [present: boolean, value: string | null]; + exchange_rate?: [present: boolean, value: number]; + settled_at?: [present: boolean, value: string | null]; + note?: [present: boolean, value: string | null]; + }): Promise { + const data = presenceSet<{ currency: string | null; exchange_rate: number; settled_at: string | null; note: string | null }>({ + currency: write.currency, + exchange_rate: write.exchange_rate, + settled_at: write.settled_at, + note: write.note, + }); + await this.kysely() + .updateTable('budget_settlements') + .set({ from_user_id: write.from_user_id, to_user_id: write.to_user_id, amount: write.amount, ...data }) + .where('id', '=', id) + .execute(); + } + + /** BG82 — `DELETE FROM budget_settlements WHERE id = ?`. `id: number`, same Plan 4 Task 8b narrowing as {@link findWithUsers}. */ + async deleteById(id: number): Promise { + await this.kysely().deleteFrom('budget_settlements').where('id', '=', id).execute(); + } + + /** BG17's `budget_settlements` half — `UPDATE budget_settlements SET currency = ? WHERE trip_id = ? AND (currency IS NULL OR currency = '')`. */ + async pinCurrency(trip_id: number | string, prev_currency: string): Promise { + await this.kysely() + .updateTable('budget_settlements') + .set({ currency: prev_currency }) + .where('trip_id', '=', trip_id as number) + .where((eb) => eb.or([eb('currency', 'is', null), eb('currency', '=', '')])) + .execute(); + } + + /** BG18's `budget_settlements` half — `SELECT DISTINCT currency AS cur FROM budget_settlements WHERE trip_id = ? AND currency IS NOT NULL`. */ + async listDistinctCurrencies(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('budget_settlements') + .select('currency') + .distinct() + .where('trip_id', '=', trip_id as number) + .where('currency', 'is not', null) + .execute(); + return rows.map(r => r.currency as string); + } + + /** + * BG90's `budget_settlements` half (`freezeMissingRates`) — `SELECT + * UPPER(currency) AS cur FROM budget_settlements WHERE trip_id = ? AND + * currency IS NOT NULL AND currency != '' AND UPPER(currency) != ? AND + * (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`. + * `DISTINCT` stands in for the dedupe the legacy UNION did. + */ + async listUnfrozenForeignCurrencies(trip_id: number | string, trip_currency: string): Promise { + const rows = await this.kysely() + .selectFrom('budget_settlements') + .select((eb) => eb.fn('upper', ['currency']).as('cur')) + .distinct() + .where('trip_id', '=', trip_id as number) + .where('currency', 'is not', null) + .where('currency', '!=', '') + .where((eb) => eb(eb.fn('upper', ['currency']), '!=', trip_currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + return rows.map(r => r.cur); + } + + /** BG91's `budget_settlements` half (`freezeMissingRates`) — `SELECT id FROM budget_settlements WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`. */ + async listUnfrozenIdsForCurrency(trip_id: number | string, currency: string): Promise { + const rows = await this.kysely() + .selectFrom('budget_settlements') + .select('id') + .where('trip_id', '=', trip_id as number) + .where((eb) => eb(eb.fn('upper', ['currency']), '=', currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + return rows.map(r => r.id); + } + + /** BG92's `budget_settlements` half (`freezeMissingRates`) — `UPDATE budget_settlements SET exchange_rate = ? WHERE trip_id = ? AND UPPER(currency) = ? AND (exchange_rate IS NULL OR exchange_rate <= 0 OR exchange_rate = 1)`, the compare-and-set write. */ + async freezeUnfrozenForCurrency(trip_id: number | string, currency: string, exchange_rate: number): Promise { + await this.kysely() + .updateTable('budget_settlements') + .set({ exchange_rate }) + .where('trip_id', '=', trip_id as number) + .where((eb) => eb(eb.fn('upper', ['currency']), '=', currency)) + .where((eb) => eb.or([eb('exchange_rate', 'is', null), eb('exchange_rate', '<=', 0), eb('exchange_rate', '=', 1)])) + .execute(); + } + + /** BG19's `budget_settlements` half — `UPDATE budget_settlements SET exchange_rate = ? WHERE trip_id = ? AND currency = ?`. */ + async setExchangeRateForCurrency(trip_id: number | string, currency: string, exchange_rate: number): Promise { + await this.kysely() + .updateTable('budget_settlements') + .set({ exchange_rate }) + .where('trip_id', '=', trip_id as number) + .where('currency', '=', currency) + .execute(); + } +} diff --git a/server/src/db/repositories/Categories.repository.ts b/server/src/db/repositories/Categories.repository.ts new file mode 100644 index 000000000..c2ac06d2a --- /dev/null +++ b/server/src/db/repositories/Categories.repository.ts @@ -0,0 +1,148 @@ +import type { Categories } from '../entities/Categories.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `categories` row as the API emits it. */ +export interface CategoryRow { + id: number; + name: string; + color: string | null; + icon: string | null; + user_id: number | null; + created_at: string | null; +} + +const _categoryRowKeys: AssertRowKeys = true; + +export class CategoriesRepository extends TrekRepository { + /** `SELECT * FROM categories ORDER BY name ASC` */ + async list(): Promise { + const categories = await this.find({}, { orderBy: { name: 'asc' } }); + return categories.map((category) => toRow(category) as CategoryRow); + } + + /** + * `share.service.ts:396` SH15 (`getSharedTripData`) — `SELECT * FROM + * categories`, no `ORDER BY` (unlike `list()` above, which adds `ORDER BY + * name ASC` for the authenticated `/api/categories` list — the legacy + * public-share statement never sorted). Unscoped by trip on purpose: "a + * shared global pool", the same statement the authed list reads from. + */ + async listAllUnordered(): Promise { + const categories = await this.find({}); + return categories.map((category) => toRow(category) as CategoryRow); + } + + /** + * PL33 (`PlacesService.importKmlPlaces`) — `SELECT id, name FROM + * categories`, the id/name-only projection the KML importer folds into + * `buildCategoryNameLookup` to resolve a folder name to a category id. No + * `ORDER BY` in the legacy statement — none added here. + */ + async listIdName(): Promise<{ id: number; name: string }[]> { + return this.qb('c') + .select(['c.id', 'c.name']) + .execute<{ id: number; name: string }[]>('all', false); + } + + /** + * `SELECT * FROM categories WHERE id = ?` — no owner filter. `user_id` + * being nullable makes a row *look* ownable, but this legacy statement + * never scoped by it: any id answers regardless of who created the row. + * Plain PK `findOne`, not `findOwnedOrGlobal` — that helper had no caller + * across categories or tags (neither site actually filters "owner or + * global") and was deleted rather than kept as dead code (Task 0 + * re-review, R6; see `_shared/owned-lookup.ts`'s docstring). + * + * `disableIdentityMap: true` by the base class's default (`_shared/ + * trek-repository.ts`, Plan 3b interlude B — supersedes the per-call + * option this repository used to pass itself, which superseded Plan 3a's + * I1 "`refresh: true` on every PK-only `findOne`"; see + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): this is + * a "rows out" read, converted via `toRow` and discarded — the entity is + * loaded in a throwaway forked context and never lands in the request's + * identity map, so a raw write to this row earlier in the same request is + * always seen, and there is nothing here for a later `flush()` to find + * dirty. + */ + async findById(id: number): Promise { + const category = await this.findOne({ id }); + return category ? (toRow(category) as CategoryRow) : null; + } + + /** + * The column set of the legacy INSERT: + * `INSERT INTO categories (name, color, icon, user_id) VALUES (?, ?, ?, ?)`. + * The `#6366f1`/`📍` defaulting stays in `CategoriesService` (D4's defaults + * rule) — this writes exactly what it is given. + * + * `this.insert` (Plan 3b interlude B, finishing F2's sweep — Task 1 + * fix round's re-review flagged this method as a leftover whole-request- + * UnitOfWork `flush()`), never `create()` + `persist().flush()`: + * `flush()` commits the *whole* unit of work of the request's + * `EntityManager`, not just this row. `insert` fires a single native + * INSERT with no side effects on the context/identity map, followed by a + * read-back (`findById`, `disableIdentityMap: true` by the base class's + * default): the returned row carries the generated `id` and the + * `defaultRaw` `created_at` the same way a legacy INSERT-then-reselect + * pair did. + */ + async createCategory(input: { name: string; color: string; icon: string; user_id: number }): Promise { + const id = await this.insert({ + name: input.name, + color: input.color, + icon: input.icon, + user: input.user_id, + }); + const inserted = await this.findById(id); + if (!inserted) { + throw new Error('createCategory: read-back after insert found no row'); + } + return inserted; + } + + /** + * `UPDATE categories SET name = COALESCE(?, name), color = COALESCE(?, + * color), icon = COALESCE(?, icon) WHERE id = ?`, re-selected. + * `nativeUpdate` only writes the keys present on `changes` — the + * entity-API equivalent of `COALESCE` against a bind parameter — so the + * caller must omit a field entirely (not pass it as `undefined`) to leave + * it untouched, which is how `CategoriesService` reproduces the legacy + * `|| null` coalesce-away-empty-string behaviour. Returns `null` when no + * row matches `id`, mirroring the legacy re-select's `undefined`. + * + * **Converted from `findOne({ refresh: true })` + `assign` + `flush()` + * to `nativeUpdate` + a typed partial (Plan 3b interlude B, per + * `task-1-rereview.md`'s ruling on this method: "convert them, don't + * complete the sweep on the read").** `nativeUpdate` writes the given + * columns directly, with no ORM change-set diff against an in-memory + * snapshot — that removes the whole class of staleness the old + * `refresh: true` lookup existed to guard against (Task 3 review, + * Important 1: an untouched column reading stale, a patch silently + * dropped because it happened to match a stale in-memory value, and a + * patch after `remove()` fabricating a row), not just the one B1 shape + * the rest of the program's `disableIdentityMap: true` ruling closes. When + * `changes` is non-empty, `nativeUpdate`'s own affected-row count IS the + * existence check (0 ⇒ `null`, no separate read); when `changes` is empty + * (nothing to write), a plain existence read (`disableIdentityMap: true` + * by the base class's default, like every other read in this repository + * now) stands in for it. Either way the return value is a fresh + * `findById` — a genuine rows-out read, not the entity `assign` just + * mutated in place. + */ + async patch(id: number, changes: { name?: string; color?: string; icon?: string }): Promise { + if (Object.keys(changes).length > 0) { + const affected = await this.nativeUpdate({ id }, changes); + if (affected === 0) return null; + } else { + const existing = await this.findOne({ id }, { fields: ['id'] }); + if (!existing) return null; + } + return this.findById(id); + } + + /** `DELETE FROM categories WHERE id = ?`. Returns the affected row count. */ + async remove(id: number): Promise { + return this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/CollabLinks.repository.ts b/server/src/db/repositories/CollabLinks.repository.ts new file mode 100644 index 000000000..bc32f05a4 --- /dev/null +++ b/server/src/db/repositories/CollabLinks.repository.ts @@ -0,0 +1,93 @@ +import type { CollabLinks } from '../entities/CollabLinks.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `collab_links` row — every scalar column of the entity, incl. the two `persist(false)` relation mirrors (`trip_id`, `user_id`). */ +export interface CollabLinkRow { + id: number; + trip_id: number; + user_id: number; + title: string; + url: string; + pinned: number | null; + created_at: string | null; + updated_at: string | null; +} + +const _collabLinkRowKeys: AssertRowKeys = true; + +/** CB36/CB38/CB41's joined projection — `l.*, u.username`. */ +export interface CollabLinkJoinRow extends CollabLinkRow { + username: string; +} + +interface CollabLinksKyselyDB { + collab_links: CollabLinkRow; + users: { id: number; username: string }; +} + +/** + * `collab_links` — pinned/shared trip links. Kysely throughout: `trip_id`/ + * `user_id` are `persist(false)` mirrors (the `TripFilesRepository` class + * docstring's trap), so a bare QueryBuilder `.select([...])` silently drops + * them. + */ +export class CollabLinksRepository extends TrekRepository { + private joinedQuery() { + return this.kysely() + .selectFrom('collab_links as l') + .innerJoin('users as u', 'u.id', 'l.user_id') + .selectAll('l') + .select(['u.username']); + } + + /** + * CB36 (`listLinks`) — `SELECT l.*, u.username FROM collab_links l JOIN + * users u ON u.id = l.user_id WHERE l.trip_id = ? ORDER BY l.pinned DESC, + * l.created_at DESC`. `trip_id: number`: the service resolves the route's + * `string | number` id via `toRowId` before calling (rule 15). + */ + async listForTrip(trip_id: number): Promise { + return await this.joinedQuery().where('l.trip_id', '=', trip_id).orderBy('l.pinned', 'desc').orderBy('l.created_at', 'desc').execute(); + } + + /** CB39 (`updateLink`'s trip-scoping guard) — `SELECT * FROM collab_links WHERE id = ? AND trip_id = ?`. Full row: the falsy-coercion update (CB40) needs the pre-image to fall back onto. */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.kysely().selectFrom('collab_links').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst(); + } + + /** CB37 (`createLink`) — `INSERT INTO collab_links (trip_id, user_id, title, url, pinned) VALUES (?×5)`. The service trims `title`/`url` before calling (matching the legacy inline `.trim()`). Returns the new row's id. */ + async insertLink(row: { trip_id: number | string; user_id: number; title: string; url: string; pinned: number }): Promise { + return await this.insert({ trip: row.trip_id, user: row.user_id, title: row.title, url: row.url, pinned: row.pinned }); + } + + /** + * CB40 (`updateLink`) — `UPDATE collab_links SET title = ?, url = ?, + * pinned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`. + * The ONE outlier in this cluster: `|| existing.x` falsy-coercion, NOT the + * presence-sentinel `CASE` shape `CollabNotesRepository.update`/`CB11` + * uses (task-5-brief.md's explicit instruction — kept as its own distinct + * shape). The SERVICE resolves `data.title?.trim() || existing.title` / + * `data.url?.trim() || existing.url` / `data.pinned === undefined ? + * existing.pinned : (data.pinned ? 1 : 0)` to final values first (same + * read-merge-write split as {@link CollabNotesRepository.update}) — this + * method just writes them, scoped by BOTH `id` AND `trip_id` (unlike + * CB11's notes update, which the legacy only scopes by `id`). + */ + async update(id: number, trip_id: number, write: { title: string; url: string; pinned: number }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id, trip: trip_id }, { title: write.title, url: write.url, pinned: write.pinned, updated_at: currentTimestamp(platform) }); + } + + /** CB38/CB41 (`createLink`'s post-insert re-select, `updateLink`'s post-write re-select) — `SELECT l.*, u.username FROM collab_links l JOIN users u ON u.id = l.user_id WHERE l.id = ?`, same text at both call sites. */ + async findWithUser(id: number): Promise { + return await this.joinedQuery().where('l.id', '=', id).executeTakeFirst(); + } + + /** CB42 (`deleteLink`) — `DELETE FROM collab_links WHERE id = ? AND trip_id = ?`, returns whether a row was actually removed (legacy `.changes > 0`). */ + async deleteScoped(id: number, trip_id: number): Promise { + const affected = await this.nativeDelete({ id, trip: trip_id }); + return affected > 0; + } +} diff --git a/server/src/db/repositories/CollabMessageReactions.repository.ts b/server/src/db/repositories/CollabMessageReactions.repository.ts new file mode 100644 index 000000000..d0a65aa08 --- /dev/null +++ b/server/src/db/repositories/CollabMessageReactions.repository.ts @@ -0,0 +1,74 @@ +import type { CollabMessageReactions } from '../entities/CollabMessageReactions.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** CB1/CB46's joined projection — `r.emoji, r.user_id, u.username` (CB46 additionally carries `message_id`, the batch key). */ +export interface ReactionJoinRow { + message_id: number; + emoji: string; + user_id: number; + username: string; +} + +export class CollabMessageReactionsRepository extends TrekRepository { + /** + * CB1 (`CollabService.loadReactions`) — `SELECT r.emoji, r.user_id, + * u.username FROM collab_message_reactions r JOIN users u ON r.user_id = + * u.id WHERE r.message_id = ?`. `r.user_id`/`r.message_id` are + * `persist(false)` mirrors of the `message`/`user` relations — selecting + * the relation property (not the mirror) is what actually hydrates it + * (`PlaceRatingsRepository.listForPlaces`'s docstring, the same trap). + */ + async listForMessage(message_id: number): Promise[]> { + return this.qb('r') + .join('r.user', 'u') + .select(['r.emoji', 'u.id as user_id', 'u.username']) + .where({ message: message_id }) + .execute[]>('all', false); + } + + /** + * CB46 (`CollabService.listMessages`'s batch reactions) — `SELECT + * r.message_id, r.emoji, r.user_id, u.username FROM collab_message_reactions + * r JOIN users u ON r.user_id = u.id WHERE r.message_id IN (dynamic)`. + * Empty `message_ids` short-circuits, matching the service's own guard + * (`if (msgIds.length > 0)`). + */ + async listForMessages(message_ids: number[]): Promise { + if (message_ids.length === 0) return []; + return this.qb('r') + .join('r.user', 'u') + .select(['r.message', 'r.emoji', 'u.id as user_id', 'u.username']) + .where({ 'r.message': { $in: message_ids } }) + .execute('all', false); + } + + /** + * CB3 (`CollabService.reactMessage`'s toggle check) — `SELECT id FROM + * collab_message_reactions WHERE message_id = ? AND user_id = ? AND + * emoji = ?`. Named `findReaction`, not `find`: `find` is `EntityRepository`'s + * own base method (a different, incompatible signature) and a same-named + * override does not typecheck. + */ + async findReaction(message_id: number, user_id: number, emoji: string): Promise<{ id: number } | null> { + return this.findOne({ message: message_id, user: user_id, emoji }, { fields: ['id'] }); + } + + /** + * CB4 (`reactMessage`'s toggle-off half) — `DELETE FROM + * collab_message_reactions WHERE id = ?`. Deliberately NOT wrapped in a + * transaction with {@link insertReaction}/{@link find}: the legacy + * check-then-toggle (CB3→CB4/CB5) has no `uow.transactional` either — a + * duplicate/missing reaction under a race is cosmetic, not the + * data-integrity class of defect the R2/`toggleMemberPaid` fixes address + * (task-5-brief.md's own ruling; left as-is rather than "fixed" + * unilaterally). + */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** CB5 (`reactMessage`'s toggle-on half) — `INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)`. */ + async insertReaction(message_id: number, user_id: number, emoji: string): Promise { + await this.insert({ message: message_id, user: user_id, emoji }); + } +} diff --git a/server/src/db/repositories/CollabMessages.repository.ts b/server/src/db/repositories/CollabMessages.repository.ts new file mode 100644 index 000000000..fc74dc207 --- /dev/null +++ b/server/src/db/repositories/CollabMessages.repository.ts @@ -0,0 +1,229 @@ +import type { CollabMessages } from '../entities/CollabMessages.entity'; +import type { TripFileRow } from './TripFiles.repository'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `collab_messages` row — every scalar column of the entity, incl. the three `persist(false)` mirrors (`trip_id`, `user_id`, `reply_to`). */ +export interface CollabMessageRow { + id: number; + trip_id: number; + user_id: number; + text: string; + reply_to: number | null; + created_at: string | null; + deleted: number | null; +} + +const _collabMessageRowKeys: AssertRowKeys = true; + +/** CB45/CB50's joined projection — `m.*, u.username, u.avatar, reply_text, reply_username`. */ +export interface CollabMessageJoinRow extends CollabMessageRow { + username: string; + avatar: string | null; + reply_text: string | null; + reply_username: string | null; +} + +/** {@link CollabMessagesRepository.listPublicForShare}'s projection (SH16) — `m.*, u.username, u.avatar` only. */ +export interface SharePublicCollabMessageRow extends CollabMessageRow { + username: string; + avatar: string | null; +} + +interface CollabMessagesKyselyDB { + collab_messages: CollabMessageRow; + users: { id: number; username: string; avatar: string | null }; +} + +/** Only the `trip_files` columns the message-attachment methods below touch — the `CollabNotesRepository` class docstring explains why they live on the OWNING collab entity's repository rather than on `TripFilesRepository`. */ +interface MessageAttachmentsKyselyDB { + trip_files: TripFileRow; +} + +/** CB43's narrow attachment projection (`formatMessage`'s hydration read). `message_id` keeps `TripFileRow`'s own nullable typing even though the WHERE clause below always matches a non-null value — no narrowing cast. */ +export interface MessageAttachmentRow { + id: number; + trip_id: number; + message_id: number | null; + filename: string; + original_name: string; + file_size: number | null; + mime_type: string | null; +} + +/** CB49's insert-only shape — every OTHER `trip_files` column is autoincrement/nullable-defaulted and omitted, matching the legacy statement's own seven-column list (the `NoteAttachmentInsertKyselyDB` precedent, `CollabNotes.repository.ts`). */ +interface MessageAttachmentInsertKyselyDB { + trip_files: { + trip_id: number | string; + message_id: number | string; + filename: string; + original_name: string; + file_size: number; + mime_type: string; + uploaded_by: number; + }; +} + +/** + * `collab_messages` — trip chat, plus (second half of this class) the + * `trip_files` rows a message owns as attachments. Kysely throughout for + * `collab_messages` itself: `trip_id`/`user_id`/`reply_to` are + * `persist(false)` mirrors (the `TripFilesRepository` class docstring's + * trap). + */ +export class CollabMessagesRepository extends TrekRepository { + /** + * CB45/CB50's shared shape — `m.*, u.username, u.avatar, CASE WHEN + * rm.deleted = 1 THEN '' ELSE rm.text END AS reply_text, ru.username AS + * reply_username FROM collab_messages m JOIN users u ON m.user_id = u.id + * LEFT JOIN collab_messages rm ON m.reply_to = rm.id LEFT JOIN users ru ON + * rm.user_id = ru.id`. A genuine self-join (`rm`/`ru` are second aliases + * of `collab_messages`/`users`), expressed as a second Kysely alias rather + * than the ORM's `replyToRef` relation: `CollabMessages.entity.ts` DOES + * have a real `replyToRef` manyToOne(CollabMessages) relation (Task 0's + * dialect finding, `progress.md`), but populating it through the + * QueryBuilder would hydrate a full `CollabMessages` entity for the reply + * (and a SEPARATE query/join for `ru.username`, since `replyToRef` doesn't + * carry the reply's own author's username) rather than the two flat scalar + * columns (`reply_text`, `reply_username`) this statement actually + * projects — the `Reservations.repository.ts#listUpcomingForUser` + * self-join precedent (`reservations as r2`) is the exact same shape: + * Kysely, not a relation walk, for a flat projected self-join. + */ + private joinedQuery() { + return this.kysely() + .selectFrom('collab_messages as m') + .innerJoin('users as u', 'u.id', 'm.user_id') + .leftJoin('collab_messages as rm', 'rm.id', 'm.reply_to') + .leftJoin('users as ru', 'ru.id', 'rm.user_id') + .selectAll('m') + .select((eb) => [ + 'u.username', + 'u.avatar', + eb.case().when('rm.deleted', '=', 1).then('').else(eb.ref('rm.text')).end().as('reply_text'), + 'ru.username as reply_username', + ]); + } + + /** + * CB45 (`listMessages`) — the joined shape above, `WHERE m.trip_id = ?[ + * AND m.id < ?] ORDER BY m.id DESC LIMIT 100`. `before` undefined omits + * the cursor filter entirely (matching the legacy's conditional extra + * param), not `>= 0`/`null` — the same shape the legacy ternary between + * two whole query strings expressed. + */ + async listForTrip(trip_id: number, before?: number): Promise { + let q = this.joinedQuery().where('m.trip_id', '=', trip_id); + if (before !== undefined) q = q.where('m.id', '<', before); + return await q.orderBy('m.id', 'desc').limit(100).execute(); + } + + /** CB50 (`createMessage`'s post-tx re-select) — the joined shape above, `WHERE m.id = ?`, no trip filter (the caller just wrote this exact id inside the transaction). */ + async findWithReply(id: number): Promise { + return await this.joinedQuery().where('m.id', '=', id).executeTakeFirst(); + } + + /** + * `share.service.ts:400` SH16 (`getSharedTripData`'s share_collab read) — + * `SELECT m.*, u.username, u.avatar FROM collab_messages m JOIN users u + * ON m.user_id = u.id WHERE m.trip_id = ? AND m.deleted = 0 ORDER BY + * m.created_at`. A narrower projection than {@link joinedQuery} above (no + * `reply_text`/`reply_username` — a public share viewer never resolves a + * reply thread through this read) and a different order/shape than + * `listForTrip` (ascending, no cursor, no `LIMIT`). + */ + async listPublicForShare(trip_id: number): Promise { + return await this.kysely() + .selectFrom('collab_messages as m') + .innerJoin('users as u', 'u.id', 'm.user_id') + .selectAll('m') + .select(['u.username', 'u.avatar']) + .where('m.trip_id', '=', trip_id) + .where('m.deleted', '=', 0) + .orderBy('m.created_at', 'asc') + .execute(); + } + + /** + * CB2/CB51 (`reactMessage`'s existence guard, `deleteMessage`'s + * trip-scoping + owner-check read) — `SELECT * FROM collab_messages WHERE + * id = ? AND trip_id = ?` (CB2's own text is `SELECT id FROM ...`, a + * narrower projection of the SAME WHERE — reading the full row for both + * call sites is behaviourally identical since CB2 only tests truthiness). + * `SELECT *` here has NO `username` column (`collab_messages` alone, no + * join) — `deleteMessage`'s legacy `return { username: message.username + * }` reads a column this statement never selected; see the `CollabService + * .deleteMessage` docstring for how that pre-existing gap is preserved. + */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.kysely().selectFrom('collab_messages').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst(); + } + + /** CB47 (`createMessage`'s reply-target validation) — `SELECT id FROM collab_messages WHERE id = ? AND trip_id = ? AND deleted = 0`. A soft-deleted message is gone as far as anyone replying is concerned. */ + async findActiveInTrip(id: number, trip_id: number): Promise<{ id: number } | undefined> { + return await this.kysely().selectFrom('collab_messages').select(['id']).where('id', '=', id).where('trip_id', '=', trip_id).where('deleted', '=', 0).executeTakeFirst(); + } + + /** CB44 (`countMessages`) — `SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?`. */ + async countForTrip(trip_id: number): Promise { + return await this.count({ trip: trip_id }); + } + + /** CB48 (`createMessage`, inside its transaction) — `INSERT INTO collab_messages (trip_id, user_id, text, reply_to) VALUES (?×4)`. Returns the new row's id. */ + async insertMessage(trip_id: number | string, user_id: number, text: string, reply_to: number | null): Promise { + return await this.insert({ trip: trip_id, user: user_id, text, reply_to }); + } + + /** CB54 (`deleteMessage`, inside its transaction) — `UPDATE collab_messages SET deleted = 1 WHERE id = ?`, soft delete — the row survives for the placeholder. */ + async softDelete(id: number): Promise { + await this.nativeUpdate({ id }, { deleted: 1 }); + } + + // ------------------------------------------------------------------------- + // Message attachments (`trip_files` scoped by `message_id`) — see + // `CollabNotesRepository`'s class docstring for why these live here + // rather than on `TripFilesRepository`. + // ------------------------------------------------------------------------- + + /** CB43 (`formatMessage`'s hydration read) — `SELECT id, trip_id, message_id, filename, original_name, file_size, mime_type FROM trip_files WHERE message_id = ? AND trip_id = ? AND deleted_at IS NULL ORDER BY id ASC`. */ + async listAttachmentsForMessage(message_id: number, trip_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .select(['id', 'trip_id', 'message_id', 'filename', 'original_name', 'file_size', 'mime_type']) + .where('message_id', '=', message_id) + .where('trip_id', '=', trip_id) + .where('deleted_at', 'is', null) + .orderBy('id', 'asc') + .execute(); + } + + /** + * CB49 (`createMessage`, inside its transaction, once per uploaded file) — + * `INSERT INTO trip_files (trip_id, message_id, filename, original_name, + * file_size, mime_type, uploaded_by) VALUES (?×7)`. + */ + async insertAttachmentForMessage(row: { trip_id: number | string; message_id: number | string; filename: string; original_name: string; file_size: number; mime_type: string; uploaded_by: number }): Promise { + await this.kysely() + .insertInto('trip_files') + .values({ + trip_id: row.trip_id, + message_id: row.message_id, + filename: row.filename, + original_name: row.original_name, + file_size: row.file_size, + mime_type: row.mime_type, + uploaded_by: row.uploaded_by, + }) + .execute(); + } + + /** CB52 (`deleteMessage`'s pre-tx storage-cleanup list) — `SELECT filename FROM trip_files WHERE message_id = ? AND trip_id = ?`, run BEFORE the transaction (the legacy ordering). */ + async listFilenamesForMessage(message_id: number, trip_id: number): Promise<{ filename: string }[]> { + return await this.kysely().selectFrom('trip_files').select(['filename']).where('message_id', '=', message_id).where('trip_id', '=', trip_id).execute(); + } + + /** CB53 (`deleteMessage`, inside its transaction, before CB54) — `DELETE FROM trip_files WHERE message_id = ? AND trip_id = ?`. */ + async deleteAttachmentsForMessage(message_id: number, trip_id: number): Promise { + await this.kysely().deleteFrom('trip_files').where('message_id', '=', message_id).where('trip_id', '=', trip_id).execute(); + } +} diff --git a/server/src/db/repositories/CollabNotes.repository.ts b/server/src/db/repositories/CollabNotes.repository.ts new file mode 100644 index 000000000..4a67f5ba2 --- /dev/null +++ b/server/src/db/repositories/CollabNotes.repository.ts @@ -0,0 +1,256 @@ +import type { CollabNotes } from '../entities/CollabNotes.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import type { TripFileRow } from './TripFiles.repository'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `collab_notes` row — every scalar column of the entity, incl. the two `persist(false)` relation mirrors (`trip_id`, `user_id`). */ +export interface CollabNoteRow { + id: number; + trip_id: number; + user_id: number; + category: string | null; + title: string; + content: string | null; + color: string | null; + pinned: number | null; + created_at: string | null; + updated_at: string | null; + website: string | null; +} + +const _collabNoteRowKeys: AssertRowKeys = true; + +/** CB7/CB9/CB12/CB20's joined projection — `n.*, u.username, u.avatar`. */ +export interface CollabNoteJoinRow extends CollabNoteRow { + username: string; + avatar: string | null; +} + +/** CB6's narrow attachment projection (`formatNote`'s hydration read). */ +export interface NoteAttachmentRow { + id: number; + filename: string; + original_name: string; + file_size: number | null; + mime_type: string | null; +} + +interface CollabNotesKyselyDB { + collab_notes: CollabNoteRow; + users: { id: number; username: string; avatar: string | null }; +} + +/** Only the `trip_files` columns the note-attachment methods below touch — see the class docstring's second half. */ +interface NoteAttachmentsKyselyDB { + trip_files: TripFileRow; +} + +/** + * CB18's insert-only shape — `id`/`place_id`/`reservation_id`/`description`/ + * `created_at`/`uploaded_by`/`starred`/`deleted_at`/`message_id` are + * autoincrement/nullable-defaulted and omitted from `.values()` below (the + * legacy statement's own six-column list omits them too), so a SEPARATE + * interface from {@link NoteAttachmentsKyselyDB} rather than widening that + * one's required columns (the `TripAlbumLinksInsertKyselyDB` / + * `FileLinksWriteKyselyDB` precedent: one purpose-shaped Kysely interface per + * statement). + */ +interface NoteAttachmentInsertKyselyDB { + trip_files: { + trip_id: number | string; + note_id: number | string; + filename: string; + original_name: string; + file_size: number; + mime_type: string; + }; +} + +/** + * `collab_notes` — shared trip notes, plus (second half of this class) the + * `trip_files` rows a note owns as attachments. + * + * Kysely throughout for `collab_notes` itself: `trip_id`/`user_id` are + * `persist(false)` mirrors (the `TripFilesRepository` class docstring's + * trap), so a bare QueryBuilder `.select([...])` silently drops them — + * `selectAll()`/a typed column list reads the real DB columns directly. + * + * **Why the note-attachment methods (CB6/CB14/CB15/CB18/CB19/CB21/CB22) live + * here instead of on `TripFilesRepository`.** The inventory's own proposal + * (`plan3e-sql-inventory.md` §4's "Proposed repositories" note) expected + * `TripFilesRepository` to grow `listForNote`/`deleteForNote`/`findScoped`/ + * `listForMessage`/`insertForMessage`/`deleteForMessage`. Task 1 (files) + * landed first (`399ca58a7`) and did NOT add those — its report lists only + * `findInTrip`/`findDeletedInTrip`/`listForTrip`/`findByIdWithJoins`/ + * `listTrashed`/`insertFile`/`updateFile`/`setStarred`/`softDelete`/ + * `restore`/`deleteById`/`deleteMany`, none scoped by `note_id`/`message_id`. + * `TripFiles.repository.ts` is Task 1's named file and this task's brief + * says "you read TripFilesRepository..., you do not edit them" — so rather + * than editing a landed, other-task-owned file, the note-scoped queries are + * expressed here (and the message-scoped twins on `CollabMessages + * .repository.ts`) via `this.kysely()` against the `trip_files` table + * directly, the same "a repository touches more than its own table via + * Kysely" shape `TripFilesRepository.joinedQuery` itself uses for + * `reservations`/`users`. `TripFileRow`'s TYPE is imported (read-only) from + * `TripFiles.repository.ts` for column-shape parity — no write to that file. + * Flagged in the task-5 report as a deviation from the inventory's proposal, + * not a defect. + */ +export class CollabNotesRepository extends TrekRepository { + private joinedQuery() { + return this.kysely() + .selectFrom('collab_notes as n') + .innerJoin('users as u', 'u.id', 'n.user_id') + .selectAll('n') + .select(['u.username', 'u.avatar']); + } + + /** + * CB7 (`CollabService.listNotes`) — `SELECT n.*, u.username, u.avatar FROM + * collab_notes n JOIN users u ON n.user_id = u.id WHERE n.trip_id = ? + * ORDER BY n.pinned DESC, n.updated_at DESC`. `trip_id: number`: the + * service resolves the route's `string | number` id via `toRowId` before + * calling (rule 15), same as every other read below — a malformed id + * short-circuits to the "not found"/empty legacy result without reaching + * this query at all. + */ + async listForTrip(trip_id: number): Promise { + return await this.joinedQuery().where('n.trip_id', '=', trip_id).orderBy('n.pinned', 'desc').orderBy('n.updated_at', 'desc').execute(); + } + + /** CB10/CB13/CB17 (`updateNote`/`deleteNote`/`addNoteFile`'s shared trip-scoping guard) — `SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?`, same text at all three call sites. */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.kysely().selectFrom('collab_notes').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst(); + } + + /** CB9/CB12 (`createNote`'s post-insert re-select, `updateNote`'s post-write re-select) — `SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?`, no trip filter (the caller already knows `id` is in-scope, having just written it). */ + async findWithUser(id: number): Promise { + return await this.joinedQuery().where('n.id', '=', id).executeTakeFirst(); + } + + /** CB20 (`getFormattedNoteById`) — same shape as {@link findWithUser} plus the trip scope (`n.trip_id = ?`) the legacy statement adds at this one call site (the post-migration hardening the class docstring on `CollabService` describes). */ + async findWithUserInTrip(id: number, trip_id: number): Promise { + return await this.joinedQuery().where('n.id', '=', id).where('n.trip_id', '=', trip_id).executeTakeFirst(); + } + + /** CB8 (`createNote`) — `INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website, pinned) VALUES (?×8)`. The service resolves the `|| null`/`|| 'General'`/`|| '#6366f1'` defaults before calling (D2's split). Returns the new row's id. */ + async insertNote(row: { trip_id: number | string; user_id: number; title: string; content: string | null; category: string; color: string; website: string | null; pinned: number }): Promise { + return await this.insert({ + trip: row.trip_id, + user: row.user_id, + title: row.title, + content: row.content, + category: row.category, + color: row.color, + website: row.website, + pinned: row.pinned, + }); + } + + /** + * CB11 (`updateNote`) — the 6-column `UPDATE collab_notes SET title = + * COALESCE(?, title), content = CASE WHEN ? THEN ? ELSE content END, + * category = COALESCE(?, category), color = COALESCE(?, color), pinned = + * CASE WHEN ? IS NOT NULL THEN ? ELSE pinned END, website = CASE WHEN ? + * THEN ? ELSE website END, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. + * + * The `Reservations.repository.ts#updateReservation` / `PlacesRepository + * #updatePlace` precedent (RS41/PL11): the SERVICE resolves every + * COALESCE-keep and presence-sentinel column to its FINAL value first (it + * already holds the pre-image via `findInTrip`'s CB10 read every caller + * makes) — this method just writes what it is handed, no SQL-side + * COALESCE/CASE. R11 names a shared "presence-sentinel helper" other Plan + * 3e tasks (2/3/4) should copy; at the time this task ran, Task 2 (budget) + * had not landed yet (`progress.md`'s task table), so there was no landed + * helper to copy — this repeats the ALREADY-LANDED RS41/PL11 read-merge- + * write shape from Plans 3c/3d directly instead of inventing a new raw-SQL + * `CASE` helper, and this class docstring records that choice for + * whichever task reads it next. + */ + async update(id: number, write: { title: string; content: string | null; category: string | null; color: string | null; pinned: number | null; website: string | null }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { + title: write.title, + content: write.content, + category: write.category, + color: write.color, + pinned: write.pinned, + website: write.website, + updated_at: currentTimestamp(platform), + }); + } + + /** CB16 (`deleteNote`, inside its transaction, after CB15's attachment sweep) — `DELETE FROM collab_notes WHERE id = ?`. */ + async delete(id: number): Promise { + await this.nativeDelete({ id }); + } + + // ------------------------------------------------------------------------- + // Note attachments (`trip_files` scoped by `note_id`) — see the class + // docstring's second half for why these live here rather than on + // `TripFilesRepository`. + // ------------------------------------------------------------------------- + + /** CB6 (`formatNote`'s hydration read) — `SELECT id, filename, original_name, file_size, mime_type FROM trip_files WHERE note_id = ?`. */ + async listAttachmentsForNote(note_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .select(['id', 'filename', 'original_name', 'file_size', 'mime_type']) + .where('note_id', '=', note_id) + .execute(); + } + + /** CB14 (`deleteNote`'s pre-tx storage-cleanup list) — `SELECT id, filename FROM trip_files WHERE note_id = ?`, run BEFORE the transaction (the legacy ordering: the storage deletes happen outside/before the DB transaction). */ + async listFilenamesForNote(note_id: number): Promise<{ id: number; filename: string }[]> { + return await this.kysely().selectFrom('trip_files').select(['id', 'filename']).where('note_id', '=', note_id).execute(); + } + + /** CB15 (`deleteNote`, inside its transaction, before CB16) — `DELETE FROM trip_files WHERE note_id = ?`. */ + async deleteAttachmentsForNote(note_id: number): Promise { + await this.kysely().deleteFrom('trip_files').where('note_id', '=', note_id).execute(); + } + + /** + * CB18 (`addNoteFile`'s insert half) — `INSERT INTO trip_files (trip_id, + * note_id, filename, original_name, file_size, mime_type) VALUES (?×6)`. + * Returns the new row's id (Kysely's `InsertResult.insertId`, this + * dialect's `lastInsertRowid` equivalent) for CB19's re-select. + */ + async insertAttachmentForNote(row: { trip_id: number | string; note_id: number | string; filename: string; original_name: string; file_size: number; mime_type: string }): Promise { + const result = await this.kysely() + .insertInto('trip_files') + .values({ trip_id: row.trip_id, note_id: row.note_id, filename: row.filename, original_name: row.original_name, file_size: row.file_size, mime_type: row.mime_type }) + .executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** CB19 (`addNoteFile`'s re-select, immediately after {@link insertAttachmentForNote}) — `SELECT * FROM trip_files WHERE id = ?`, unscoped (the caller just wrote this exact id). */ + async findAttachmentById(id: number): Promise { + return await this.kysely().selectFrom('trip_files').selectAll().where('id', '=', id).executeTakeFirst(); + } + + /** + * CB21 (`deleteNoteFile`) — `SELECT * FROM trip_files WHERE id = ? AND + * note_id = ? AND trip_id = ?`. **IDOR guard, security-critical**: all + * three scoping columns in ONE query, never split into separate existence + * checks a caller could satisfy independently (the legacy doc comment's + * own warning, task-5-brief.md's explicit instruction) — this ties the + * deleted object to the URL's `:tripId` the controller already + * access-checked, not just to a note/file id an attacker can enumerate. + */ + async findScopedForNote(id: number, note_id: number, trip_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .selectAll() + .where('id', '=', id) + .where('note_id', '=', note_id) + .where('trip_id', '=', trip_id) + .executeTakeFirst(); + } + + /** CB22 (`deleteNoteFile`, after the IDOR-guarded read and the storage delete) — `DELETE FROM trip_files WHERE id = ?`. */ + async deleteAttachmentById(id: number): Promise { + await this.kysely().deleteFrom('trip_files').where('id', '=', id).execute(); + } +} diff --git a/server/src/db/repositories/CollabPollVotes.repository.ts b/server/src/db/repositories/CollabPollVotes.repository.ts new file mode 100644 index 000000000..674f5e503 --- /dev/null +++ b/server/src/db/repositories/CollabPollVotes.repository.ts @@ -0,0 +1,51 @@ +import type { CollabPollVotes } from '../entities/CollabPollVotes.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** CB24's joined projection — `v.option_index, v.user_id, u.username, u.avatar`. */ +export interface PollVoteJoinRow { + option_index: number; + user_id: number; + username: string; + avatar: string | null; +} + +export class CollabPollVotesRepository extends TrekRepository { + /** + * CB24 (`getPollWithVotes`'s votes half) — `SELECT v.option_index, + * v.user_id, u.username, u.avatar FROM collab_poll_votes v JOIN users u ON + * v.user_id = u.id WHERE v.poll_id = ?`. + */ + async listForPoll(poll_id: number): Promise { + return this.qb('v') + .join('v.user', 'u') + .select(['v.option_index', 'u.id as user_id', 'u.username', 'u.avatar']) + .where({ poll: poll_id }) + .execute('all', false); + } + + /** + * CB28 (`votePoll`'s existing-vote check) — `SELECT id FROM + * collab_poll_votes WHERE poll_id = ? AND user_id = ? AND option_index = + * ?`. Named `findVote`, not `find`: `find` is `EntityRepository`'s own + * base method (a different, incompatible signature) and a same-named + * override does not typecheck. + */ + async findVote(poll_id: number, user_id: number, option_index: number): Promise<{ id: number } | null> { + return this.findOne({ poll: poll_id, user: user_id, option_index }, { fields: ['id'] }); + } + + /** CB29 (`votePoll`'s toggle-off half, NOT inside the transaction — matching the legacy: only the multi-vote-clear branch below is transacted) — `DELETE FROM collab_poll_votes WHERE id = ?`. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** CB30 (`votePoll`'s single-choice clear, inside the transaction, only when `!poll.multiple`) — `DELETE FROM collab_poll_votes WHERE poll_id = ? AND user_id = ?`. */ + async deleteForUser(poll_id: number, user_id: number): Promise { + await this.nativeDelete({ poll: poll_id, user: user_id }); + } + + /** CB31 (`votePoll`, inside the transaction) — `INSERT INTO collab_poll_votes (poll_id, user_id, option_index) VALUES (?, ?, ?)`, the entity's own composite-unique `(poll, user, option_index)`. */ + async insertVote(poll_id: number, user_id: number, option_index: number): Promise { + await this.insert({ poll: poll_id, user: user_id, option_index }); + } +} diff --git a/server/src/db/repositories/CollabPolls.repository.ts b/server/src/db/repositories/CollabPolls.repository.ts new file mode 100644 index 000000000..dfa1434d2 --- /dev/null +++ b/server/src/db/repositories/CollabPolls.repository.ts @@ -0,0 +1,77 @@ +import type { CollabPolls } from '../entities/CollabPolls.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `collab_polls` row — every scalar column of the entity, incl. the two `persist(false)` relation mirrors (`trip_id`, `user_id`). */ +export interface CollabPollRow { + id: number; + trip_id: number; + user_id: number; + question: string; + options: string; + multiple: number | null; + closed: number | null; + deadline: string | null; + created_at: string | null; +} + +const _collabPollRowKeys: AssertRowKeys = true; + +/** CB23's joined projection — `p.*, u.username, u.avatar`. */ +export interface CollabPollJoinRow extends CollabPollRow { + username: string; + avatar: string | null; +} + +interface CollabPollsKyselyDB { + collab_polls: CollabPollRow; + users: { id: number; username: string; avatar: string | null }; +} + +/** + * `collab_polls` — decision polls. Kysely throughout: `trip_id`/`user_id` + * are `persist(false)` mirrors (the `TripFilesRepository` class docstring's + * trap), so a bare QueryBuilder `.select([...])` silently drops them. + */ +export class CollabPollsRepository extends TrekRepository { + /** CB23 (`getPollWithVotes`'s poll half) — `SELECT p.*, u.username, u.avatar FROM collab_polls p JOIN users u ON p.user_id = u.id WHERE p.id = ?`. `id: number`: the service resolves the route's `string | number` id via `toRowId` before calling (rule 15). */ + async findWithUser(id: number): Promise { + return await this.kysely() + .selectFrom('collab_polls as p') + .innerJoin('users as u', 'u.id', 'p.user_id') + .selectAll('p') + .select(['u.username', 'u.avatar']) + .where('p.id', '=', id) + .executeTakeFirst(); + } + + /** CB25 (`listPolls`) — `SELECT id FROM collab_polls WHERE trip_id = ? ORDER BY created_at DESC`. */ + async listIdsForTrip(trip_id: number): Promise { + const rows = await this.kysely().selectFrom('collab_polls').select(['id']).where('trip_id', '=', trip_id).orderBy('created_at', 'desc').execute(); + return rows.map((r) => r.id); + } + + /** + * CB27/CB32/CB34 (`votePoll`/`closePoll`/`deletePoll`'s shared trip-scoping + * guard) — `SELECT * FROM collab_polls WHERE id = ? AND trip_id = ?`, same + * text at all three call sites. + */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.kysely().selectFrom('collab_polls').selectAll().where('id', '=', id).where('trip_id', '=', trip_id).executeTakeFirst(); + } + + /** CB26 (`createPoll`) — `INSERT INTO collab_polls (trip_id, user_id, question, options, multiple, deadline) VALUES (?×6)`. Returns the new row's id. */ + async insertPoll(row: { trip_id: number | string; user_id: number; question: string; options: string; multiple: number; deadline: string | null }): Promise { + return await this.insert({ trip: row.trip_id, user: row.user_id, question: row.question, options: row.options, multiple: row.multiple, deadline: row.deadline }); + } + + /** CB33 (`closePoll`) — `UPDATE collab_polls SET closed = 1 WHERE id = ?`. */ + async close(id: number): Promise { + await this.nativeUpdate({ id }, { closed: 1 }); + } + + /** CB35 (`deletePoll`) — `DELETE FROM collab_polls WHERE id = ?`. */ + async delete(id: number): Promise { + await this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/CollectionLabels.repository.ts b/server/src/db/repositories/CollectionLabels.repository.ts new file mode 100644 index 000000000..37e58b272 --- /dev/null +++ b/server/src/db/repositories/CollectionLabels.repository.ts @@ -0,0 +1,144 @@ +import type { CollectionLabels } from '../entities/CollectionLabels.entity'; +import { lower, lowerParam } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `collection_labels` — `loadLabelsByCollection`'s (CL7) shape. */ +export interface CollectionLabelRow { + id: number; + collection_id: number; + name: string; + color: string | null; + sort_order: number | null; +} + +interface CollectionLabelsKyselyDB { + collection_labels: { + id: number; + collection_id: number; + name: string; + color: string | null; + sort_order: number | null; + created_at: string | null; + }; +} + +/** CL20's own `INSERT INTO collection_labels (collection_id, name, color, sort_order)` column list, exactly — no `id`/`created_at`, the `BucketListInsertKyselyDB` narrower-insert-interface precedent. */ +interface CollectionLabelsInsertKyselyDB { + collection_labels: { collection_id: number; name: string; color: string; sort_order: number }; +} + +/** + * `collection_labels` — Plan 3h Task 1's first cut. Owns Part A's own + * label reads/insert (CL7, `labelIdsForFile`'s CL18/CL19, `insertImportedLabel`'s + * CL20). Task 2 extends this repository with `createLabel`/`updateLabel`/ + * `deleteLabel`/`collectionIdOfLabel`/`getLabelById` (CL71-77, beyond this + * task's CL1-36 range). + */ +export class CollectionLabelsRepository extends TrekRepository { + private db_() { + return this.kysely(); + } + + /** CL7 (`loadLabelsByCollection`) — `SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE collection_id=? ORDER BY sort_order, id`. */ + async listByCollection(collectionId: number): Promise { + return await this.db_() + .selectFrom('collection_labels') + .select(['id', 'collection_id', 'name', 'color', 'sort_order']) + .where('collection_id', '=', collectionId) + .orderBy('sort_order') + .orderBy('id') + .execute(); + } + + /** CL18 (`labelIdsForFile`) — `SELECT id, name FROM collection_labels WHERE collection_id=?`. */ + async idNameByCollection(collectionId: number): Promise<{ id: number; name: string }[]> { + return await this.db_() + .selectFrom('collection_labels') + .select(['id', 'name']) + .where('collection_id', '=', collectionId) + .execute(); + } + + /** CL19 (`labelIdsForFile`) — `SELECT COALESCE(MAX(sort_order),-1) AS m FROM collection_labels WHERE collection_id=?`. Dup text also `createLabel` (Task 2's own call site, CL73's neighbor). */ + async maxSortOrder(collectionId: number): Promise { + const row = await this.db_() + .selectFrom('collection_labels') + .select((eb) => eb.fn.coalesce(eb.fn.max('sort_order'), eb.val(-1)).as('m')) + .where('collection_id', '=', collectionId) + .executeTakeFirstOrThrow(); + return Number(row.m); + } + + /** CL20 (`insertImportedLabel`) — `INSERT INTO collection_labels (collection_id, name, color, sort_order) VALUES (?,?,?,?)`. Returns the new row's id. Dup text also `createLabel` (Task 2's own call site). */ + async insertLabel(row: { collection_id: number; name: string; color: string; sort_order: number }): Promise { + const result = await this.kysely().insertInto('collection_labels').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + // ------------------------------------------------------------------------- + // Plan 3h Task 2 (part B) — CL71-77, additive. + // ------------------------------------------------------------------------- + + /** + * CL71 (`collectionIdOfLabel`) — `SELECT collection_id FROM + * collection_labels WHERE id=?`. Kysely, not `findOne({fields: + * ['collection_id']})` — `collection_id` is a `persist(false)` relation + * mirror, and the program-wide "a bare `persist(false)` mirror column + * selected via `fields:` is silently dropped" trap (3d/3e ledgers) + * applies here exactly as it does to a bare `.select([...])`. + */ + async collectionIdOf(labelId: number): Promise { + const row = await this.db_().selectFrom('collection_labels').select('collection_id').where('id', '=', labelId).executeTakeFirst(); + return row?.collection_id; + } + + /** CL72 (`getLabelById`) — `SELECT id, collection_id, name, color, sort_order FROM collection_labels WHERE id=?`. Kysely, same `collection_id` mirror-column reason as {@link collectionIdOf}. */ + async findById(labelId: number): Promise { + return await this.db_() + .selectFrom('collection_labels') + .select(['id', 'collection_id', 'name', 'color', 'sort_order']) + .where('id', '=', labelId) + .executeTakeFirst(); + } + + /** CL73 (`createLabel`) — `SELECT COUNT(*) AS n FROM collection_labels WHERE collection_id=?`. */ + async countByCollection(collectionId: number): Promise { + return await this.count({ collection: collectionId }); + } + + /** + * CL74 (`createLabel`) — `SELECT 1 FROM collection_labels WHERE + * collection_id=? AND lower(name)=lower(?)`. Both sides folded by SQLite + * (`lower`/`lowerParam`'s pair, program rule 18) — the legacy statement + * lowers the bound value in SQL too, never in JS. + */ + async nameExists(collectionId: number, name: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { collection: collectionId, [lower(platform, 'name')]: lowerParam(platform, name) }, + { fields: ['id'] }, + ); + return !!row; + } + + /** CL75 (`updateLabel`) — same as {@link nameExists}, plus `AND id != ?` (excludes the label being renamed). */ + async nameExistsExcluding(collectionId: number, name: string, excludeId: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { collection: collectionId, [lower(platform, 'name')]: lowerParam(platform, name), id: { $ne: excludeId } }, + { fields: ['id'] }, + ); + return !!row; + } + + /** CL76 (`updateLabel`) — the allow-listed dynamic `UPDATE collection_labels SET ... WHERE id=?`. No `updated_at` stamp — the table has no such column, and the legacy statement never wrote one. */ + async updateFields(labelId: number, write: Partial<{ name: string; color: string; sort_order: number }>): Promise { + if (Object.keys(write).length === 0) return; + await this.nativeUpdate({ id: labelId }, write); + } + + /** CL77 (`deleteLabel`) — `DELETE FROM collection_labels WHERE id=?` (CASCADE clears place assignments). */ + async deleteById(labelId: number): Promise { + await this.nativeDelete({ id: labelId }); + } +} diff --git a/server/src/db/repositories/CollectionMembers.repository.ts b/server/src/db/repositories/CollectionMembers.repository.ts new file mode 100644 index 000000000..0bf6ebb1b --- /dev/null +++ b/server/src/db/repositories/CollectionMembers.repository.ts @@ -0,0 +1,197 @@ +import type { CollectionMembers } from '../entities/CollectionMembers.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `buildMembers`'s owner row — `collections` joined to `users` (CL12). */ +export interface CollectionOwnerMemberRow { + user_id: number; + username: string; + email: string; + avatar: string | null; +} + +/** `buildMembers`'s accepted/pending member rows — `collection_members` joined to `users` (CL13). */ +export interface CollectionMemberRow extends CollectionOwnerMemberRow { + status: string; + role: string; +} + +interface CollectionOwnerKyselyDB { + collections: { id: number; owner_id: number }; + users: { id: number; username: string; email: string; avatar: string | null }; +} + +interface CollectionMembersKyselyDB { + collection_members: { collection_id: number; user_id: number; status: string; role: string }; + users: { id: number; username: string; email: string; avatar: string | null }; +} + +interface CollectionMemberUserIdsKyselyDB { + collection_members: { collection_id: number; user_id: number; status: string }; +} + +/** `listCollections`'s incoming-invites read — a genuine statement missing + * its own CL number in the plan's inventory gather (jumped CL15 → CL16 + * without capturing it): `SELECT cm.collection_id, c.name, u.id AS from_id, + * u.username AS from_username FROM collection_members cm JOIN collections c + * ON c.id=cm.collection_id JOIN users u ON u.id=c.owner_id WHERE + * cm.user_id=? AND cm.status='pending'`. */ +export interface CollectionIncomingInviteRow { + collection_id: number; + name: string; + from_id: number; + from_username: string; +} + +interface CollectionIncomingInvitesKyselyDB { + collection_members: { collection_id: number; user_id: number; status: string }; + collections: { id: number; name: string; owner_id: number }; + users: { id: number; username: string }; +} + +/** + * `collection_members` — Plan 3h Task 1's first cut (`roleOf`'s own member + * lookup lives in `_shared/collection-role.ts` instead, R6). Task 2 extends + * this repository with `sendInvite`/`acceptInvite`/etc.'s invite-lifecycle + * methods (CL79-88) and the membership-lookup surface (CL69, `findMembership`). + * Owns `buildMembers`'s two joined reads (CL12/CL13) and `deleteCollection`'s + * snapshot-before-cascade reads (CL33/CL34). + */ +export class CollectionMembersRepository extends TrekRepository { + /** CL12 (`buildMembers`) — `SELECT u.id AS user_id, u.username, u.email, u.avatar FROM collections col JOIN users u ON u.id=col.owner_id WHERE col.id=?`. */ + async ownerRow(collectionId: number): Promise { + return await this.kysely() + .selectFrom('collections as col') + .innerJoin('users as u', 'u.id', 'col.owner_id') + .select(['u.id as user_id', 'u.username', 'u.email', 'u.avatar']) + .where('col.id', '=', collectionId) + .executeTakeFirst(); + } + + /** CL13 (`buildMembers`) — `SELECT u.id AS user_id, u.username, u.email, u.avatar, cm.status, cm.role FROM collection_members cm JOIN users u ON u.id=cm.user_id WHERE cm.collection_id=? ORDER BY u.username`. */ + async memberRows(collectionId: number): Promise { + return await this.kysely() + .selectFrom('collection_members as cm') + .innerJoin('users as u', 'u.id', 'cm.user_id') + .select(['u.id as user_id', 'u.username', 'u.email', 'u.avatar', 'cm.status', 'cm.role']) + .where('cm.collection_id', '=', collectionId) + .orderBy('u.username') + .execute(); + } + + /** CL33 (`deleteCollection`'s snapshot, taken BEFORE the cascading delete) — `SELECT user_id FROM collection_members WHERE collection_id=? AND status='accepted'`. Dup text also `collectionMemberIds`/`notifyCollectionUsers` (Task 2's own call sites). */ + async acceptedUserIds(collectionId: number): Promise { + const rows = await this.kysely() + .selectFrom('collection_members') + .select('user_id') + .where('collection_id', '=', collectionId) + .where('status', '=', 'accepted') + .execute(); + return rows.map((r) => r.user_id); + } + + /** CL34 (`deleteCollection`'s snapshot, taken BEFORE the cascading delete) — `SELECT user_id FROM collection_members WHERE collection_id=? AND status='pending'`. */ + async pendingUserIds(collectionId: number): Promise { + const rows = await this.kysely() + .selectFrom('collection_members') + .select('user_id') + .where('collection_id', '=', collectionId) + .where('status', '=', 'pending') + .execute(); + return rows.map((r) => r.user_id); + } + + /** `listCollections`'s incoming-invites read (see {@link CollectionIncomingInviteRow}'s docstring). */ + async pendingInvitesForUser(userId: number): Promise { + return await this.kysely() + .selectFrom('collection_members as cm') + .innerJoin('collections as c', 'c.id', 'cm.collection_id') + .innerJoin('users as u', 'u.id', 'c.owner_id') + .select(['cm.collection_id', 'c.name', 'u.id as from_id', 'u.username as from_username']) + .where('cm.user_id', '=', userId) + .where('cm.status', '=', 'pending') + .execute(); + } + + // ------------------------------------------------------------------------- + // Plan 3h Task 2 (part B) — CL79-88, additive: the invite lifecycle + // (sendInvite/acceptInvite/declineInvite/cancelInvite/leaveCollection/ + // removeMember/setMemberRole/availableUsers/findMembershipForUser). + // ------------------------------------------------------------------------- + + /** CL80 (`sendInvite`) — `SELECT id, status FROM collection_members WHERE collection_id=? AND user_id=?` (dup/pending guard). */ + async findByCollectionAndUser(collectionId: number, userId: number): Promise<{ id: number; status: string } | undefined> { + const row = await this.findOne({ collection: collectionId, user: userId }, { fields: ['id', 'status'] }); + return row ?? undefined; + } + + /** CL81 (`sendInvite`) — `INSERT INTO collection_members (collection_id, user_id, status, role) VALUES (?, ?, 'pending', ?)`. */ + async insertInvite(collectionId: number, userId: number, role: string): Promise { + await this.insert({ collection: collectionId, user: userId, status: 'pending', role }); + } + + /** CL82 (`acceptInvite`) — `SELECT id FROM collection_members WHERE collection_id=? AND user_id=? AND status='pending'`. */ + async findPendingInvite(collectionId: number, userId: number): Promise { + const row = await this.findOne({ collection: collectionId, user: userId, status: 'pending' }, { fields: ['id'] }); + return row?.id; + } + + /** CL83 (`acceptInvite`) — `UPDATE collection_members SET status='accepted' WHERE id=?`. */ + async accept(memberId: number): Promise { + await this.nativeUpdate({ id: memberId }, { status: 'accepted' }); + } + + /** CL84 (`declineInvite`, dup text also `cancelInvite`) — `DELETE FROM collection_members WHERE collection_id=? AND user_id=? AND status='pending'`. */ + async deletePending(collectionId: number, userId: number): Promise { + await this.nativeDelete({ collection: collectionId, user: userId, status: 'pending' }); + } + + /** CL85 (`leaveCollection`, dup text also `removeMember`) — `DELETE FROM collection_members WHERE collection_id=? AND user_id=? AND status='accepted'`. Returns the affected row count (`removeMember`'s own 404-on-zero check). */ + async deleteAccepted(collectionId: number, userId: number): Promise { + return await this.nativeDelete({ collection: collectionId, user: userId, status: 'accepted' }); + } + + /** CL86 (`setMemberRole`) — `UPDATE collection_members SET role=? WHERE collection_id=? AND user_id=? AND status='accepted'`. Returns the affected row count (0 ⇒ 404). */ + async setRole(collectionId: number, userId: number, role: string): Promise { + return await this.nativeUpdate({ collection: collectionId, user: userId, status: 'accepted' }, { role }); + } + + /** + * CL87 (`availableUsers`) — `SELECT u.id, u.username FROM users u WHERE + * u.id != ? AND u.id NOT IN (SELECT user_id FROM collection_members WHERE + * collection_id = ?) AND u.is_guest = 0 ORDER BY u.username`. Kysely — a + * `NOT IN` subquery against a different repository's own table (`users`), + * the same cross-entity shape `pendingInvitesForUser` above already uses. + */ + async availableUsers(ownerId: number, collectionId: number): Promise<{ id: number; username: string }[]> { + return await this.kysely() + .selectFrom('users as u') + .select(['u.id', 'u.username']) + .where('u.id', '!=', ownerId) + .where('u.is_guest', '=', 0) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('collection_members as cm') + .select('cm.user_id') + .whereRef('cm.user_id', '=', 'u.id') + .where('cm.collection_id', '=', collectionId), + ), + ), + ) + .orderBy('u.username') + .execute(); + } + + /** CL88 (`findMembershipForUser`) — `SELECT status FROM collection_members WHERE collection_id=? AND user_id=?` (no status filter — any row, pending or accepted). */ + async statusFor(collectionId: number, userId: number): Promise { + const row = await this.findOne({ collection: collectionId, user: userId }, { fields: ['status'] }); + return row?.status; + } +} + +/** {@link CollectionMembersRepository.availableUsers}'s narrow `users`/`collection_members` shape. */ +interface CollectionAvailableUsersKyselyDB { + users: { id: number; username: string; is_guest: number }; + collection_members: { collection_id: number; user_id: number }; +} diff --git a/server/src/db/repositories/CollectionPlaceRatings.repository.ts b/server/src/db/repositories/CollectionPlaceRatings.repository.ts new file mode 100644 index 000000000..f30808f1c --- /dev/null +++ b/server/src/db/repositories/CollectionPlaceRatings.repository.ts @@ -0,0 +1,72 @@ +import type { CollectionPlaceRatings } from '../entities/CollectionPlaceRatings.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `collection_place_ratings` (Plan 3h Task 2, part B — #1435's per-voter + * star ratings on a saved place). A real entity/composite-unique table + * (`uniques: [{ properties: ['collectionPlace', 'user'] }]`, Task 0's + * entity/repository population table), unlike the two pivot tables this + * plan's own `collection_place_tags`/`collection_place_labels` conversions + * route through `CollectionPlacesRepository`'s Kysely bridge — writes here + * go through the MikroORM entity API using the entity's own relation + * property names (`collectionPlace`/`user`), `PlaceRatingsRepository + * .upsertRating`'s exact precedent for the sibling `place_ratings` table. + */ +export class CollectionPlaceRatingsRepository extends TrekRepository { + /** + * CL52 (`setRating`, `rating === null` branch) — `DELETE FROM + * collection_place_ratings WHERE collection_place_id=? AND user_id=?`. + */ + async deleteRating(collectionPlaceId: number, userId: number): Promise { + await this.nativeDelete({ collectionPlace: collectionPlaceId, user: userId }); + } + + /** + * CL53 (`setRating`, `rating !== null` branch) — `INSERT INTO + * collection_place_ratings (collection_place_id, user_id, rating) VALUES + * (?, ?, ?) ON CONFLICT(collection_place_id, user_id) DO UPDATE SET + * rating = excluded.rating`. A STRAIGHTFORWARD blind-overwrite composite + * upsert (the brief's own framing, confirmed against + * `PlaceRatingsRepository.upsertRating`, the closer precedent than R4's + * additive-counter shape): `onConflictFields: ['collectionPlace', 'user']` + * on the entity's own declared composite unique (property names, not + * column names — `collection_place_id`/`user_id` are `persist(false)` + * mirrors of those two relations), `onConflictMergeFields: ['rating']` + * so a future column never silently joins the merge set (same reasoning + * `PlaceRatingsRepository.upsertRating`'s own docstring gives). + */ + async upsertRating(collectionPlaceId: number, userId: number, rating: number): Promise { + await this.upsert( + { collectionPlace: collectionPlaceId, user: userId, rating }, + { onConflictFields: ['collectionPlace', 'user'], onConflictAction: 'merge', onConflictMergeFields: ['rating'] }, + ); + } + + /** + * CL41 (`copyTripRatings`, PREPARED/looped) — `INSERT OR IGNORE INTO + * collection_place_ratings (collection_place_id, user_id, rating) VALUES + * (?, ?, ?)`. Distinct from {@link upsertRating}'s `DO UPDATE` shape — a + * vote already carried over from a prior copy is left alone, not + * overwritten, `onConflictAction: 'ignore'` on the same composite unique. + */ + async insertIgnoreRating(collectionPlaceId: number, userId: number, rating: number): Promise { + await this.upsert( + { collectionPlace: collectionPlaceId, user: userId, rating }, + { onConflictFields: ['collectionPlace', 'user'], onConflictAction: 'ignore' }, + ); + } + + /** + * CL68 (`copyToTrip`, loop, TX) — `SELECT user_id, rating FROM + * collection_place_ratings WHERE collection_place_id=?`. `user_id` is a + * `persist(false)` relation mirror — Kysely, the program-wide "bare + * mirror column silently dropped" trap (3d/3e ledgers). + */ + async listForPlace(collectionPlaceId: number): Promise<{ user_id: number; rating: number }[]> { + return await this.kysely<{ collection_place_ratings: { collection_place_id: number; user_id: number; rating: number } }>() + .selectFrom('collection_place_ratings') + .select(['user_id', 'rating']) + .where('collection_place_id', '=', collectionPlaceId) + .execute(); + } +} diff --git a/server/src/db/repositories/CollectionPlaces.repository.ts b/server/src/db/repositories/CollectionPlaces.repository.ts new file mode 100644 index 000000000..23d0b762b --- /dev/null +++ b/server/src/db/repositories/CollectionPlaces.repository.ts @@ -0,0 +1,486 @@ +import type { CollectionPlaces } from '../entities/CollectionPlaces.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import type { CollectionPlaceRow } from './Collections.repository'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `collection_places` (Plan 3h Task 2, part B — saved places CRUD, dedup, + * copy-to-trip, library-wide membership lookup, label assignment). Task 1's + * own bridge methods for this table (CL10/11/16/17/22/23/25/26/27) stay on + * `CollectionsRepository` as its report flags — this repository is additive + * ONLY, every method here a NEW statement in Task 2's own CL37-69 range, + * never a re-derivation of what Task 1 already built. + * + * **Read/write split**: every READ here goes through `this.kysely()` rather + * than the MikroORM entity API, even for statements the entity COULD in + * principle express, because every read in this file's own range projects + * at least one `persist(false)` relation-mirror column (`collection_id`/ + * `category_id`) or needs `abs()`/dynamic OR conditions Kysely expresses + * more directly — the TRAP list's "a `persist(false)` mirror column + * selected bare is silently dropped, with or without an active join" + * finding (3d/3e ledgers) applies to a `qb().select([...])`/`fields: [...]` + * read exactly as much as it does to `find`, so Kysely sidesteps the whole + * class of bug rather than being audited statement-by-statement for it. + * WRITES go through the MikroORM entity API (`insert`/`nativeUpdate`/ + * `nativeDelete`) wherever the target table has an entity, using the + * entity's own relation property names (`collection`/`owner`/`category`) + * for FK columns — {@link PlaceRatingsRepository.upsertRating}'s + * `{ place: place_id, user: user_id }` precedent — since a write is never + * subject to the read-side narrowing trap. The two pivot tables this file + * also owns (`collection_place_tags`/`collection_place_labels`) have NO + * entity of their own (`Task 0's` entity/repository population table) and + * so go through Kysely unconditionally, both reads and writes. + */ +export class CollectionPlacesRepository extends TrekRepository { + private db_() { + return this.kysely(); + } + + // --------------------------------------------------------------------- + // CL37/CL38 — attachTags (private helper, savePlace/updatePlace) + // --------------------------------------------------------------------- + + /** + * CL38 (`attachTags`, PREPARED/looped in the legacy code) — `INSERT OR + * IGNORE INTO collection_place_tags (collection_place_id, tag_id) VALUES + * (?, ?)`, one call per eligible tag id. Batched into ONE Kysely insert + * with `onConflict(...).doNothing()` on the pivot's real composite + * `PRIMARY KEY (collection_place_id, tag_id)` — the exact shape + * `TagsRepository.insertIgnore` already established for `place_tags` + * (the sibling pivot in the 3c-owned `places` cluster). Empty `tagIds` + * short-circuits before any query. + */ + async attachTags(collectionPlaceId: number, tagIds: number[]): Promise { + if (tagIds.length === 0) return; + await this.db_() + .insertInto('collection_place_tags') + .values(tagIds.map((tag_id) => ({ collection_place_id: collectionPlaceId, tag_id }))) + .onConflict((oc) => oc.columns(['collection_place_id', 'tag_id']).doNothing()) + .execute(); + } + + // --------------------------------------------------------------------- + // CL42/CL46 — savePlace / saveFromTripPlaces inserts + // --------------------------------------------------------------------- + + /** + * CL42 (`savePlace`, TX) AND CL46 (`saveFromTripPlaces`, PREPARED/looped, + * TX) — collapsed onto ONE method, not two: both legacy statements share + * the IDENTICAL 22-column `INSERT INTO collection_places (collection_id, + * owner_id, saved_by, name, description, lat, lng, address, category_id, + * price, currency, notes, image_url, google_place_id, google_ftid, + * osm_id, website, phone, status, source_trip_id, source_place_id, + * links)` list in the SAME order — CL46's only difference is that + * `status`/`links` are bound SQL literals (`'idea'`/`NULL`) instead of + * placeholders, which is a caller-side VALUE difference, not a distinct + * statement shape (unlike CL23's file-import insert, which genuinely + * carries a different column set — `sort_order`, no `source_trip_id`/ + * `source_place_id` — and stays its own method on `CollectionsRepository`, + * per the plan's own "do not collapse" instruction for that pair). + * `saveFromTripPlaces`'s own call site passes `status: 'idea', links: + * null` explicitly, reproducing the literal exactly. `em.insert()` + * returns the generated id; `sort_order` is never in the column list + * either statement writes, so the table's own `DEFAULT 0` fills it, + * matching both legacy statements. + */ + async insertSavedPlace(row: { + collection_id: number; + owner_id: number; + saved_by: number; + name: string; + description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + currency: string | null; + notes: string | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + osm_id: string | null; + website: string | null; + phone: string | null; + status: string; + source_trip_id: number | null; + source_place_id: number | null; + links: string | null; + }): Promise { + return await this.insert({ + collection: row.collection_id, + owner: row.owner_id, + savedByRef: row.saved_by, + name: row.name, + description: row.description, + lat: row.lat, + lng: row.lng, + address: row.address, + category: row.category_id, + price: row.price, + currency: row.currency, + notes: row.notes, + image_url: row.image_url, + google_place_id: row.google_place_id, + google_ftid: row.google_ftid, + osm_id: row.osm_id, + website: row.website, + phone: row.phone, + status: row.status, + source_trip_id: row.source_trip_id, + source_place_id: row.source_place_id, + links: row.links, + }); + } + + // --------------------------------------------------------------------- + // CL47/CL48 — updatePlace + // --------------------------------------------------------------------- + + /** CL47 (`updatePlace`'s pre-image read, dup text also `deletePlace`/`deletePlacesMany`/`setPlaceImage`) — `SELECT image_url FROM collection_places WHERE id=?`. Plain (non-FK) column — safe through the entity API, `fields: ['image_url']`. */ + async imageUrl(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['image_url'] }); + return row?.image_url; + } + + /** + * CL48 (`updatePlace`, TX) — the allow-listed dynamic `UPDATE + * collection_places SET ... WHERE id=?`, including the cross-list move + * branch (`collection_id`/`owner_id`). `write` carries exactly the + * columns the caller built (the service resolves `body.field !== + * undefined` itself, `CollectionsRepository.updateFields`'s same shape) + * — an empty `write` short-circuits, matching the legacy `if + * (updates.length > 0)` guard. FK columns are translated to their + * entity relation property names for the write + * (`collection_id`→`collection`, `owner_id`→`owner`, `category_id`→ + * `category`) — `PlaceRatingsRepository.upsertRating`'s precedent for + * writing a `persist(false)` mirror through its owning relation. + */ + async updateFields( + id: number, + write: Partial<{ + name: string; + description: string | null; + notes: string | null; + lat: number | null; + lng: number | null; + address: string | null; + status: string; + category_id: number | null; + image_url: string | null; + links: string | null; + collection_id: number; + owner_id: number; + }>, + ): Promise { + if (Object.keys(write).length === 0) return; + const platform = this.getEntityManager().getPlatform(); + const { category_id, collection_id, owner_id, ...rest } = write; + await this.nativeUpdate( + { id }, + { + ...rest, + ...(category_id !== undefined ? { category: category_id } : {}), + ...(collection_id !== undefined ? { collection: collection_id } : {}), + ...(owner_id !== undefined ? { owner: owner_id } : {}), + updated_at: currentTimestamp(platform), + }, + ); + } + + /** CL49 (`updatePlace`, TX) — `DELETE FROM collection_place_tags WHERE collection_place_id=?`. Pivot table, Kysely. */ + async deleteTags(collectionPlaceId: number): Promise { + await this.db_().deleteFrom('collection_place_tags').where('collection_place_id', '=', collectionPlaceId).execute(); + } + + /** CL50 (`updatePlace`'s move branch, TX, dup text also `setPlaceLabels`) — `DELETE FROM collection_place_labels WHERE collection_place_id=?`. Pivot table, Kysely. */ + async deleteLabelAssignments(collectionPlaceId: number): Promise { + await this.db_().deleteFrom('collection_place_labels').where('collection_place_id', '=', collectionPlaceId).execute(); + } + + // --------------------------------------------------------------------- + // CL51/CL52/CL53 — setStatus / setRating + // --------------------------------------------------------------------- + + /** CL51 (`setStatus`) — `UPDATE collection_places SET status=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`. */ + async setStatus(id: number, status: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { status, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // CL54 — deletePlace / deletePlacesMany + // --------------------------------------------------------------------- + + /** CL54 (`deletePlace`, dup text also `deletePlacesMany`'s TX loop) — `DELETE FROM collection_places WHERE id=?` (CASCADE drops tags/labels/ratings). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + // --------------------------------------------------------------------- + // CL56 — setStatusMany + // --------------------------------------------------------------------- + + /** + * CL56 (`setStatusMany`, TX) — `UPDATE collection_places SET status=?, + * updated_at=CURRENT_TIMESTAMP WHERE id=? AND status IS NOT ?`, distinct + * from CL51 by the extra `status IS NOT ?` guard. Returns the affected + * row count (0 or 1) — the caller sums it across the batch, matching the + * legacy `res.changes` accumulation. + */ + async setStatusIfChanged(id: number, status: string): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.nativeUpdate({ id, status: { $ne: status } }, { status, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // CL58 — matchingCollectionPlaces (setStatusFromTrip's own private helper) + // --------------------------------------------------------------------- + + /** + * CL58 (`matchingCollectionPlaces`) — `SELECT cp.id FROM collection_places + * cp WHERE cp.collection_id IN (...) AND (${dynamic OR conditions})`. + * RULE 23: the dynamic OR is a typed array of Kysely expressions built + * conditionally (never a string-concatenated WHERE) — the base + * `source_trip_id`/`source_place_id` pair is always present (an AND'd + * sub-clause inside the outer OR, matching the legacy's own parenthesised + * first condition), and each provider-id/coordinate-tolerance clause is + * pushed onto the array only when the corresponding input field is + * present, exactly reproducing the legacy `conditions.push(...)` guards. + * `tolerance` is caller-supplied (`CollectionsRepository + * .findDuplicateByCoords`'s own precedent), not imported from + * `nest/places/places.helpers` here — a `db/` repository does not reach + * into `nest/`. + */ + async matchingByTripSource( + collectionIds: number[], + tripId: number, + place: { id: number; lat: number | null; lng: number | null; google_place_id: string | null; google_ftid: string | null; osm_id: string | null }, + tolerance: number, + ): Promise<{ id: number }[]> { + if (collectionIds.length === 0) return []; + return await this.db_() + .selectFrom('collection_places as cp') + .select('cp.id') + .where('cp.collection_id', 'in', collectionIds) + .where((eb) => { + const clauses = [eb.and([eb('cp.source_trip_id', '=', tripId), eb('cp.source_place_id', '=', place.id)])]; + if (place.google_place_id) clauses.push(eb('cp.google_place_id', '=', place.google_place_id)); + if (place.google_ftid) clauses.push(eb('cp.google_ftid', '=', place.google_ftid)); + if (place.osm_id) clauses.push(eb('cp.osm_id', '=', place.osm_id)); + if (place.lat != null && place.lng != null) { + clauses.push( + eb.and([ + eb('cp.lat', 'is not', null), + eb('cp.lng', 'is not', null), + eb(eb.fn('abs', [eb('cp.lat', '-', place.lat)]), '<=', tolerance), + eb(eb.fn('abs', [eb('cp.lng', '-', place.lng)]), '<=', tolerance), + ]), + ); + } + return eb.or(clauses); + }) + .execute(); + } + + // --------------------------------------------------------------------- + // CL59 — setPlaceImage + // --------------------------------------------------------------------- + + /** CL59 (`setPlaceImage`) — `UPDATE collection_places SET image_url=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`. */ + async setImageUrl(id: number, imageUrl: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { image_url: imageUrl, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // CL62 — copyToTrip's per-source-id read + // --------------------------------------------------------------------- + + /** + * CL62 (`copyToTrip`, per source id) — `SELECT id, collection_id, name, + * description, lat, lng, address, category_id, price, currency, notes, + * image_url, google_place_id, google_ftid, osm_id, website, phone FROM + * collection_places WHERE id=?`. `collection_id`/`category_id` in the + * projection — Kysely, the class docstring's read-side rule. + */ + async findForCopy(id: number): Promise { + return await this.db_() + .selectFrom('collection_places') + .select([ + 'id', 'collection_id', 'name', 'description', 'lat', 'lng', 'address', 'category_id', 'price', 'currency', + 'notes', 'image_url', 'google_place_id', 'google_ftid', 'osm_id', 'website', 'phone', + ]) + .where('id', '=', id) + .executeTakeFirst(); + } + + // --------------------------------------------------------------------- + // CL67 — copyToTrip's per-source tag read (loop, TX) + // --------------------------------------------------------------------- + + /** CL67 (`copyToTrip`, loop, TX) — `SELECT tag_id FROM collection_place_tags WHERE collection_place_id=?`. Pivot table, Kysely. */ + async tagIdsFor(collectionPlaceId: number): Promise { + const rows = await this.db_().selectFrom('collection_place_tags').select('tag_id').where('collection_place_id', '=', collectionPlaceId).execute(); + return rows.map((r) => r.tag_id); + } + + // --------------------------------------------------------------------- + // CL69 — findMembership (library-wide membership lookup) + // --------------------------------------------------------------------- + + /** + * CL69 (`findMembership`) — `SELECT cp.id AS place_id, cp.collection_id, + * c.name, cp.status FROM collection_places cp JOIN collections c ON + * c.id=cp.collection_id WHERE cp.collection_id IN (...) AND (${dynamic + * conditions})`. RULE 23, same shape as {@link matchingByTripSource} + * above, but genuinely a DIFFERENT statement (no `source_trip_id`/ + * `source_place_id` base clause, no `osm_id` condition — the legacy + * `findMembership` never checks it — and an extra `collections` join for + * the list name), so this is its own method rather than a parameterised + * reuse of `matchingByTripSource` (one repository method per legacy + * statement). Returns `[]` when `query` carries no usable condition, + * matching the service's own `if (conditions.length === 0) return + * { saved: false, lists: [] };` guard — defensive here too, though the + * caller already checks first. + */ + async searchMembership( + collectionIds: number[], + query: { google_place_id?: string; google_ftid?: string; lat?: number; lng?: number }, + tolerance: number, + ): Promise<{ place_id: number; collection_id: number; name: string; status: string }[]> { + if (collectionIds.length === 0) return []; + const hasCoords = query.lat != null && query.lng != null; + if (!query.google_place_id && !query.google_ftid && !hasCoords) return []; + return await this.kysely() + .selectFrom('collection_places as cp') + .innerJoin('collections as c', 'c.id', 'cp.collection_id') + .select(['cp.id as place_id', 'cp.collection_id', 'c.name', 'cp.status']) + .where('cp.collection_id', 'in', collectionIds) + .where((eb) => { + const clauses = []; + if (query.google_place_id) clauses.push(eb('cp.google_place_id', '=', query.google_place_id)); + if (query.google_ftid) clauses.push(eb('cp.google_ftid', '=', query.google_ftid)); + if (hasCoords) { + clauses.push( + eb.and([ + eb('cp.lat', 'is not', null), + eb('cp.lng', 'is not', null), + eb(eb.fn('abs', [eb('cp.lat', '-', query.lat as number)]), '<=', tolerance), + eb(eb.fn('abs', [eb('cp.lng', '-', query.lng as number)]), '<=', tolerance), + ]), + ); + } + return eb.or(clauses); + }) + .execute(); + } + + // --------------------------------------------------------------------- + // CL78 — assignLabels' remove branch (TX) + // --------------------------------------------------------------------- + + /** + * CL78 (`assignLabels`'s remove branch, PREPARED/looped, TX) — `DELETE + * FROM collection_place_labels WHERE collection_place_id=? AND + * label_id=?`, per pair in the service's own nested loop (mirrors + * `CollectionsRepository.assignPlaceLabel`'s CL24 per-pair shape, the + * insert branch's own repository call). Pivot table, Kysely. Returns the + * deleted row count (0 or 1) for the caller's `changed +=` accumulation. + */ + async unassignLabel(collectionPlaceId: number, labelId: number): Promise { + const result = await this.db_() + .deleteFrom('collection_place_labels') + .where('collection_place_id', '=', collectionPlaceId) + .where('label_id', '=', labelId) + .executeTakeFirst(); + return Number(result.numDeletedRows); + } + + /** + * `assignLabels`'s own insert branch (its own text is `CL24`'s dup, `:1488` + * per Task 1's report) — same `INSERT OR IGNORE INTO collection_place_labels + * (collection_place_id, label_id) VALUES (?, ?)` shape as + * `CollectionsRepository.assignPlaceLabel`, but with a COUNT-returning + * contract that method doesn't have (it returns `void` — `setPlaceLabels`'s + * own call site, the sole other CL24 consumer, never needed one). `assignLabels` + * sums the affected-row count across every pair (matching the legacy's own + * `res.changes` accumulation — an already-assigned label contributes 0, a + * newly-assigned one contributes 1), which `void` cannot answer. A second + * method rather than widening Task 1's committed signature, per the + * brief's "Task 1's methods untouched" instruction. + */ + async assignLabel(collectionPlaceId: number, labelId: number): Promise { + const result = await this.db_() + .insertInto('collection_place_labels') + .values({ collection_place_id: collectionPlaceId, label_id: labelId }) + .onConflict((oc) => oc.columns(['collection_place_id', 'label_id']).doNothing()) + .executeTakeFirst(); + return Number(result.numInsertedOrUpdatedRows ?? 0); + } + + // --------------------------------------------------------------------- + // Plan 3h Task 6 (survivors — `places.service.ts` SV-PI2, `place-photo- + // cache.service.ts` SV-PP6, `place-image.ts` SV-PLACEIMG-2) — additive. + // --------------------------------------------------------------------- + + /** + * SV-PI2 (`places.service.ts::reclaimPlaceImage`) / SV-PLACEIMG-2 + * (`CollectionsService`'s own private `reclaimPlaceImage`, replacing + * `place-image.ts`'s free function) — `SELECT 1 FROM collection_places + * WHERE image_url = ? LIMIT 1`. Trip-agnostic, same shape as + * `PlacesRepository.existsByImageUrl` for its own table. + */ + async existsByImageUrl(imageUrl: string): Promise { + const row = await this.db_().selectFrom('collection_places').select('id').where('image_url', '=', imageUrl).limit(1).executeTakeFirst(); + return !!row; + } + + /** + * SV-PP6 (`place-photo-cache.service.ts::isReferenced`) — `SELECT 1 FROM + * collection_places WHERE google_place_id = ? OR image_url = ? LIMIT 1`, + * the same shape `PlacesRepository.existsByGoogleIdOrImageUrl` uses for + * its own table. + */ + async existsByGoogleIdOrImageUrl(googlePlaceId: string, imageUrl: string): Promise { + const row = await this.db_() + .selectFrom('collection_places') + .select('id') + .where((eb) => eb.or([eb('google_place_id', '=', googlePlaceId), eb('image_url', '=', imageUrl)])) + .limit(1) + .executeTakeFirst(); + return !!row; + } +} + +/** {@link CollectionPlacesRepository.findForCopy}'s narrow projection (CL62). */ +export interface CollectionPlaceCopyRow { + id: number; + collection_id: number; + name: string; + description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + currency: string | null; + notes: string | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + osm_id: string | null; + website: string | null; + phone: string | null; +} + +interface CollectionPlacesKyselyDB { + collection_places: Omit; + collection_place_tags: { collection_place_id: number; tag_id: number }; + collection_place_labels: { collection_place_id: number; label_id: number }; +} + +interface CollectionPlacesMembershipKyselyDB { + collection_places: Pick; + collections: { id: number; name: string }; +} diff --git a/server/src/db/repositories/Collections.repository.ts b/server/src/db/repositories/Collections.repository.ts new file mode 100644 index 000000000..97f956986 --- /dev/null +++ b/server/src/db/repositories/Collections.repository.ts @@ -0,0 +1,504 @@ +import type { Collections } from '../entities/Collections.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `collections` — every scalar column, `getCollectionRow`'s (CL14) `SELECT *` shape. */ +export interface CollectionRow { + id: number; + owner_id: number; + name: string; + description: string | null; + color: string | null; + icon: string | null; + cover_image: string | null; + links: string | null; + sort_order: number | null; + created_at: string | null; + updated_at: string | null; +} + +/** + * A saved place row, `cp.*` joined to its category name/color/icon — CL10 + * (`getPlaceById`, filtered by `cp.id`) and CL16/CL17 (`getCollection`/ + * `exportCollection`'s dup-text pair, filtered by `cp.collection_id`) both + * select this exact shape. `links` stays the raw TEXT column (`string | + * null`) here — never `CollectionLink[]` — the service's own `parseLinks` + * does that parsing on the way out, the same reason `CollectionRow.links` + * below is also raw TEXT: a hand-written row interface documents the DB + * shape, not the wire shape (`_shared/rows.ts`'s docstring). + */ +export interface CollectionPlaceRow { + id: number; + collection_id: number; + owner_id: number; + saved_by: number | null; + name: string; + description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + currency: string | null; + notes: string | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + osm_id: string | null; + website: string | null; + phone: string | null; + status: string; + source_trip_id: number | null; + source_place_id: number | null; + links: string | null; + sort_order: number | null; + created_at: string | null; + updated_at: string | null; + category_name: string | null; + category_color: string | null; + category_icon: string | null; +} + +interface CollectionsKyselyDB { + collections: CollectionRow; +} + +/** + * CL29's own `INSERT INTO collections (owner_id, name, description, color, + * icon, cover_image, links, sort_order)` column list, exactly — no `id`/ + * `created_at`/`updated_at` (never bound; the schema's own + * `DEFAULT CURRENT_TIMESTAMP`/autoincrement fill them), the + * `BucketListRepository`/`BucketListInsertKyselyDB` precedent for a + * narrower insert-only Kysely interface instead of `Generated<>` markers. + */ +interface CollectionsInsertKyselyDB { + collections: { + owner_id: number; + name: string; + description: string | null; + color: string | null; + icon: string | null; + cover_image: string | null; + links: string | null; + sort_order: number; + }; +} + +interface CollectionIdsKyselyDB { + collections: { id: number; owner_id: number }; + collection_members: { collection_id: number; user_id: number; status: string }; +} + +interface CollectionPlacesReadKyselyDB { + collection_places: Omit; + categories: { id: number; name: string; color: string | null; icon: string | null }; +} + +/** + * CL23's own `INSERT INTO collection_places (21 cols)` list, exactly — no + * `id`/`created_at`/`updated_at`, the same narrower-insert-interface shape + * as {@link CollectionsInsertKyselyDB} above. + */ +interface CollectionPlacesWriteKyselyDB { + collection_places: { + collection_id: number; + owner_id: number; + saved_by: number | null; + name: string; + description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + currency: string | null; + notes: string | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + osm_id: string | null; + website: string | null; + phone: string | null; + status: string; + links: string | null; + sort_order: number | null; + }; +} + +interface CollectionPlaceLabelsKyselyDB { + collection_place_labels: { collection_place_id: number; label_id: number }; +} + +/** + * `collections` — the root of the collections cluster (Plan 3h Task 1, part + * A). Owns every CL1-CL36 statement not moved to `CollectionMembersRepository`/ + * `CollectionLabelsRepository`: the `collections` table's own CRUD/reorder, + * plus — as a pragmatic first cut, since `CollectionPlacesRepository`/ + * `CollectionPlaceTagsRepository`/`CollectionPlaceRatingsRepository` don't + * exist yet (Task 2 builds them) — every hydration/export/import/dedup + * statement Part A's own methods (`hydratePlaces`, `getPlaceById`, + * `getCollection`/`exportCollection`, `writeFilePlaces`, + * `findDuplicateCollectionPlace`) need against `collection_places`/ + * `collection_place_tags`/`collection_place_labels`/`collection_place_ratings`. + * Task 2's own report should note whether any of these bridge methods move + * onto its new, more specific repositories once they exist — see this + * task's own report for the explicit flag. + */ +export class CollectionsRepository extends TrekRepository { + private readDb() { + return this.kysely(); + } + + private insertDb() { + return this.kysely(); + } + + private idsDb() { + return this.kysely(); + } + + private placesReadDb() { + return this.kysely(); + } + + private placesWriteDb() { + return this.kysely(); + } + + private placeLabelsDb() { + return this.kysely(); + } + + /** + * CL1 (`accessibleCollectionIds`) — `SELECT id FROM collections WHERE + * owner_id=? UNION SELECT collection_id FROM collection_members WHERE + * user_id=? AND status='accepted'`. Two typed selects, merged and + * deduplicated in TS (`Set`) the same way SQL's `UNION` (not `UNION ALL`) + * would — the legacy statement is `UNION`, so a row present in both halves + * collapses to one id either way. + */ + async accessibleCollectionIds(userId: number): Promise { + const owned = await this.idsDb().selectFrom('collections').select('id').where('owner_id', '=', userId).execute(); + const member = await this.idsDb() + .selectFrom('collection_members') + .select('collection_id as id') + .where('user_id', '=', userId) + .where('status', '=', 'accepted') + .execute(); + return [...new Set([...owned.map((r) => r.id), ...member.map((r) => r.id)])]; + } + + /** CL5 (`ownerOf`) — `SELECT owner_id FROM collections WHERE id=?`. */ + async ownerId(id: number): Promise { + const row = await this.readDb().selectFrom('collections').select('owner_id').where('id', '=', id).executeTakeFirst(); + return row?.owner_id; + } + + /** CL14 (`getCollectionRow`) — `SELECT * FROM collections WHERE id=?`. */ + async findRow(id: number): Promise { + return await this.readDb().selectFrom('collections').selectAll().where('id', '=', id).executeTakeFirst(); + } + + /** CL15 (`getCollectionRow`) — `SELECT COUNT(*) AS n FROM collection_places WHERE collection_id=?`. */ + async placeCount(collectionId: number): Promise { + const row = await this.placesReadDb() + .selectFrom('collection_places') + .select((eb) => eb.fn.countAll().as('n')) + .where('collection_id', '=', collectionId) + .executeTakeFirstOrThrow(); + return Number(row.n); + } + + /** CL28 (`createCollection`) — `SELECT COALESCE(MAX(sort_order),-1) AS m FROM collections WHERE owner_id=?`. */ + async maxSortOrder(ownerId: number): Promise { + const row = await this.readDb() + .selectFrom('collections') + .select((eb) => eb.fn.coalesce(eb.fn.max('sort_order'), eb.val(-1)).as('m')) + .where('owner_id', '=', ownerId) + .executeTakeFirstOrThrow(); + return Number(row.m); + } + + /** + * CL29 (`createCollection`) — `INSERT INTO collections (owner_id, name, + * description, color, icon, cover_image, links, sort_order) VALUES (...)`. + * Returns the new row's id. + */ + async insertCollection(row: { + owner_id: number; + name: string; + description: string | null; + color: string | null; + icon: string | null; + cover_image: string | null; + links: string | null; + sort_order: number; + }): Promise { + const result = await this.insertDb().insertInto('collections').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** + * CL30 (`updateCollection`) — the allow-listed dynamic `UPDATE collections + * SET ... WHERE id=?`. `write` carries exactly the columns the caller + * built (the service resolves `body.field !== undefined` itself, same + * shape as every other repository's `presenceSet`-fed write) — an empty + * `write` short-circuits before the `UPDATE`, matching the legacy + * `if (updates.length > 0)` guard. + */ + async updateFields( + id: number, + write: Partial<{ + name: string; + description: string | null; + color: string | null; + icon: string | null; + cover_image: string | null; + links: string | null; + sort_order: number; + }>, + ): Promise { + if (Object.keys(write).length === 0) return; + await this.readDb().updateTable('collections').set({ ...write, updated_at: new Date().toISOString() }).where('id', '=', id).execute(); + } + + /** CL31 (`setCollectionCover`) — `SELECT cover_image FROM collections WHERE id=?`. */ + async coverImage(id: number): Promise { + const row = await this.readDb().selectFrom('collections').select('cover_image').where('id', '=', id).executeTakeFirst(); + return row?.cover_image; + } + + /** CL32 (`setCollectionCover`) — `UPDATE collections SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`. */ + async setCoverImage(id: number, coverUrl: string | null): Promise { + await this.readDb().updateTable('collections').set({ cover_image: coverUrl, updated_at: new Date().toISOString() }).where('id', '=', id).execute(); + } + + /** CL35 (`deleteCollection`) — `DELETE FROM collections WHERE id=?` (CASCADE drops members + places + tags). */ + async deleteById(id: number): Promise { + await this.readDb().deleteFrom('collections').where('id', '=', id).execute(); + } + + /** + * CL36 (`reorderCollections`, TX `:712`) — `UPDATE collections SET + * sort_order=? WHERE id=?`, looped per visible id. The legacy statement + * is a `this.db.prepare(...)` reused across the loop (a single compiled + * statement, `.run()` called once per iteration) — Kysely's query builder + * has no equivalent "compile once, bind many" primitive for an UPDATE + * with per-row values, so this issues one `updateTable` call per pair + * instead (informational for Task 7: the "prepare once, loop `.run()`" + * shape does NOT survive this conversion, per-call is what the repository + * layer supports here). + */ + async setSortOrders(pairs: { id: number; sortOrder: number }[]): Promise { + const db = this.readDb(); + for (const { id, sortOrder } of pairs) { + await db.updateTable('collections').set({ sort_order: sortOrder }).where('id', '=', id).execute(); + } + } + + // ------------------------------------------------------------------------- + // Hydration / export / import / dedup bridge methods — CL6, CL8-CL11, + // CL16/17 (collapsed), CL22-CL27. First-cut home: these touch + // `collection_places`/`collection_place_tags`/`collection_place_labels`/ + // `collection_place_ratings`, tables Task 2's own repositories will own + // going forward, but Part A's `hydratePlaces`/`getPlaceById`/ + // `getCollection`/`exportCollection`/`writeFilePlaces`/ + // `findDuplicateCollectionPlace` are its own methods and need somewhere to + // land now. + // ------------------------------------------------------------------------- + + /** CL10 (`getPlaceById`) — `SELECT cp.*, c.name/color/icon AS category_* FROM collection_places cp LEFT JOIN categories c ON cp.category_id=c.id WHERE cp.id=?`. */ + async findPlaceRowById(id: number): Promise { + return await this.placesReadDb() + .selectFrom('collection_places as cp') + .leftJoin('categories as c', 'c.id', 'cp.category_id') + .select([ + 'cp.id', 'cp.collection_id', 'cp.owner_id', 'cp.saved_by', 'cp.name', 'cp.description', 'cp.lat', 'cp.lng', + 'cp.address', 'cp.category_id', 'cp.price', 'cp.currency', 'cp.notes', 'cp.image_url', 'cp.google_place_id', + 'cp.google_ftid', 'cp.osm_id', 'cp.website', 'cp.phone', 'cp.status', 'cp.source_trip_id', 'cp.source_place_id', + 'cp.links', 'cp.sort_order', 'cp.created_at', 'cp.updated_at', + 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', + ]) + .where('cp.id', '=', id) + .executeTakeFirst(); + } + + /** + * CL16/CL17 (`getCollection`/`exportCollection`'s dup-text pair, collapsed + * onto ONE repository method as the brief directs) — the same + * `cp.*`/category-alias select as {@link findPlaceRowById}, scoped by + * `cp.collection_id` and ordered by `cp.sort_order, cp.created_at`. + */ + async listPlaceRows(collectionId: number): Promise { + return await this.placesReadDb() + .selectFrom('collection_places as cp') + .leftJoin('categories as c', 'c.id', 'cp.category_id') + .select([ + 'cp.id', 'cp.collection_id', 'cp.owner_id', 'cp.saved_by', 'cp.name', 'cp.description', 'cp.lat', 'cp.lng', + 'cp.address', 'cp.category_id', 'cp.price', 'cp.currency', 'cp.notes', 'cp.image_url', 'cp.google_place_id', + 'cp.google_ftid', 'cp.osm_id', 'cp.website', 'cp.phone', 'cp.status', 'cp.source_trip_id', 'cp.source_place_id', + 'cp.links', 'cp.sort_order', 'cp.created_at', 'cp.updated_at', + 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', + ]) + .where('cp.collection_id', '=', collectionId) + .orderBy('cp.sort_order') + .orderBy('cp.created_at') + .execute(); + } + + /** CL11 (`collectionIdOfPlace`) — `SELECT collection_id FROM collection_places WHERE id=?`. */ + async collectionIdOfPlace(placeId: number): Promise { + const row = await this.placesReadDb().selectFrom('collection_places').select('collection_id').where('id', '=', placeId).executeTakeFirst(); + return row?.collection_id; + } + + /** + * CL6 (`loadTagsByCollectionPlaceIds`) — `SELECT cpt.collection_place_id + * AS pid, t.id, t.name, t.color FROM collection_place_tags cpt JOIN tags t + * ON t.id=cpt.tag_id WHERE cpt.collection_place_id IN (...)`. Empty input + * short-circuits to `[]` (no `IN ()` sent) — the service's own batched + * hydration helper already guards this, but the repository guards too so + * it is safe to call directly. + */ + async loadTagsByPlaceIds(placeIds: number[]): Promise<{ pid: number; id: number; name: string; color: string }[]> { + if (placeIds.length === 0) return []; + return await this.kysely<{ collection_place_tags: { collection_place_id: number; tag_id: number }; tags: { id: number; name: string; color: string } }>() + .selectFrom('collection_place_tags as cpt') + .innerJoin('tags as t', 't.id', 'cpt.tag_id') + .select(['cpt.collection_place_id as pid', 't.id', 't.name', 't.color']) + .where('cpt.collection_place_id', 'in', placeIds) + .execute(); + } + + /** CL8 (`loadLabelIdsByPlaceIds`) — `SELECT collection_place_id AS pid, label_id FROM collection_place_labels WHERE collection_place_id IN (...)`. */ + async loadLabelIdsByPlaceIds(placeIds: number[]): Promise<{ pid: number; label_id: number }[]> { + if (placeIds.length === 0) return []; + const rows = await this.placeLabelsDb() + .selectFrom('collection_place_labels') + .select(['collection_place_id as pid', 'label_id']) + .where('collection_place_id', 'in', placeIds) + .execute(); + return rows; + } + + /** + * CL9 (`loadRatingsByCollectionPlaceIds`, #1435) — `SELECT + * cpr.collection_place_id AS pid, cpr.user_id, u.username, u.avatar, + * cpr.rating FROM collection_place_ratings cpr JOIN users u ON + * u.id=cpr.user_id WHERE cpr.collection_place_id IN (...) ORDER BY + * cpr.created_at`. + */ + async loadRatingsByPlaceIds( + placeIds: number[], + ): Promise<{ pid: number; user_id: number; username: string; avatar: string | null; rating: number }[]> { + if (placeIds.length === 0) return []; + return await this.kysely<{ + collection_place_ratings: { collection_place_id: number; user_id: number; rating: number; created_at: string | null }; + users: { id: number; username: string; avatar: string | null }; + }>() + .selectFrom('collection_place_ratings as cpr') + .innerJoin('users as u', 'u.id', 'cpr.user_id') + .select(['cpr.collection_place_id as pid', 'cpr.user_id', 'u.username', 'u.avatar', 'cpr.rating']) + .where('cpr.collection_place_id', 'in', placeIds) + .orderBy('cpr.created_at') + .execute(); + } + + /** CL22 (`writeFilePlaces`) — `SELECT COALESCE(MAX(sort_order),-1) AS m FROM collection_places WHERE collection_id=?`. */ + async maxPlaceSortOrder(collectionId: number): Promise { + const row = await this.placesReadDb() + .selectFrom('collection_places') + .select((eb) => eb.fn.coalesce(eb.fn.max('sort_order'), eb.val(-1)).as('m')) + .where('collection_id', '=', collectionId) + .executeTakeFirstOrThrow(); + return Number(row.m); + } + + /** + * CL23 (`writeFilePlaces`, PREPARED/looped in the legacy code) — `INSERT + * INTO collection_places (21 cols incl. sort_order, no source_trip_id/ + * source_place_id) VALUES (...)`. Distinct insert shape from CL42/CL46 + * (Task 2's) — never collapsed with either. Returns the new row's id. + */ + async insertFilePlace(row: { + collection_id: number; + owner_id: number; + saved_by: number; + name: string; + description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + currency: string | null; + notes: string | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + osm_id: string | null; + website: string | null; + phone: string | null; + status: string; + links: string | null; + sort_order: number; + }): Promise { + const result = await this.placesWriteDb().insertInto('collection_places').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** CL24 (`writeFilePlaces`'s `assignLabel` prepared statement) — `INSERT OR IGNORE INTO collection_place_labels (collection_place_id, label_id) VALUES (?,?)`. */ + async assignPlaceLabel(collectionPlaceId: number, labelId: number): Promise { + await this.placeLabelsDb() + .insertInto('collection_place_labels') + .values({ collection_place_id: collectionPlaceId, label_id: labelId }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + /** CL25 (`findDuplicateCollectionPlace`, externalId branch) — `SELECT id,name FROM collection_places WHERE collection_id=? AND (google_place_id=? OR google_ftid=? OR osm_id=?) ORDER BY id ASC LIMIT 1`. */ + async findDuplicateByExternalId( + collectionId: number, + externalId: string, + ): Promise<{ id: number; name: string } | undefined> { + return await this.placesReadDb() + .selectFrom('collection_places') + .select(['id', 'name']) + .where('collection_id', '=', collectionId) + .where((eb) => eb.or([eb('google_place_id', '=', externalId), eb('google_ftid', '=', externalId), eb('osm_id', '=', externalId)])) + .orderBy('id', 'asc') + .limit(1) + .executeTakeFirst(); + } + + /** CL26 (`findDuplicateCollectionPlace`, name branch) — `SELECT id,name FROM collection_places WHERE collection_id=? AND lower(trim(name))=? ORDER BY id ASC LIMIT 1`. `normalizedName` is already `lower(trim(...))`-shaped by the caller (`_shared/../dialect/sql-functions.ts`'s `lowerTrim`/`lowerTrimParam` precedent). */ + async findDuplicateByName(collectionId: number, normalizedName: string): Promise<{ id: number; name: string } | undefined> { + return await this.placesReadDb() + .selectFrom('collection_places') + .select(['id', 'name']) + .where('collection_id', '=', collectionId) + .where((eb) => eb(eb.fn('lower', [eb.fn('trim', ['name'])]), '=', normalizedName)) + .orderBy('id', 'asc') + .limit(1) + .executeTakeFirst(); + } + + /** CL27 (`findDuplicateCollectionPlace`, coord branch) — `SELECT id,name FROM collection_places WHERE collection_id=? AND lat IS NOT NULL AND lng IS NOT NULL AND abs(lat-?)<=? AND abs(lng-?)<=? ORDER BY id ASC LIMIT 1`. */ + async findDuplicateByCoords( + collectionId: number, + lat: number, + lng: number, + tolerance: number, + ): Promise<{ id: number; name: string } | undefined> { + return await this.placesReadDb() + .selectFrom('collection_places') + .select(['id', 'name']) + .where('collection_id', '=', collectionId) + .where('lat', 'is not', null) + .where('lng', 'is not', null) + .where((eb) => eb(eb.fn('abs', [eb('lat', '-', lat)]), '<=', tolerance)) + .where((eb) => eb(eb.fn('abs', [eb('lng', '-', lng)]), '<=', tolerance)) + .orderBy('id', 'asc') + .limit(1) + .executeTakeFirst(); + } +} diff --git a/server/src/db/repositories/DawarichConnections.repository.ts b/server/src/db/repositories/DawarichConnections.repository.ts new file mode 100644 index 000000000..2dbb381c0 --- /dev/null +++ b/server/src/db/repositories/DawarichConnections.repository.ts @@ -0,0 +1,201 @@ +import { currentTimestamp } from '../dialect/sql-functions'; +import type { DawarichConnections } from '../entities/DawarichConnections.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `dawarich_connections` — every scalar column, for {@link AssertRowKeys}'s + * pin only. No method below returns this exact shape: DWC1's `findRow` + * covers 9 of the 11 (the legacy `readRow` statement never selected + * `created_at`/`updated_at`), and nothing else reads a full row. + */ +interface DawarichConnectionsAllColumns { + user_id: number; + url: string | null; + api_key: string | null; + allow_insecure_tls: number; + sync_enabled: number; + last_sync_at: string | null; + last_sync_state: string; + last_sync_error: string | null; + capabilities: string | null; + created_at: string | null; + updated_at: string | null; +} +const _dawarichConnectionsAllColumns: AssertRowKeys = true; + +/** + * DWC1's own 9-column read (`readRow`'s legacy statement text). **R7**: + * `api_key` is the still-encrypted TEXT value, passed through untouched — + * `maybe_encrypt_api_key`/`decrypt_api_key` are called from `DawarichService` + * only, never from this repository. + */ +export interface DawarichConnectionRow { + user_id: number; + url: string | null; + api_key: string | null; + allow_insecure_tls: number; + sync_enabled: number; + last_sync_at: string | null; + last_sync_state: string; + last_sync_error: string | null; + capabilities: string | null; +} + +/** + * `dawarich_connections` — one row per user, `user` (mirrored as `user_id`) + * the entity's own primary key (`[PrimaryKeyProp]?: 'user'`, same shape as + * `VacayUserSettingsRepository`'s own table). Every dawarich file depends on + * this repository's `getCredentials`/`listSyncableUserIds`/ + * `recordSyncResult` (via `DawarichService`) — convert this one first. + */ +export class DawarichConnectionsRepository extends TrekRepository { + /** + * DWC1 — `readRow`: `SELECT user_id, url, api_key, allow_insecure_tls, + * sync_enabled, last_sync_at, last_sync_state, last_sync_error, + * capabilities FROM dawarich_connections WHERE user_id = ?`. + * `mapResults: false` (matching `TripsRepository.findAccessible`'s own + * PK-is-FK read) so the raw driver row is returned as-is — `c.user` is the + * relation property; SQL still names the physical `user_id` column. + */ + async findRow(userId: number): Promise { + const row = await this.qb('c') + .select(['c.user', 'c.url', 'c.api_key', 'c.allow_insecure_tls', 'c.sync_enabled', 'c.last_sync_at', 'c.last_sync_state', 'c.last_sync_error', 'c.capabilities']) + .where({ user: userId }) + .execute('get', false); + return row ?? null; + } + + /** + * DWC2 — `listSyncableUserIds`: `SELECT user_id FROM dawarich_connections + * WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS + * NOT NULL`. **NON-HTTP ENTRYPOINT SOURCE** — the cron's user list. Three + * `.andWhere` calls, not one combined filter object: `url`'s two + * conditions (`IS NOT NULL` and `<> ''`) cannot both live under one + * `{ url: {...} }` key. + */ + async listSyncableUserIds(): Promise { + const rows = await this.qb('c') + .select(['c.user']) + .where({ sync_enabled: 1 }) + .andWhere({ url: { $ne: null } }) + .andWhere({ url: { $ne: '' } }) + .andWhere({ api_key: { $ne: null } }) + .execute<{ user_id: number }[]>('all', false); + return rows.map((r) => r.user_id); + } + + /** DWC3 — `saveSettings`'s previous-host read: `SELECT url FROM dawarich_connections WHERE user_id = ?`. */ + async getUrl(userId: number): Promise { + const row = await this.qb('c') + .select(['c.url']) + .where({ user: userId }) + .execute<{ url: string | null } | undefined>('get', false); + return row?.url ?? null; + } + + /** + * DWC4 — `saveSettings`'s upsert: `INSERT INTO dawarich_connections + * (user_id, url, allow_insecure_tls, sync_enabled, updated_at) VALUES (?, + * ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET url = + * excluded.url, allow_insecure_tls = excluded.allow_insecure_tls, + * sync_enabled = excluded.sync_enabled, updated_at = CURRENT_TIMESTAMP`. + * Single-column-PK upsert, `onConflictFields: ['user']`. + * `onConflictMergeFields` narrowed to exactly the four columns the legacy + * statement's own `SET` list touches (`OauthConsentsRepository.upsertGrant`'s + * precedent for the unconditional `updated_at` stamp; + * `VacayUserYearsRepository.upsertCarriedOver`'s precedent for narrowing + * the merge set) — `api_key`/`capabilities`/`last_sync_*` are untouched on + * conflict and default to NULL/`'never'` on a genuine INSERT, matching the + * legacy statement's own column list exactly. + */ + async upsertConnection(userId: number, data: { url: string | null; allowInsecureTls: boolean; syncEnabled: boolean }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.upsert( + { + user: userId, + url: data.url, + allow_insecure_tls: data.allowInsecureTls ? 1 : 0, + sync_enabled: data.syncEnabled ? 1 : 0, + updated_at: currentTimestamp(platform), + }, + { onConflictFields: ['user'], onConflictAction: 'merge', onConflictMergeFields: ['url', 'allow_insecure_tls', 'sync_enabled', 'updated_at'] }, + ); + } + + /** + * DWC5/DWC7 — `saveSettings`'s key write/scrub, one statement text: + * `UPDATE dawarich_connections SET api_key = ? WHERE user_id = ?`. + * **SECURITY**: `apiKey` is the already-encrypted TEXT (or `null` to + * scrub), passed through untouched (R7). + */ + async setApiKey(userId: number, apiKey: string | null): Promise { + await this.nativeUpdate({ user: userId }, { api_key: apiKey }); + } + + /** + * DWC6 — `saveSettings`'s host-moved reset: `UPDATE dawarich_connections + * SET capabilities = NULL, last_sync_state = 'never', last_sync_error = + * NULL, last_sync_at = NULL WHERE user_id = ?`. + */ + async resetSyncState(userId: number): Promise { + await this.nativeUpdate({ user: userId }, { capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null }); + } + + /** + * DWC8 — `saveSettings`'s address-cleared reset: `UPDATE + * dawarich_connections SET api_key = NULL, capabilities = NULL, + * last_sync_state = 'never', last_sync_error = NULL, last_sync_at = NULL + * WHERE user_id = ?` — {@link resetSyncState}'s four columns plus + * `api_key`, one statement (never two calls: the legacy text is a single + * UPDATE with five `SET` clauses). + */ + async clearForRemovedUrl(userId: number): Promise { + await this.nativeUpdate({ user: userId }, { api_key: null, capabilities: null, last_sync_state: 'never', last_sync_error: null, last_sync_at: null }); + } + + /** + * DWC9 — `disconnect`: `DELETE FROM dawarich_connections WHERE user_id = + * ?`. **PARITY**: does not cascade to `dawarich_visit_suggestions` — + * deliberate (the entity carries no relation from suggestions back to a + * connection at all, so there is nothing for a migration-level cascade to + * even attach to). + */ + async disconnect(userId: number): Promise { + await this.nativeDelete({ user: userId }); + } + + /** DWC10 — `storeCapabilities`: `UPDATE dawarich_connections SET capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`. */ + async storeCapabilities(userId: number, capabilitiesJson: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ user: userId }, { capabilities: capabilitiesJson, updated_at: currentTimestamp(platform) }); + } + + /** + * DWC11 — `recordSyncResult`: `UPDATE dawarich_connections SET + * last_sync_at = ?, last_sync_state = ?, last_sync_error = ?, updated_at = + * CURRENT_TIMESTAMP WHERE user_id = ?`. **NON-HTTP ENTRYPOINT** — called + * after every cron sync. + */ + async recordSyncResult(userId: number, data: { lastSyncAt: string; state: string; error: string | null }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { user: userId }, + { last_sync_at: data.lastSyncAt, last_sync_state: data.state, last_sync_error: data.error, updated_at: currentTimestamp(platform) }, + ); + } + + /** + * DSY1 — `dawarich-sync.service.ts#syncUser`'s in-flight-branch read, a + * narrower dup shape of DWC1: `SELECT last_sync_state FROM + * dawarich_connections WHERE user_id = ?`. Lives here (not on + * `DawarichVisitSuggestionsRepository`) because it reads this table. + */ + async getLastSyncState(userId: number): Promise { + const row = await this.qb('c') + .select(['c.last_sync_state']) + .where({ user: userId }) + .execute<{ last_sync_state: string } | undefined>('get', false); + return row?.last_sync_state ?? null; + } +} diff --git a/server/src/db/repositories/DawarichVisitSuggestions.repository.ts b/server/src/db/repositories/DawarichVisitSuggestions.repository.ts new file mode 100644 index 000000000..84ed763ce --- /dev/null +++ b/server/src/db/repositories/DawarichVisitSuggestions.repository.ts @@ -0,0 +1,399 @@ +import { coalesceParam, columnRef } from '../dialect/sql-functions'; +import type { DawarichVisitSuggestions } from '../entities/DawarichVisitSuggestions.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `dawarich_visit_suggestions` — every scalar column, DWS6/DSY3's `SELECT *` shape. */ +export interface SuggestionRow { + id: number; + user_id: number; + source_visit_id: string; + trip_id: number | null; + name: string; + lat: number | null; + lng: number | null; + started_at: string; + ended_at: string; + duration_minutes: number; + local_date: string; + source_status: string; + confidence: number | null; + confidence_band: string | null; + country_code: string | null; + state: string; + target: string | null; + accepted_place_id: number | null; + accepted_journal_entry_id: number | null; + accepted_bucket_list_item_id: number | null; + matched_bucket_list_item_id: number | null; + source_hash: string; + accepted_hash: string | null; + source_missing_at: string | null; + first_seen_at: string; + last_seen_at: string; +} +const _suggestionRowKeys: AssertRowKeys = true; + +/** DWS1/DWS3's joined shape — `SuggestionRow` plus the two `LEFT JOIN`-derived columns. */ +export interface SuggestionJoinRow extends SuggestionRow { + trip_title: string | null; + matched_bucket_name: string | null; +} + +/** The narrow `dawarich_visit_suggestions`/`trips`/`bucket_list` shape DWS1/DWS3's join needs. */ +interface SuggestionJoinKyselyDB { + dawarich_visit_suggestions: SuggestionRow; + trips: { id: number; title: string | null }; + bucket_list: { id: number; name: string }; +} + +/** The narrow `dawarich_visit_suggestions` shape a bare `SELECT *` read needs (DWS6/DSY3). */ +interface SuggestionAllKyselyDB { + dawarich_visit_suggestions: SuggestionRow; +} + +/** + * The narrow `dawarich_visit_suggestions`/`journey_entries` shape DWS2's + * `reopenOrphaned` needs. `journey_entries` is 3g's table, read cross-domain + * over `this.kysely()` — the same "a repository may query any table" + * convention `JourneyEntriesRepository`'s own cross-domain reads + * (`listStatsTrips`/`listSuggestedTrips`) already establish — rather than a + * `JourneyEntriesRepository.existsById` call: 3g's Task 4 (the survivors + * task that would add that method) had not landed at the time this task + * ran, and appending to a Journey* repository is outside this task's file + * set (owned by that concurrent implementer). A single self-contained + * `NOT EXISTS` subquery inside the SAME `UPDATE` also preserves the legacy + * statement's one-round-trip shape more faithfully than a JS-side + * per-row `existsById` loop would have. + */ +interface ReopenOrphanedKyselyDB { + dawarich_visit_suggestions: SuggestionRow; + journey_entries: { id: number }; +} + +/** The narrow shape DSY7's window-candidates read needs. */ +interface CandidateRow { + id: number; + source_visit_id: string; + state: string; +} + +/** The narrow shape DSY11's wish-holders read needs. */ +interface HolderRow { + id: number; + source_visit_id: string; + lat: number | null; + lng: number | null; + duration_minutes: number; +} + +/** + * `dawarich_visit_suggestions` — one row per Dawarich stay TREK has synced + * in, reviewable by the user it belongs to. Depends on + * `DawarichConnectionsRepository` (via `DawarichService`) for credentials; + * reaches `journey_entries` (3g), `places` (3c, additive `PlacesRepository + * .setSource`) and `bucket_list` (3f, additive `BucketListRepository` + * methods) cross-domain. + */ +export class DawarichVisitSuggestionsRepository extends TrekRepository { + private joinedQuery() { + return this.kysely() + .selectFrom('dawarich_visit_suggestions as s') + .leftJoin('trips as t', 't.id', 's.trip_id') + .leftJoin('bucket_list as b', 'b.id', 's.matched_bucket_list_item_id') + .selectAll('s') + .select(['t.title as trip_title', 'b.name as matched_bucket_name']); + } + + /** + * DWS1 — `list`: `SELECT s.*, t.title AS trip_title, b.name AS + * matched_bucket_name FROM dawarich_visit_suggestions s LEFT JOIN trips t + * LEFT JOIN bucket_list b WHERE (dynamic: s.user_id=? [+ s.trip_id=?] [+ + * s.state=?]) ORDER BY s.started_at DESC, s.id DESC`. **SECURITY**: + * `user_id` is scoped in SQL, never filtered after — these rows are + * someone's location history. + */ + async list(userId: number, filter: { tripId?: number; state?: string }): Promise { + let q = this.joinedQuery().where('s.user_id', '=', userId); + if (filter.tripId !== undefined) q = q.where('s.trip_id', '=', filter.tripId); + if (filter.state) q = q.where('s.state', '=', filter.state); + const rows = await q.orderBy('s.started_at', 'desc').orderBy('s.id', 'desc').execute(); + return rows as SuggestionJoinRow[]; + } + + /** + * DWS2 — `reopenOrphaned`: put back into review every acceptance whose + * result no longer exists. Returns the affected-row count (unused by the + * caller today — `list`'s own re-select after this write is what the + * service reads — but kept for parity with every other write method here). + */ + async reopenOrphaned(userId: number): Promise { + const result = await this.kysely() + .updateTable('dawarich_visit_suggestions') + .set({ + state: 'new', + target: null, + accepted_hash: null, + accepted_place_id: null, + accepted_journal_entry_id: null, + accepted_bucket_list_item_id: null, + }) + .where('user_id', '=', userId) + .where('state', '=', 'accepted') + .where((eb) => + eb.or([ + eb.and([eb('target', '=', 'place'), eb('accepted_place_id', 'is', null)]), + eb.and([eb('target', '=', 'bucket_list'), eb('accepted_bucket_list_item_id', 'is', null)]), + eb.and([ + eb('target', '=', 'journal'), + eb.or([ + eb('accepted_journal_entry_id', 'is', null), + eb.not( + eb.exists( + eb + .selectFrom('journey_entries') + .select('id') + .whereRef('journey_entries.id', '=', 'dawarich_visit_suggestions.accepted_journal_entry_id'), + ), + ), + ]), + ]), + ]), + ) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0); + } + + /** DWS3 — `getOne`: single-row variant of {@link list}'s join. */ + async getOne(userId: number, id: number): Promise { + const row = await this.joinedQuery().where('s.id', '=', id).where('s.user_id', '=', userId).executeTakeFirst(); + return (row as SuggestionJoinRow) ?? null; + } + + /** DWS4 — `setState`'s guard read: `SELECT id, state FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?`. */ + async findStateForUser(id: number, userId: number): Promise<{ id: number; state: string } | null> { + const row = await this.qb('s') + .select(['s.id', 's.state']) + .where({ id, user: userId }) + .execute<{ id: number; state: string } | undefined>('get', false); + return row ?? null; + } + + /** DWS5 — `setState`: `UPDATE dawarich_visit_suggestions SET state = ? WHERE id = ?`. */ + async setState(id: number, state: string): Promise { + await this.nativeUpdate({ id }, { state }); + } + + /** + * DWS6/DSY3 — `SELECT * FROM dawarich_visit_suggestions WHERE id = ? AND + * user_id = ?` (`accept`'s guard) and `WHERE user_id = ? AND + * source_visit_id = ?` (`syncTripWindow`'s existing-row read), one shape + * with a different WHERE — {@link findForUser}/{@link findByUserAndVisit}. + * Kysely `selectAll()` rather than a MikroORM `qb` read: every + * `accepted_*`/`trip_id`/`matched_bucket_list_item_id` column is a + * `persist(false)` shadow FK on this entity, and a plain `SELECT *` over + * the physical table sidesteps that trap entirely rather than needing a + * `columnRef`/full-entity-load workaround for each one. + */ + async findForUser(id: number, userId: number): Promise { + const row = await this.kysely() + .selectFrom('dawarich_visit_suggestions') + .selectAll() + .where('id', '=', id) + .where('user_id', '=', userId) + .executeTakeFirst(); + return (row as SuggestionRow) ?? null; + } + + /** DSY3 — `syncTripWindow`'s existing-row read: `SELECT * FROM dawarich_visit_suggestions WHERE user_id = ? AND source_visit_id = ?`. Implies `UNIQUE(user_id, source_visit_id)` (the entity's own `uniques`). */ + async findByUserAndVisit(userId: number, sourceVisitId: string): Promise { + const row = await this.kysely() + .selectFrom('dawarich_visit_suggestions') + .selectAll() + .where('user_id', '=', userId) + .where('source_visit_id', '=', sourceVisitId) + .executeTakeFirst(); + return (row as SuggestionRow) ?? null; + } + + /** DSY4 — `syncTripWindow`'s not-existing branch: the 17-column `INSERT`, `state` fixed to `'new'`. Returns the new row's id. */ + async insertVisit(data: { + user_id: number; + source_visit_id: string; + trip_id: number | null; + name: string; + lat: number | null; + lng: number | null; + started_at: string; + ended_at: string; + duration_minutes: number; + local_date: string; + source_status: string; + confidence: number | null; + confidence_band: string | null; + country_code: string | null; + source_hash: string; + first_seen_at: string; + last_seen_at: string; + }): Promise { + return await this.insert({ + user: data.user_id, + source_visit_id: data.source_visit_id, + trip: data.trip_id, + name: data.name, + lat: data.lat, + lng: data.lng, + started_at: data.started_at, + ended_at: data.ended_at, + duration_minutes: data.duration_minutes, + local_date: data.local_date, + source_status: data.source_status, + confidence: data.confidence, + confidence_band: data.confidence_band, + country_code: data.country_code, + state: 'new', + source_hash: data.source_hash, + first_seen_at: data.first_seen_at, + last_seen_at: data.last_seen_at, + }); + } + + /** DSY5 — `syncTripWindow`'s existing, `state='new'` branch: the full 15-column overwrite (a stay nobody has acted on yet). */ + async updateNewVisit( + id: number, + data: { + name: string; + lat: number | null; + lng: number | null; + started_at: string; + ended_at: string; + duration_minutes: number; + local_date: string; + source_status: string; + confidence: number | null; + confidence_band: string | null; + country_code: string | null; + source_hash: string; + trip_id: number | null; + last_seen_at: string; + }, + ): Promise { + await this.nativeUpdate( + { id }, + { + name: data.name, + lat: data.lat, + lng: data.lng, + started_at: data.started_at, + ended_at: data.ended_at, + duration_minutes: data.duration_minutes, + local_date: data.local_date, + source_status: data.source_status, + confidence: data.confidence, + confidence_band: data.confidence_band, + country_code: data.country_code, + source_hash: data.source_hash, + source_missing_at: null, + trip: data.trip_id, + last_seen_at: data.last_seen_at, + }, + ); + } + + /** + * DSY6 — `syncTripWindow`'s existing, accepted/dismissed branch: `UPDATE + * dawarich_visit_suggestions SET source_hash = ?, source_missing_at = + * NULL, last_seen_at = ? WHERE id = ?`. **PARITY**: "nothing the user can + * see is rewritten". + */ + async refreshHash(id: number, sourceHash: string, lastSeenAt: string): Promise { + await this.nativeUpdate({ id }, { source_hash: sourceHash, source_missing_at: null, last_seen_at: lastSeenAt }); + } + + /** DSY7 — `flagMissing`'s candidates read: `SELECT id, source_visit_id, state FROM dawarich_visit_suggestions WHERE user_id = ? AND trip_id = ? AND local_date >= ? AND local_date <= ?`. **PARITY-CRITICAL**: `local_date` string bounding — see the SERVICE's own doc comment on why. */ + async listCandidatesForWindow(userId: number, tripId: number, from: string, to: string): Promise { + return await this.qb('s') + .select(['s.id', 's.source_visit_id', 's.state']) + .where({ user: userId, trip: tripId, local_date: { $gte: from, $lte: to } }) + .execute('all', false); + } + + /** DSY8 — `flagMissing`'s delete branch (only unseen `'new'`-state rows): `DELETE FROM dawarich_visit_suggestions WHERE id = ?`. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * DSY9 — `flagMissing`'s stamp branch: `UPDATE dawarich_visit_suggestions + * SET source_missing_at = COALESCE(source_missing_at, ?) WHERE id = ?` — + * preserves the FIRST-missing timestamp. `coalesceParam` as an UPDATE-SET + * value (its own documented shape), not a filter key. + */ + async stampMissingIfUnset(id: number, stamp: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { source_missing_at: coalesceParam(platform, 'source_missing_at', stamp) }); + } + + /** + * DWS11 — `markAccepted`: `UPDATE dawarich_visit_suggestions SET + * state='accepted', target=?, accepted_place_id=?, + * accepted_journal_entry_id=?, accepted_bucket_list_item_id=?, + * accepted_hash=source_hash WHERE id=?`. **`accepted_hash = source_hash` + * is a self-column copy** — `columnRef(platform, 'source_hash')`, a + * column-REFERENCE expression read at UPDATE time, never a bound + * parameter: binding the row's CURRENT `source_hash` as a literal would + * freeze it at the moment this method was CALLED, not at the moment the + * UPDATE actually commits — wrong the instant a concurrent sync run + * changes `source_hash` between the two. `acceptedPlace`/ + * `acceptedBucketListItem` (relation properties, not their `persist(false)` + * `_id` mirrors) for the two FK writes; `accepted_journal_entry_id` is a + * plain persisted integer (journey entries carry no FK back from this + * table) written directly. + */ + async markAccepted( + id: number, + data: { target: string; placeId: number | null; journalEntryId: number | null; bucketItemId: number | null }, + ): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + state: 'accepted', + target: data.target, + acceptedPlace: data.placeId, + accepted_journal_entry_id: data.journalEntryId, + acceptedBucketListItem: data.bucketItemId, + accepted_hash: columnRef(platform, 'source_hash'), + }, + ); + } + + /** DSY11 — `claimWish`'s holders read: `SELECT id, source_visit_id, lat, lng, duration_minutes FROM dawarich_visit_suggestions WHERE user_id = ? AND matched_bucket_list_item_id = ? AND source_visit_id <> ?`. */ + async listHoldersOfWish(userId: number, wishId: number, excludeSourceVisitId: string): Promise { + return await this.qb('s') + .select(['s.id', 's.source_visit_id', 's.lat', 's.lng', 's.duration_minutes']) + .where({ user: userId, matchedBucketListItem: wishId, source_visit_id: { $ne: excludeSourceVisitId } }) + .execute('all', false); + } + + /** DSY12 — `claimWish` (same TX as {@link assignWishHolder}, order matters): `UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = NULL WHERE user_id = ? AND matched_bucket_list_item_id = ?` — clears every previous holder. */ + async clearWishHolders(userId: number, wishId: number): Promise { + await this.nativeUpdate({ user: userId, matchedBucketListItem: wishId }, { matchedBucketListItem: null }); + } + + /** DSY13 — `claimWish`: `UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = ? WHERE user_id = ? AND source_visit_id = ?` — assigns the winner. Called AFTER {@link clearWishHolders} in the same transaction, never before. */ + async assignWishHolder(userId: number, sourceVisitId: string, wishId: number): Promise { + await this.nativeUpdate({ user: userId, source_visit_id: sourceVisitId }, { matchedBucketListItem: wishId }); + } + + /** DWS14 — `matchedStayStart`: `SELECT started_at FROM dawarich_visit_suggestions WHERE user_id = ? AND matched_bucket_list_item_id = ? ORDER BY started_at DESC LIMIT 1`. */ + async matchedStayStart(userId: number, itemId: number): Promise { + const row = await this.qb('s') + .select(['s.started_at']) + .where({ user: userId, matchedBucketListItem: itemId }) + .orderBy({ started_at: 'desc' }) + .limit(1) + .execute<{ started_at: string } | undefined>('get', false); + return row?.started_at ?? null; + } +} diff --git a/server/src/db/repositories/DayAccommodations.repository.ts b/server/src/db/repositories/DayAccommodations.repository.ts new file mode 100644 index 000000000..039dd224a --- /dev/null +++ b/server/src/db/repositories/DayAccommodations.repository.ts @@ -0,0 +1,540 @@ +import type { DayAccommodations } from '../entities/DayAccommodations.entity'; +import { coalesceOverride, columnRef } from '../dialect/sql-functions'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A `day_accommodations` row exactly as AC34 (`AccommodationsService + * .getAccommodation`) re-selects it — every scalar column of the entity + * (`a.*`), the trip-scoping guard every write path (REST update/delete, MCP, + * RPC) re-reads through. + */ +export interface DayAccommodationRow { + id: number; + trip_id: number; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + confirmation: string | null; + notes: string | null; + created_at: string | null; +} + +const _dayAccommodationRowKeys: AssertRowKeys = true; + +/** AC2's projection: {@link DayAccommodationRow} plus the linked place's display fields. */ +export interface DayAccommodationWithPlaceRow extends DayAccommodationRow { + place_name: string | null; + place_address: string | null; + place_image: string | null; + place_lat: number | null; + place_lng: number | null; +} + +/** AC3's projection: {@link DayAccommodationWithPlaceRow} plus the linked booking's title — one row per linked `reservations` row (the LEFT JOIN fans out; parity keeps the duplicates, AC3's own ruling). */ +export interface DayAccommodationListRow extends DayAccommodationWithPlaceRow { + reservation_title: string | null; +} + +/** + * Kysely typing for AC3 (`listForTripWithPlaceAndBooking`) — `reservations + * .accommodation_id` is a bare `p.text().nullable()` column with no FK to + * `day_accommodations` (§18.1 of the inventory, the same gap + * `ReservationsRepository.joinedQuery` documents from the other side), so + * the QueryBuilder's relation-path `.join()` cannot express `LEFT JOIN + * reservations r ON r.accommodation_id = a.id` at all. + */ +interface DayAccommodationListKyselyDB { + day_accommodations: { + id: number; + // `number | string` — `ReservationsRepository.listForTrip`'s own + // `trip_id` widening: the raw-bind flexibility a `.where(col, '=', + // value)` call keeps regardless of how loosely its value is typed + // (program rule 23 bans a raw SQL-text condition, not a loosely-typed + // bound value). + trip_id: number | string; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + confirmation: string | null; + notes: string | null; + created_at: string | null; + }; + places: { + id: number; + name: string; + address: string | null; + image_url: string | null; + lat: number | null; + lng: number | null; + }; + reservations: { + accommodation_id: string | null; + title: string; + }; +} + +/** + * Plan 3d Task 2 (`ReservationsService`'s `day_accommodations` writes — RS27, + * RS29, RS30, RS37–RS40, RS42, RS43, RS46, RS47 — plus the RS21 dispatch + * union's `'day_accommodations'` arm). This is the reservations-owned SUBSET + * of the repository: `days.service.ts` DY19/DY20/DY22 and `places.service.ts` + * PL16 (Plan 3d Task 3) add methods here too — the class is shared, per the + * inventory's own note (§7) — Task 3 appends rather than replaces. + * + * `day_accommodations` has no MikroORM relation to `reservations` + * (`reservations.accommodation_id` is a bare `p.text().nullable()` column, + * no FK — Task 0's R10 finding) — every method here that only reaches this + * table stays on the QueryBuilder/`nativeUpdate`/`insert`, never Kysely: the + * TEXT-vs-INTEGER join lives in `ReservationsRepository`, not here. + */ +export class DayAccommodationsRepository extends TrekRepository { + /** + * RS27/RS40 (`ReservationsService.createInTx`/`updateInTx`'s auto-create + * branch) — `INSERT INTO day_accommodations (trip_id, place_id, + * start_day_id, end_day_id, check_in, check_out, confirmation) VALUES + * (?×7)`. Seven columns only — no `check_in_end`, no `notes` (unlike + * `AccommodationsService`'s own AC30, a 9-column insert — a DIFFERENT + * method, D4). The caller passes already-decided values (the `|| null` + * coercions stay in the service); `em.insert()` returns the generated PK. + */ + async insertBookingStay(input: { + trip_id: number | string; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_out: string | null; + confirmation: string | null; + }): Promise { + return await this.insert({ + trip: input.trip_id, + place: input.place_id, + startDay: input.start_day_id, + endDay: input.end_day_id, + check_in: input.check_in, + check_out: input.check_out, + confirmation: input.confirmation, + }); + } + + /** + * RS29/RS42 — `UPDATE day_accommodations SET check_in = COALESCE(?, + * check_in), check_in_end = COALESCE(?, check_in_end), check_out = + * COALESCE(?, check_out) WHERE id = ?`. `coalesceOverride` (value-side, a + * bound `?` FIRST, the column the fallback — the new value wins) through + * `qb().update()` — `nativeUpdate`'s `EntityData` shape doesn't accept a + * raw fragment value (`DayAssignmentsRepository.shiftOrderFrom`'s + * precedent). + */ + async patchTimes(id: number, check_in: string | null, check_in_end: string | null, check_out: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ + check_in: coalesceOverride(platform, check_in, 'check_in'), + check_in_end: coalesceOverride(platform, check_in_end, 'check_in_end'), + check_out: coalesceOverride(platform, check_out, 'check_out'), + }) + .where({ id }) + .execute('run'); + } + + /** RS30/RS43 — `UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?`. */ + async patchConfirmation(id: number, confirmation: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ confirmation: coalesceOverride(platform, confirmation, 'confirmation') }) + .where({ id }) + .execute('run'); + } + + /** + * RS37/RS46 — `SELECT id FROM day_accommodations WHERE id = ? AND trip_id + * = ?`. `id`/`trip_id: number` (typed filter, program rule 23): both + * callers (`updateInTx`'s re-link scoping check, `remove`'s own-stay + * check) pass the SAME `toRowId`-parsed trip id the service method itself + * resolved once at its gate. + */ + async existsInTrip(id: number, trip_id: number): Promise { + const row = await this.qb('a') + .select(['a.id']) + .where({ id, trip: trip_id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** RS38 — `SELECT check_in FROM day_accommodations WHERE id = ?`. */ + async getCheckIn(id: number): Promise { + const row = await this.qb('a') + .select(['a.check_in']) + .where({ id }) + .execute<{ check_in: string | null } | undefined>('get', false); + return row?.check_in; + } + + /** + * RS39 — `UPDATE day_accommodations SET place_id = ?, start_day_id = ?, + * end_day_id = ?, check_in = ?, check_out = ?, confirmation = ? WHERE id = + * ?`. Six columns (no `check_in_end`/`notes`, unlike `AccommodationsService + * .moveStay`'s own write) — the caller's already-decided values, written + * verbatim (the `PlacesRepository.updatePlace`/PL11 precedent: no SQL-side + * COALESCE here, the legacy statement itself has none for these columns). + */ + async updateFromBooking(id: number, write: { + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_out: string | null; + confirmation: string | null; + }): Promise { + await this.nativeUpdate({ id }, { + place: write.place_id, + startDay: write.start_day_id, + endDay: write.end_day_id, + check_in: write.check_in, + check_out: write.check_out, + confirmation: write.confirmation, + }); + } + + /** RS47 — `DELETE FROM day_accommodations WHERE id = ? AND trip_id = ?`, run AFTER `accommodations.dropStayStops` (ordering load-bearing, kept in the service). `id`/`trip_id: number` (typed filter, program rule 23; rule 21 — the SAME `toRowId`-parsed trip id `existsInTrip`'s gate read used). */ + async deleteInTrip(id: number, trip_id: number): Promise { + await this.nativeDelete({ id, trip: trip_id }); + } + + /** + * RS21's `'day_accommodations'` dispatch arm (`ReservationsService + * .referencesOutsideTrip`'s `elsewhere('day_accommodations', + * data.accommodation_id)` — `SELECT trip_id FROM day_accommodations WHERE + * id = ?`). A missing row is not an offender (the caller's own `!!row && + * String(row.trip_id) !== String(tripId)` check, preserved in the + * service) — this returns `undefined` on a miss, never throws. + */ + async getTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('a') + .select([columnRef(platform, 'a.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 3 (`AccommodationsService`, `days.service.ts` DY19/DY20/DY22, + // `places.service.ts` PL16) — appended after Task 2's own methods above, + // per this task's file-ownership rule ("the class is shared... Task 3 + // appends rather than replaces"). + // --------------------------------------------------------------------------- + + /** + * AC2 (`AccommodationsService.getAccommodationWithPlace`) — `SELECT a.*, + * p.name as place_name, p.address as place_address, p.image_url as + * place_image, p.lat as place_lat, p.lng as place_lng FROM + * day_accommodations a LEFT JOIN places p ON a.place_id = p.id WHERE a.id + * = ?`. The re-select every stay write answers with, post-commit. + */ + async findWithPlace(id: number): Promise { + // `{ 'a.id': id }`, not a bare `{ id }` (`DayAssignmentsRepository + // .findWithPlaceAndCategory`'s precedent): the LEFT JOIN to `places` + // means an unqualified `id` key is ambiguous between `a.id` and `p.id`. + return await this.qb('a') + .leftJoin('a.place', 'p') + .select(['a.*', 'p.name as place_name', 'p.address as place_address', 'p.image_url as place_image', 'p.lat as place_lat', 'p.lng as place_lng']) + .where({ 'a.id': id }) + .execute('get', false); + } + + /** + * AC3 (`AccommodationsService.listAccommodations`) — AC2's projection plus + * `r.title as reservation_title` via `LEFT JOIN reservations r ON + * r.accommodation_id = a.id`, `WHERE a.trip_id = ? ORDER BY a.created_at + * ASC`. Kysely (the reservations join, see {@link DayAccommodationListKyselyDB}). + * Fans out one row per linked booking — the legacy statement's own shape + * (the delete path documents more than one booking per stay is legal) — + * parity keeps the duplicates, no `DISTINCT`. + */ + async listForTripWithPlaceAndBooking(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .leftJoin('places as p', 'p.id', 'a.place_id') + .leftJoin('reservations as r', 'r.accommodation_id', 'a.id') + .selectAll('a') + .select([ + 'p.name as place_name', + 'p.address as place_address', + 'p.image_url as place_image', + 'p.lat as place_lat', + 'p.lng as place_lng', + 'r.title as reservation_title', + ]) + .where('a.trip_id', '=', trip_id) + .orderBy('a.created_at', 'asc') + .execute(); + return rows as DayAccommodationListRow[]; + } + + /** + * AC34 (`AccommodationsService.getAccommodation`) — `SELECT * FROM + * day_accommodations WHERE id = ? AND trip_id = ?`, the trip-scoping + * guard every write path (REST update/delete, MCP, RPC) re-reads through. + * `id`/`trip_id: number` (rule 21 — the SERVICE parses both ONCE via + * `toRowId` before calling this). + */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.qb('a') + .select(['a.*']) + .where({ id, trip: trip_id }) + .execute('get', false); + } + + /** + * AC30 (`AccommodationsService.createAccommodation`) — `INSERT INTO + * day_accommodations (trip_id, place_id, start_day_id, end_day_id, + * check_in, check_in_end, check_out, confirmation, notes) VALUES (?×9)`. + * Nine columns — a DIFFERENT method from Task 2's own `insertBookingStay` + * (seven columns, no `check_in_end`/`notes`, D4). The caller passes + * already-decided values (the `|| null` coercions stay in the service); + * `em.insert()` returns the generated PK (R6's `lastInsertRowid` + * replacement). + */ + async insertStay(input: { + trip_id: number | string; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + confirmation: string | null; + notes: string | null; + }): Promise { + return await this.insert({ + trip: input.trip_id, + place: input.place_id, + startDay: input.start_day_id, + endDay: input.end_day_id, + check_in: input.check_in, + check_in_end: input.check_in_end, + check_out: input.check_out, + confirmation: input.confirmation, + notes: input.notes, + }); + } + + /** + * AC36 (`AccommodationsService.updateAccommodation`) — `UPDATE + * day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, + * check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = + * ? WHERE id = ?`. Eight columns, written verbatim — the caller's + * `fields.x !== undefined ? fields.x : existing.x` presence sentinel + * (note: NO `|| null` here, unlike AC30 — the legacy statement itself has + * none for these columns, so `''` is stored as `''`). + */ + async updateStay(id: number, write: { + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + confirmation: string | null; + notes: string | null; + }): Promise { + await this.nativeUpdate({ id }, { + place: write.place_id, + startDay: write.start_day_id, + endDay: write.end_day_id, + check_in: write.check_in, + check_in_end: write.check_in_end, + check_out: write.check_out, + confirmation: write.confirmation, + notes: write.notes, + }); + } + + /** + * AC44 (`AccommodationsService.deleteAccommodation`) — `DELETE FROM + * day_accommodations WHERE id = ?`. No trip scoping (the legacy statement + * has none either — the caller already scoped `id` via AC34's guard, + * rule 21). + */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * DY19 (`DaysService.assertNoInvertedAccommodation`) — `SELECT a.id, + * s.day_number AS start_no, e.day_number AS end_no FROM day_accommodations + * a JOIN days s ON a.start_day_id = s.id JOIN days e ON a.end_day_id = + * e.id WHERE a.trip_id = ?`. Both `days` joins are declared relations + * (`startDay`/`endDay`), so the QueryBuilder expresses this directly. + */ + async listStartEndDayNumbers(trip_id: number): Promise<{ id: number; start_no: number; end_no: number }[]> { + return await this.qb('a') + .join('a.startDay', 's') + .join('a.endDay', 'e') + .select(['a.id', 's.day_number as start_no', 'e.day_number as end_no']) + .where({ trip: trip_id }) + .execute<{ id: number; start_no: number; end_no: number }[]>('all', false); + } + + /** + * DY20 (`DaysService.resyncAccommodationDays`) — `SELECT id, + * start_day_id, end_day_id, check_in FROM day_accommodations WHERE trip_id + * = ?`. `check_in` seats the stay's stop on the day it is carried to + * (night-seat.ts). `start_day_id`/`end_day_id` are `persist(false)` mirrors + * of the `startDay`/`endDay` relations — a bare select would drop them (the + * `RoadtripDayBoundariesRepository` trap, Task 1's report), so both go + * through `columnRef`. + */ + async listForResync(trip_id: number): Promise<{ id: number; start_day_id: number; end_day_id: number; check_in: string | null }[]> { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('a') + .select(['a.id', columnRef(platform, 'a.start_day_id').as('start_day_id'), columnRef(platform, 'a.end_day_id').as('end_day_id'), 'a.check_in']) + .where({ trip: trip_id }) + .execute<{ id: number; start_day_id: number; end_day_id: number; check_in: string | null }[]>('all', false); + } + + /** + * DY41 (`DayRemovalService.cancelStays`) — `SELECT id FROM + * day_accommodations WHERE trip_id = ? AND (start_day_id = ? OR end_day_id + * = ?) ORDER BY id`: the stays that check in or out on a day being deleted. + */ + async listIdsCheckingInOrOutOn(trip_id: number, day_id: number): Promise { + const rows = await this.qb('a') + .select(['a.id']) + .where({ trip: trip_id, $or: [{ startDay: day_id }, { endDay: day_id }] }) + .orderBy({ id: 'asc' }) + .execute<{ id: number }[]>('all', false); + return rows.map((r) => r.id); + } + + /** DY22 (`DaysService.resyncAccommodationDays`) — `UPDATE day_accommodations SET start_day_id = ?, end_day_id = ? WHERE id = ?`. */ + async setDayRange(id: number, start_day_id: number, end_day_id: number): Promise { + await this.nativeUpdate({ id }, { startDay: start_day_id, endDay: end_day_id }); + } + + /** + * PL16 (`PlacesService.cancelStaysAt`) — `SELECT id FROM day_accommodations + * WHERE trip_id = ? AND place_id = ?`. Reached from `PlacesService` through + * `AccommodationsService` (already injected there for the cascade itself), + * not a new repository dependency of `PlacesService`'s own (R4 — keeps the + * trip scoping AC44's own unscoped delete relies on, inventory §14.3). + */ + async listForPlace(trip_id: number, place_id: number): Promise<{ id: number }[]> { + return await this.qb('a') + .select(['a.id']) + .where({ trip: trip_id, place: place_id }) + .execute<{ id: number }[]>('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 6 (`trips.service.ts::copy`) — additive, appended after + // Task 3's own methods above, per this task's file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * TP55 (`trips.service.ts::copy`'s day_accommodations read) — `SELECT * + * FROM day_accommodations WHERE trip_id = ?`, no `ORDER BY` (matching the + * legacy statement exactly — each row is independently remapped and + * inserted, so read order doesn't affect the copy). {@link + * DayAccommodationRow} is already this table's full-column shape (AC34's + * own `findInTrip` projection), reused here rather than declared again. + */ + async listAllForTrip(trip_id: number): Promise { + return await this.qb('a') + .select(['a.*']) + .where({ trip: trip_id }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // RoadtripPlanService.context (`stays` read) — RPL3 + // --------------------------------------------------------------------------- + + /** + * RPL3 (`RoadtripPlanService.context`'s `stays` read) — `SELECT a.id, + * a.place_id, a.start_day_id, a.end_day_id, a.check_in, a.check_out, + * p.name AS place_name, p.lat AS place_lat, p.lng AS place_lng, (SELECT + * MIN(r.id) FROM reservations r WHERE r.accommodation_id = a.id) AS + * reservation_id FROM day_accommodations a LEFT JOIN places p ON p.id = + * a.place_id WHERE a.trip_id = ? ORDER BY a.id`. Every stay of the trip in + * id order, the order the night-bookend rule reads them in. Kysely for the + * same reason as AC3: `reservations.accommodation_id` has no relation to + * this table (see {@link DayAccommodationListKyselyDB}). + */ + async listRoadtripStays(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .leftJoin('places as p', 'p.id', 'a.place_id') + .select((eb) => [ + 'a.id as id', + 'a.place_id as place_id', + 'a.start_day_id as start_day_id', + 'a.end_day_id as end_day_id', + 'a.check_in as check_in', + 'a.check_out as check_out', + 'p.name as place_name', + 'p.lat as place_lat', + 'p.lng as place_lng', + eb + .selectFrom('reservations as r') + .select((eb2) => eb2.fn.min('r.id').as('m')) + .whereRef('r.accommodation_id', '=', 'a.id') + .as('reservation_id'), + ]) + .where('a.trip_id', '=', trip_id) + .orderBy('a.id', 'asc') + .execute(); + return rows as RoadtripStayRow[]; + } +} + +/** + * RPL3's row: a booked night as the road trip reads it, the stay, the days it spans, + * where it is and the earliest booking linked to it. The rule reads these for the hotel + * at the edges of the days around the night (`seatNightBookends`, as a `BookendStay`); + * get_roadtrip_context reports them as they are, with the check-in beside the check-out. + */ +export interface RoadtripStayRow { + id: number; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_out: string | null; + place_name: string | null; + place_lat: number | null; + place_lng: number | null; + reservation_id: number | null; +} + +/** Kysely typing for RPL3 (`listRoadtripStays`). */ +interface RoadtripStaysKyselyDB { + day_accommodations: { + id: number; + trip_id: number; + place_id: number | null; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_out: string | null; + }; + places: { + id: number; + name: string; + lat: number | null; + lng: number | null; + }; + reservations: { + id: number; + accommodation_id: string | null; + }; +} diff --git a/server/src/db/repositories/DayAssignments.repository.ts b/server/src/db/repositories/DayAssignments.repository.ts new file mode 100644 index 000000000..c34afb833 --- /dev/null +++ b/server/src/db/repositories/DayAssignments.repository.ts @@ -0,0 +1,1019 @@ +import type { Platform } from '@mikro-orm/core'; +import type { DayAssignments } from '../entities/DayAssignments.entity'; +import { caseWhenNotNull, coalesce, columnIncrementedBy, columnRef } from '../dialect/sql-functions'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * The DY1/DY3/AS1/AS3 assignment-with-place projection row (Plan 3c Task 2's + * report is the contract for Task 3, which consumes this unchanged): + * + * ```sql + * SELECT da.*, p.id as place_id, p.name as place_name, p.description as place_description, + * p.lat, p.lng, p.address, p.category_id, p.price, p.currency as place_currency, + * COALESCE(da.assignment_time, p.place_time) as place_time, + * COALESCE(da.assignment_end_time, p.end_time) as end_time, + * p.duration_minutes, p.notes as place_notes, + * p.image_url, p.transport_mode, p.google_place_id, p.google_ftid, p.osm_id, p.amap_poi_id, + * p.website, p.phone, p.stop_type, p.fill_percent, + * t.place_id AS tour_place_id, + * CASE WHEN t.place_id IS NOT NULL THEN p.route_geometry END AS tour_route_geometry, + * c.name as category_name, c.color as category_color, c.icon as category_icon + * FROM day_assignments da + * JOIN places p ON da.place_id = p.id + * LEFT JOIN tours t ON t.place_id = p.id + * LEFT JOIN categories c ON p.category_id = c.id + * WHERE + * [ORDER BY da.order_index ASC, da.created_at ASC] + * ``` + * + * `da.*` selects every scalar column of `DayAssignments` (`place_id` among + * them, the physical FK column) and then `p.id as place_id` selects it again + * under the same output key — the legacy statement did this too, and + * better-sqlite3's row object keeps the LAST column with a given name, so + * `place_id` on the wire is always `p.id`, not `da.place_id` (they agree by + * the JOIN condition regardless). Nullability here is the physical column's, + * not `AssignmentRow`'s (types.ts) narrower claims (rule 16) — a place with + * no category genuinely nulls `category_name`/`category_color`/ + * `category_icon` through the LEFT JOIN even though `Categories.name` itself + * is NOT NULL. + */ +export interface AssignmentWithPlaceRow extends DayAssignmentRow { + place_name: string; + place_description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + place_currency: string | null; + place_time: string | null; + end_time: string | null; + duration_minutes: number | null; + place_notes: string | null; + image_url: string | null; + transport_mode: string | null; + google_place_id: string | null; + google_ftid: string | null; + osm_id: string | null; + amap_poi_id: string | null; + website: string | null; + phone: string | null; + stop_type: string | null; + fill_percent: number | null; + /** The place's id when it is a Tour (its `tours` facet row), else null. */ + tour_place_id: number | null; + /** The place's `route_geometry`, but only for a Tour; null for every other place. */ + tour_route_geometry: string | null; + category_name: string | null; + category_color: string | null; + category_icon: string | null; +} + +/** + * {@link DayAssignmentsRepository.listPublicForShare}'s projection (SH9) — + * {@link AssignmentWithPlaceRow} minus the six columns a public share link + * must not leak, and minus the two Tour facet columns the share projection + * never selected. + */ +export type SharePublicAssignmentRow = Omit< + AssignmentWithPlaceRow, + 'google_place_id' | 'google_ftid' | 'osm_id' | 'amap_poi_id' | 'stop_type' | 'fill_percent' + | 'tour_place_id' | 'tour_route_geometry' +>; + +/** + * A `day_assignments` row exactly as `AS12` (`AssignmentsService + * .getAssignmentForTrip`) re-selects it — every scalar column of the entity + * (`da.*`), scoped by a two-hop join to `days` for the trip check (Task 3). + */ +export interface DayAssignmentRow { + id: number; + day_id: number; + place_id: number; + order_index: number | null; + notes: string | null; + reservation_status: string | null; + reservation_notes: string | null; + reservation_datetime: string | null; + created_at: string | null; + assignment_time: string | null; + assignment_end_time: string | null; + leg_transport_mode: string | null; + incoming_leg_transport_mode: string | null; + end_day: number; + accommodation_id: number | null; + route_excluded: number; +} + +const _dayAssignmentRowKeys: AssertRowKeys = true; + +/** AS18's stop, exactly as `AssignmentsService.sortDayByTime`/`chronoOrder` read it. */ +export interface DayStopRow { + id: number; + order_index: number | null; + effective_time: string | null; + located: number; +} + +/** AC7's row ({@link DayAssignmentsRepository.listSeatRows}), `night-seat.ts`'s `SeatRow`. */ +export interface DaySeatRow { + id: number; + order_index: number | null; + at: string | null; + night_id: number | null; + located: number; +} + +/** + * `day_assignments`/`places`/`day_accommodations`'s shape for AS16 + * (`effectiveStart`) and AS18 (`listForTimeSort`), an explicit `TDB` type + * argument for `this.kysely()` (per `WebauthnChallengesRepository + * .claimChallenge`'s docstring — entity-metadata inference is not what a + * hand-written statement wants). There is no MikroORM relation from + * `DayAssignments` to `DayAccommodations` — `accommodation_id` is a plain + * `p.integer()` column on this entity, not a `manyToOne` (`day_accommodations` + * is Plan 3d's table; adding the relation means touching + * `src/db/entities/DayAssignments.entity.ts`, out of this task's scope) — so + * the QueryBuilder's relation-path `.join()` cannot express the LEFT JOIN at + * all. Kysely joining the physical table name directly is the sanctioned + * next step down D3's escape-hatch order (`find/findOne → nativeUpdate → + * qb() → em.getKysely()`). + */ +interface AssignmentTimeSortKyselyDB { + day_assignments: { + id: number; + day_id: number; + place_id: number; + order_index: number | null; + assignment_time: string | null; + accommodation_id: number | null; + created_at: string | null; + }; + places: { + id: number; + place_time: string | null; + lat: number | null; + lng: number | null; + }; + day_accommodations: { + id: number; + check_in: string | null; + }; +} + +/** + * RPL2's row, exactly as `RoadtripPlanService.context`'s old local `VisitRow` + * interface declared it — the stay the visit stands for on its check-in day + * (never `a.accommodation_id`, see the method docstring), and the checkout + * day's `day_number`, both `null` for a visit with no linked stay. + * `accommodation_id` is the stay whose booking wrote this stop (null for one + * the traveller placed), and `order_index` is the stop's slot in the day plan + * the carrier terminals are seated against. + */ +export interface RoadtripVisitRow { + id: number; + day_id: number; + order_index: number; + place_id: number; + name: string; + lat: number | null; + lng: number | null; + time: string | null; + end_time: string | null; + duration_minutes: number | null; + end_day: number; + leg_transport_mode: string | null; + incoming_leg_transport_mode: string | null; + stop_type: string | null; + fill_percent: number | null; + accommodation_id: number | null; + stay_id: number | null; + check_in: string | null; + check_out: string | null; + checkout_day: number | null; +} + +/** + * `day_assignments`/`days`/`places`/`day_accommodations`'s shape for RPL2 + * (`listRoadtripVisits`), the same Kysely-typed-DB-interface shape + * `AssignmentTimeSortKyselyDB` above uses. `days` is joined twice under two + * aliases (`d`, `checkout`) — both resolve against this one `days` entry. + */ +interface RoadtripVisitsKyselyDB { + day_assignments: { + id: number; + day_id: number; + place_id: number; + order_index: number | null; + accommodation_id: number | null; + created_at: string | null; + assignment_time: string | null; + assignment_end_time: string | null; + end_day: number; + leg_transport_mode: string | null; + incoming_leg_transport_mode: string | null; + }; + days: { + id: number; + trip_id: number; + day_number: number; + }; + places: { + id: number; + name: string; + lat: number | null; + lng: number | null; + place_time: string | null; + end_time: string | null; + duration_minutes: number | null; + stop_type: string | null; + fill_percent: number | null; + }; + day_accommodations: { + id: number; + place_id: number; + start_day_id: number; + end_day_id: number | null; + check_in: string | null; + check_out: string | null; + }; +} + +export class DayAssignmentsRepository extends TrekRepository { + /** The DY1/DY3/AS1/AS3 projection's SELECT list, shared by all three query shapes below. */ + private assignmentWithPlaceSelect(platform: Platform) { + return [ + 'da.*', + 'p.id as place_id', + 'p.name as place_name', + 'p.description as place_description', + 'p.lat', + 'p.lng', + 'p.address', + // `category_id` is `persist(false)` on `Places` — a bare `'p.category_id'` + // string selects nothing (not a real property name), and `'p.category'` + // (the owning relation) is ALSO the joined alias here (`c`), so MikroORM + // absorbs the select into the join and returns `c__id`, not `category_id` + // (`McpTokensRepository.listAllWithUsername`'s `t.user`/`user_id` + // docstring is the same trap, one join away from this one). `columnRef` + // selects the literal physical column, keyed `category_id`. + columnRef(platform, 'p.category_id'), + 'p.price', + 'p.currency as place_currency', + coalesce(platform, 'da.assignment_time', 'p.place_time').as('place_time'), + coalesce(platform, 'da.assignment_end_time', 'p.end_time').as('end_time'), + 'p.duration_minutes', + 'p.notes as place_notes', + 'p.image_url', + 'p.transport_mode', + 'p.google_place_id', + 'p.google_ftid', + 'p.osm_id', + 'p.amap_poi_id', + 'p.website', + 'p.phone', + 'p.stop_type', + 'p.fill_percent', + // `place_id` on `Tours` is the `persist(false)` twin of its primary-key + // relation, the same trap as `category_id` above, so `columnRef` again. + columnRef(platform, 't.place_id').as('tour_place_id'), + caseWhenNotNull(platform, 't.place_id', 'p.route_geometry').as('tour_route_geometry'), + 'c.name as category_name', + 'c.color as category_color', + 'c.icon as category_icon', + ] as const; + } + + /** + * AS1 (`assignments.service.ts::getAssignmentWithPlace`, Task 3): the + * projection filtered to one assignment, no ORDER BY (a single row). + */ + async findWithPlaceAndCategory(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('da') + .join('da.place', 'p') + .leftJoin('p.tours', 't') + .leftJoin('p.category', 'c') + .select(this.assignmentWithPlaceSelect(platform)) + .where({ 'da.id': id }) + .execute('get', false); + return row ?? null; + } + + /** + * DY1 (`days.service.ts::getAssignmentsForDay`) and AS3 + * (`assignments.service.ts::listDayAssignments`, Task 3): the projection + * for one day, `ORDER BY da.order_index ASC, da.created_at ASC`. + */ + async listForDay(day_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('da') + .join('da.place', 'p') + .leftJoin('p.tours', 't') + .leftJoin('p.category', 'c') + .select(this.assignmentWithPlaceSelect(platform)) + .where({ 'da.day': day_id }) + .orderBy({ 'da.order_index': 'asc', 'da.created_at': 'asc' }) + .execute('all', false); + } + + /** + * DY3 (`days.service.ts::list`): the same projection for several days at + * once (`WHERE da.day_id IN (...)`), same ORDER BY. Empty-array + * short-circuit before any query, as the legacy dynamic-`IN` builder did. + */ + async listWithPlaceAndCategory(day_ids: number[]): Promise { + if (day_ids.length === 0) return []; + const platform = this.getEntityManager().getPlatform(); + return await this.qb('da') + .join('da.place', 'p') + .leftJoin('p.tours', 't') + .leftJoin('p.category', 'c') + .select(this.assignmentWithPlaceSelect(platform)) + .where({ 'da.day': { $in: day_ids } }) + .orderBy({ 'da.order_index': 'asc', 'da.created_at': 'asc' }) + .execute('all', false); + } + + /** + * `share.service.ts:288` SH9 (`getSharedTripData`'s share_map assignment + * read) — the SAME projection {@link assignmentWithPlaceSelect} builds, + * minus six internal-id/routing columns a public share link must never + * leak (`google_place_id`, `google_ftid`, `osm_id`, `amap_poi_id`, + * `stop_type`, `fill_percent`) — never widen this list to match the + * private `listWithPlaceAndCategory` projection above. + */ + private publicAssignmentSelect(platform: Platform) { + return [ + 'da.*', + 'p.id as place_id', + 'p.name as place_name', + 'p.description as place_description', + 'p.lat', + 'p.lng', + 'p.address', + columnRef(platform, 'p.category_id'), + 'p.price', + 'p.currency as place_currency', + coalesce(platform, 'da.assignment_time', 'p.place_time').as('place_time'), + coalesce(platform, 'da.assignment_end_time', 'p.end_time').as('end_time'), + 'p.duration_minutes', + 'p.notes as place_notes', + 'p.image_url', + 'p.transport_mode', + 'p.website', + 'p.phone', + 'c.name as category_name', + 'c.color as category_color', + 'c.icon as category_icon', + ] as const; + } + + /** SH9 — same WHERE/ORDER BY shape as {@link listWithPlaceAndCategory}, narrower projection. */ + async listPublicForShare(day_ids: number[]): Promise { + if (day_ids.length === 0) return []; + const platform = this.getEntityManager().getPlatform(); + return await this.qb('da') + .join('da.place', 'p') + .leftJoin('p.category', 'c') + .select(this.publicAssignmentSelect(platform)) + .where({ 'da.day': { $in: day_ids } }) + .orderBy({ 'da.order_index': 'asc', 'da.created_at': 'asc' }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 3 (`AssignmentsService`) — appended after Task 2's own + // methods above, per this task's file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * AS9 (`assignmentExistsInDay`) — `SELECT da.id FROM day_assignments da + * JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND + * d.trip_id = ?`. + * + * **`id: number`, not `number | string` (Task 3 review H1, absorbed in + * Task 4 — `task-3-review.md` §9.1):** this method's ONLY caller + * (`AssignmentsService.assignmentExistsInDay`) now runs `toRowId` on the + * route's raw `id` BEFORE calling this — a non-canonical id (`"3 "`, + * `"3.0"`) must resolve to "not found" in the SERVICE gate, not pass this + * statement's own SQLite text/integer affinity match and then disagree + * with `deleteAssignment`'s `toRowId(id)!` downstream (the H1 regression). + * `day_id`/`trip_id` keep their `number | string` raw-bind scoping — + * `deleteAssignment` never reads either back, so there is no gate-vs-write + * id to protect there. + */ + async existsInDay(id: number, day_id: number | string, trip_id: number | string): Promise { + const row = await this.qb('da') + .join('da.day', 'd') + .select(['da.id']) + .where('da.id = ? AND da.day_id = ? AND d.trip_id = ?', [id, day_id, trip_id]) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * AS12 (`getAssignmentForTrip`) — `SELECT da.* FROM day_assignments da + * JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?`. + * + * **`id: number`, not `number | string` (Task 3 review H1, absorbed in + * Task 4 — `task-3-review.md` §9.1):** this method's ONLY caller + * (`AssignmentsService.getAssignmentForTrip`) now runs `toRowId` on the + * route/tool's raw `id` BEFORE calling this, the same H1 fix + * `existsInDay` above applies — every mutation behind this gate + * (`updateTime`/`setEndDay`/`updateNotes`/`setLegTransportMode`/ + * `setIncomingLegTransportMode`/`setParticipants`/`moveAssignment`) + * converts `id` with its own `toRowId(id)!`, and `ItineraryRpc.unassign` + * reads `existing.day_id` off THIS result (a genuine re-select, not only + * an existence guard) — so the id this read resolves must be the same one + * every one of those writes uses. `trip_id` keeps its `number | string` + * raw-bind scoping, unchanged. + */ + async findInTrip(id: number, trip_id: number | string): Promise { + const row = await this.qb('da') + .join('da.day', 'd') + .select(['da.*']) + .where('da.id = ? AND d.trip_id = ?', [id, trip_id]) + .execute('get', false); + return row ?? undefined; + } + + /** + * AS6 (`createAssignment`'s append-position read) — `SELECT + * MAX(order_index) as max FROM day_assignments WHERE day_id = ?`, read as + * the highest row by an ordered `limit(1)` (`DaysRepository.maxDayNumber`'s + * precedent — SQLite sorts `NULL` last under `DESC`, so an all-NULL or + * empty table folds the same way `MAX()` ignoring every `NULL` does). + * Returns the raw nullable value, NOT folded to `0`/`-1` here: the + * service's explicit `maxOrder !== null ? maxOrder : -1` check (AS6's + * ruling) needs to tell "no rows" from a stored `0` order_index, which a + * `?? -1` fold would collapse identically — unlike `DaysRepository + * .maxDayNumber`, this repository method must NOT pre-fold the null case. + */ + async maxOrderIndex(day_id: number): Promise { + const row = await this.qb('da') + .select('da.order_index') + .where({ day: day_id }) + .orderBy({ order_index: 'desc' }) + .limit(1) + .execute<{ order_index: number | null } | undefined>('get', false); + return row?.order_index ?? null; + } + + /** + * AS7 — `UPDATE day_assignments SET order_index = order_index + 1 WHERE + * day_id = ? AND order_index >= ?`, a column-from-column update + * (`InviteTokensRepository.incrementUsedCount`'s `columnIncrementedBy` + * precedent — `nativeUpdate`'s `EntityData` shape doesn't accept a raw + * fragment value, so this goes through `qb().update()` instead). + */ + async shiftOrderFrom(day_id: number, from: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ order_index: columnIncrementedBy(platform, 'order_index', 1) }) + .where({ day: day_id, order_index: { $gte: from } }) + .execute('run'); + } + + /** + * AS8 — `INSERT INTO day_assignments (day_id, place_id, order_index, + * notes, accommodation_id) VALUES (?, ?, ?, ?, ?)`. The caller passes + * already-decided values (the `notes || null`/`accommodationId ?? null` + * coercions and the clamped `orderIndex` stay in the service, the same + * split `DaysRepository.createDay`'s docstring describes) — `em.insert()` + * returns the generated PK (R6's `lastInsertRowid` replacement). + */ + async insertAssignment(input: { + day_id: number; + place_id: number; + order_index: number; + notes: string | null; + accommodation_id: number | null; + }): Promise { + return await this.insert({ + day: input.day_id, + place: input.place_id, + order_index: input.order_index, + notes: input.notes, + accommodation_id: input.accommodation_id, + }); + } + + /** AS10 — `DELETE FROM day_assignments WHERE id = ?`. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * AS11 (`reorderAssignments`, day-scoped: `UPDATE day_assignments SET + * order_index = ? WHERE id = ? AND day_id = ?`) and AS19 + * (`sortDayByTime`'s per-row write, NOT day-scoped: `UPDATE + * day_assignments SET order_index = ? WHERE id = ?`) — one method, both + * signatures, per the inventory's ruling ("keep both signatures"): + * `day_id` omitted (`undefined`) reproduces AS19's unscoped statement. + */ + async setOrderIndex(id: number, day_id: number | undefined, order_index: number): Promise { + const where = day_id !== undefined ? { id, day: day_id } : { id }; + await this.nativeUpdate(where, { order_index }); + } + + /** AS13 (`moveAssignment`'s source-day read) — `SELECT day_id FROM day_assignments WHERE id = ?`. */ + async getDayId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + // `day_id` is `persist(false)` (the physical FK mirror of the `day` + // relation) — a bare `'da.day_id'` select hits the same trap + // `assignmentWithPlaceSelect`'s `category_id` comment documents; + // `columnRef` selects the literal physical column instead. + const row = await this.qb('da') + .select([columnRef(platform, 'da.day_id').as('day_id')]) + .where({ id }) + .execute<{ day_id: number } | undefined>('get', false); + return row?.day_id; + } + + /** + * `moveAssignment`'s Tour check: `SELECT place_id FROM day_assignments + * WHERE id = ?`, `columnRef` for the same `persist(false)` reason as + * {@link getDayId} above. + */ + async getPlaceId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('da') + .select([columnRef(platform, 'da.place_id').as('place_id')]) + .where({ id }) + .execute<{ place_id: number } | undefined>('get', false); + return row?.place_id; + } + + /** AS14 — `UPDATE day_assignments SET day_id = ?, order_index = ? WHERE id = ?`. */ + async moveToDay(id: number, day_id: number, order_index: number): Promise { + await this.nativeUpdate({ id }, { day: day_id, order_index }); + } + + /** + * AS16 (`updateTime`'s pre-write read) — `SELECT da.day_id, + * COALESCE(da.assignment_time, p.place_time, acc.check_in) AS start FROM + * day_assignments da JOIN places p ON da.place_id = p.id LEFT JOIN + * day_accommodations acc ON acc.id = da.accommodation_id WHERE da.id = ?`. + * Kysely (see `AssignmentTimeSortKyselyDB`'s docstring for why: no ORM + * relation to `day_accommodations` exists to `.join()` through). + */ + async effectiveStart(id: number): Promise<{ day_id: number; start: string | null } | undefined> { + const row = await this.kysely() + .selectFrom('day_assignments as da') + .innerJoin('places as p', 'p.id', 'da.place_id') + .leftJoin('day_accommodations as acc', 'acc.id', 'da.accommodation_id') + .select((eb) => ['da.day_id as day_id', eb.fn.coalesce('da.assignment_time', 'p.place_time', 'acc.check_in').as('start')]) + .where('da.id', '=', id) + .executeTakeFirst(); + return row as { day_id: number; start: string | null } | undefined; + } + + /** + * AS17 — `UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?`. + * The falsy-clears-to-null coercion (`placeTime || null`) stays in the + * service (AS17's ruling); this writes the already-decided values. + */ + async setTimes(id: number, assignment_time: string | null, assignment_end_time: string | null): Promise { + await this.nativeUpdate({ id }, { assignment_time, assignment_end_time }); + } + + /** + * AS18 (`sortDayByTime`'s day-wide read) — `SELECT da.id, da.order_index, + * COALESCE(da.assignment_time, p.place_time, acc.check_in) as + * effective_time, (p.lat IS NOT NULL AND p.lng IS NOT NULL) as located + * FROM day_assignments da JOIN places p ON da.place_id = p.id LEFT JOIN + * day_accommodations acc ON acc.id = da.accommodation_id WHERE da.day_id + * = ? ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC`. Same + * Kysely escape hatch as `effectiveStart`. The computed boolean column is + * expressed with the expression builder's own comparison/`.and()` + * combinators — never the banned `sql` tag — and SQLite returns it as the + * same `0`/`1` integer the legacy raw statement did. + */ + async listForTimeSort(day_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_assignments as da') + .innerJoin('places as p', 'p.id', 'da.place_id') + .leftJoin('day_accommodations as acc', 'acc.id', 'da.accommodation_id') + .select((eb) => [ + 'da.id as id', + 'da.order_index as order_index', + eb.fn.coalesce('da.assignment_time', 'p.place_time', 'acc.check_in').as('effective_time'), + eb.and([eb('p.lat', 'is not', null), eb('p.lng', 'is not', null)]).as('located'), + ]) + .where('da.day_id', '=', day_id) + .orderBy('da.order_index', 'asc') + .orderBy('da.created_at', 'asc') + .orderBy('da.id', 'asc') + // `$castTo` (Task 9 fix wave, B-L1): Kysely infers `located` as its own + // `SqlBool` type from `eb.and(...)`, not the `0`/`1` integer + // `DayStopRow.located` declares (the value SQLite actually returns for + // a boolean expression) — a straight `as DayStopRow[]` is rejected as + // an unrelated-type cast, which is what forced the `as unknown as` + // bridge. `$castTo` retypes the builder itself before `execute()` + // runs, so the result is `DayStopRow[]` with no intermediate `unknown`. + .$castTo() + .execute(); + return rows; + } + + /** AS24 — `UPDATE day_assignments SET end_day = ? WHERE id = ?`. */ + async setEndDay(id: number, end_day: number): Promise { + await this.nativeUpdate({ id }, { end_day }); + } + + /** AS25 — `UPDATE day_assignments SET notes = ? WHERE id = ?`. */ + async setNotes(id: number, notes: string | null): Promise { + await this.nativeUpdate({ id }, { notes }); + } + + /** AS26 — `UPDATE day_assignments SET leg_transport_mode = ? WHERE id = ?`. */ + async setLegMode(id: number, mode: string | null): Promise { + await this.nativeUpdate({ id }, { leg_transport_mode: mode }); + } + + /** AS27 — `UPDATE day_assignments SET incoming_leg_transport_mode = ? WHERE id = ?`. */ + async setIncomingLegMode(id: number, mode: string | null): Promise { + await this.nativeUpdate({ id }, { incoming_leg_transport_mode: mode }); + } + + /** + * AS32 (`AssignmentsService.clearDay`, #2470) — `SELECT id FROM + * day_assignments WHERE day_id = ?`, no ORDER BY (unlike AC1's + * {@link listIdsForDay}). + */ + async listIdsToClear(day_id: number): Promise { + const rows = await this.qb('da') + .select(['da.id']) + .where({ day: day_id }) + .execute<{ id: number }[]>('all', false); + return rows.map((r) => r.id); + } + + /** AS33 (`AssignmentsService.clearDay`, #2470) — `DELETE FROM day_assignments WHERE day_id = ?`. */ + async deleteForDay(day_id: number): Promise { + await this.nativeDelete({ day: day_id }); + } + + /** AS34 (`AssignmentsService.setRouteExcluded`, #2532) — `UPDATE day_assignments SET route_excluded = ? WHERE id = ?`. */ + async setRouteExcluded(id: number, route_excluded: number): Promise { + await this.nativeUpdate({ id }, { route_excluded }); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 4 (`PlacesService.exportGpx`) — appended per the task-4 + // brief's "task-3-report.md" pointer ("DayAssignmentsRepository for the + // PL30 GPX itinerary read if it fits there"). Unlike AS16/AS18, this join + // (`day_assignments` -> `days`, `day_assignments` -> `places`) is fully + // expressible through the entity's own declared relations (`da.day`, + // `da.place`) — no `day_accommodations`-shaped gap — so the QueryBuilder is + // used directly rather than Kysely. + // --------------------------------------------------------------------------- + + /** + * PL30 (`places.service.ts::exportGpx`) — `SELECT d.day_number, d.date, + * d.title, p.name, p.lat, p.lng FROM days d JOIN day_assignments da ON + * da.day_id = d.id JOIN places p ON p.id = da.place_id WHERE d.trip_id = ? + * AND p.lat IS NOT NULL AND p.lng IS NOT NULL ORDER BY d.day_number, + * da.order_index` — rooted here on `day_assignments` instead of `days` + * (the legacy statement's own `FROM` root) because both joins the legacy + * statement needs (`day_assignments -> days`, `day_assignments -> places`) + * are single hops FROM this entity, while `days` has no direct relation to + * `places`. Same result set, same ordering; the root alias choice is not + * observable from the row shape. + */ + async listItineraryForGpx(trip_id: string | number): Promise<{ + day_number: number; date: string | null; title: string | null; + name: string; lat: number; lng: number; + }[]> { + return this.qb('da') + .join('da.day', 'd') + .join('da.place', 'p') + .select(['d.day_number', 'd.date', 'd.title', 'p.name', 'p.lat', 'p.lng']) + .where('d.trip_id = ? AND p.lat IS NOT NULL AND p.lng IS NOT NULL', [trip_id]) + .orderBy({ 'd.day_number': 'asc', 'da.order_index': 'asc' }) + .execute<{ day_number: number; date: string | null; title: string | null; name: string; lat: number; lng: number }[]>('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP48 (`trips.service.ts::copy`'s assignments read) — `SELECT da.* FROM + * day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ?`, + * no `ORDER BY` (matching the legacy statement, which relies on SQLite's + * own rowid-ascending scan order — the copy loop's insertion order tracks + * the source's own, same reasoning as `PlacesRepository.listAllForTrip`). + */ + async listAllForTrip(trip_id: number | string): Promise { + return this.qb('da') + .join('da.day', 'd') + .select(['da.*']) + .where('d.trip_id = ?', [trip_id]) + .execute('all', false); + } + + /** + * TP49 (`trips.service.ts::copy`'s assignment INSERT) — the 10-column + * `INSERT INTO day_assignments (day_id, place_id, order_index, notes, + * reservation_status, reservation_notes, reservation_datetime, + * assignment_time, assignment_end_time, end_day) VALUES (...)`. A + * different column set from AS8's `insertAssignment` (5 columns: `day_id`, + * `place_id`, `order_index`, `notes`, `accommodation_id` — no reservation/ + * time/end_day fields, and `accommodation_id` is stamped separately here, + * AFTER the bookings loop, via {@link setAccommodation} below, TP57) — the + * copy carries a source assignment's reservation snapshot and timing + * verbatim, values already fully resolved by the caller (`a.end_day ?? 0` + * stays the service's own decision, `DaysRepository.createDay`'s split). + */ + async insertAssignmentCopy(input: { + day_id: number; + place_id: number; + order_index: number | null; + notes: string | null; + reservation_status: string | null; + reservation_notes: string | null; + reservation_datetime: string | null; + assignment_time: string | null; + assignment_end_time: string | null; + end_day: number; + }): Promise { + return await this.insert({ + day: input.day_id, + place: input.place_id, + order_index: input.order_index, + notes: input.notes, + reservation_status: input.reservation_status, + reservation_notes: input.reservation_notes, + reservation_datetime: input.reservation_datetime, + assignment_time: input.assignment_time, + assignment_end_time: input.assignment_end_time, + end_day: input.end_day, + }); + } + + /** + * TP57 (`trips.service.ts::copy`'s post-bookings-loop stamp) — `UPDATE + * day_assignments SET accommodation_id = ? WHERE id = ?`. Run once per + * copied stay-stop, AFTER the `day_accommodations` bookings are copied + * (Plan 3d's own table — the bookings loop itself stays raw, `// TPn — + * Plan 3d`), because only then does the copied accommodation's id exist + * to stamp on. `accommodation_id` is a plain `p.integer()` column on this + * entity, not a relation (`AssignmentTimeSortKyselyDB`'s docstring), so a + * typed `nativeUpdate` partial reaches it directly. + */ + async setAccommodation(id: number, accommodation_id: number): Promise { + await this.nativeUpdate({ id }, { accommodation_id }); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 1 (`RoadtripPlanService`) — additive, per this task's own + // file-ownership rule ("all additive methods on Days/Places/DayAssignments + // repositories"). + // --------------------------------------------------------------------------- + + /** + * RPL2 (`roadtrip-plan.service.ts::context`'s `visits` read) — the + * cluster's most dialect-specific SELECT (inventory §17b): + * + * ```sql + * SELECT a.id, a.day_id, a.order_index, a.place_id, p.name, p.lat, p.lng, + * COALESCE(a.assignment_time, p.place_time) AS time, + * COALESCE(a.assignment_end_time, p.end_time) AS end_time, + * p.duration_minutes, a.end_day, + * a.leg_transport_mode, a.incoming_leg_transport_mode, p.stop_type, p.fill_percent, + * a.accommodation_id, stay.id AS stay_id, stay.check_in, stay.check_out, checkout.day_number AS checkout_day + * FROM day_assignments a + * JOIN days d ON d.id = a.day_id + * JOIN places p ON p.id = a.place_id + * LEFT JOIN day_accommodations stay + * ON stay.id = (SELECT id FROM day_accommodations WHERE place_id = p.id AND start_day_id = d.id ORDER BY id LIMIT 1) + * LEFT JOIN days checkout ON checkout.id = stay.end_day_id + * WHERE d.trip_id = ? + * ORDER BY d.day_number, a.order_index, a.created_at + * ``` + * + * A correlated scalar subquery INSIDE a `LEFT JOIN … ON` clause — no + * QueryBuilder shape expresses this (§17b), and no MikroORM relation + * connects a place+check-in-day pair to its `day_accommodations` row (the + * stay is matched by `place_id`+`start_day_id`, not by + * `a.accommodation_id` — deliberately: the inventory's own RPL2 note), + * so this is `this.kysely()` from the start, same escape-hatch order as + * `effectiveStart`/`listForTimeSort` above. `days` is + * joined twice under two different aliases (`d`, `checkout`) — ordinary + * Kysely self-join aliasing, no separate interface entry needed since both + * resolve against the same `days` table shape. + */ + async listRoadtripVisits(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_assignments as a') + .innerJoin('days as d', 'd.id', 'a.day_id') + .innerJoin('places as p', 'p.id', 'a.place_id') + .leftJoin('day_accommodations as stay', (join) => + join.on('stay.id', '=', (eb) => + eb + .selectFrom('day_accommodations as da2') + .select('da2.id') + .whereRef('da2.place_id', '=', 'p.id') + .whereRef('da2.start_day_id', '=', 'd.id') + .orderBy('da2.id', 'asc') + .limit(1), + ), + ) + .leftJoin('days as checkout', 'checkout.id', 'stay.end_day_id') + .select((eb) => [ + 'a.id as id', + 'a.day_id as day_id', + 'a.order_index as order_index', + 'a.place_id as place_id', + 'p.name as name', + 'p.lat as lat', + 'p.lng as lng', + eb.fn.coalesce('a.assignment_time', 'p.place_time').as('time'), + eb.fn.coalesce('a.assignment_end_time', 'p.end_time').as('end_time'), + 'p.duration_minutes as duration_minutes', + 'a.end_day as end_day', + 'a.leg_transport_mode as leg_transport_mode', + 'a.incoming_leg_transport_mode as incoming_leg_transport_mode', + 'p.stop_type as stop_type', + 'p.fill_percent as fill_percent', + 'a.accommodation_id as accommodation_id', + 'stay.id as stay_id', + 'stay.check_in as check_in', + 'stay.check_out as check_out', + 'checkout.day_number as checkout_day', + ]) + .where('d.trip_id', '=', trip_id) + .orderBy('d.day_number', 'asc') + .orderBy('a.order_index', 'asc') + .orderBy('a.created_at', 'asc') + .$castTo() + .execute(); + return rows; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 3 (`AccommodationsService`) — additive, per this task's own + // file-ownership rule ("additive methods on Days/Places/DayAssignments/ + // Reservations repositories where a read belongs there"). + // --------------------------------------------------------------------------- + + /** AC1 (`AccommodationsService.announceMirror`) — `SELECT id FROM day_assignments WHERE day_id = ? ORDER BY order_index`, once per touched day. */ + async listIdsForDay(day_id: number): Promise { + const rows = await this.qb('da') + .select(['da.id']) + .where({ day: day_id }) + .orderBy({ order_index: 'asc' }) + .execute<{ id: number }[]>('all', false); + return rows.map((r) => r.id); + } + + /** + * AC7 (`night-seat.ts`'s `SeatRow`, read by `AccommodationsService` and + * `DaysService` through `seatIndexWith`/`seatHolds`; `reseat-booked-nights.ts` + * runs the same statement raw inside migration 242) — `SELECT da.id, + * da.order_index, CASE WHEN other.id IS NULL THEN + * COALESCE(da.assignment_time, p.place_time) ELSE other.check_in END AS at, + * other.id AS night_id, (p.lat IS NOT NULL AND p.lng IS NOT NULL) AS + * located FROM day_assignments da JOIN places p ON p.id = da.place_id LEFT + * JOIN day_accommodations other ON other.id = da.accommodation_id WHERE + * da.day_id = ? ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC`. + * `locatedStopIdsWith` reads the via positions off these same rows + * (`located`), so a night and its vias are measured by one statement. + * Same Kysely escape hatch as {@link effectiveStart}/{@link listForTimeSort} + * (no ORM relation to `day_accommodations`); `located` and the `$castTo` + * follow `listForTimeSort`. + */ + async listSeatRows(day_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_assignments as da') + .innerJoin('places as p', 'p.id', 'da.place_id') + .leftJoin('day_accommodations as other', 'other.id', 'da.accommodation_id') + .select((eb) => [ + 'da.id as id', + 'da.order_index as order_index', + eb + .case() + .when('other.id', 'is', null) + .then(eb.fn.coalesce('da.assignment_time', 'p.place_time')) + .else(eb.ref('other.check_in')) + .end() + .as('at'), + 'other.id as night_id', + eb.and([eb('p.lat', 'is not', null), eb('p.lng', 'is not', null)]).as('located'), + ]) + .where('da.day_id', '=', day_id) + .orderBy('da.order_index', 'asc') + .orderBy('da.created_at', 'asc') + .orderBy('da.id', 'asc') + .$castTo() + .execute(); + return rows; + } + + /** + * AC18/AC24 (`AccommodationsService.relocateOwnStop`/`mirrorStay`) — one + * method, two call shapes (D4): `SELECT id FROM day_assignments WHERE + * day_id = ? AND place_id = ? AND id != ?` (AC18, with `exclude_id`) and + * `SELECT id FROM day_assignments WHERE day_id = ? AND place_id = ?` + * (AC24, without). + */ + async existsForDayAndPlace(day_id: number, place_id: number, exclude_id?: number): Promise { + const query = this.qb('da').select(['da.id']).where({ day: day_id, place: place_id }); + if (exclude_id !== undefined) query.andWhere({ id: { $ne: exclude_id } }); + const row = await query.execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * AC19 (`AccommodationsService.relocateOwnStop`) — `UPDATE day_assignments + * SET order_index = order_index - 1 WHERE day_id = ? AND order_index > ?`, + * a column-from-column update (`columnIncrementedBy`, {@link shiftOrderFrom}'s + * precedent). + */ + async closeGap(day_id: number, from_index: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ order_index: columnIncrementedBy(platform, 'order_index', -1) }) + .where({ day: day_id, order_index: { $gt: from_index } }) + .execute('run'); + } + + /** + * AC20 (`AccommodationsService.relocateOwnStop`) — `SELECT MAX(order_index) + * AS max FROM day_assignments WHERE day_id = ? AND id != ?`, read the same + * way {@link maxOrderIndex} is (an ordered `limit(1)`, null NOT pre-folded — + * the caller's own `max.max !== null ? max.max : -1` check needs to tell + * "no rows" from a stored `0`). + */ + async maxOrderIndexExcluding(day_id: number, exclude_id: number): Promise { + const row = await this.qb('da') + .select('da.order_index') + .where({ day: day_id, id: { $ne: exclude_id } }) + .orderBy({ order_index: 'desc' }) + .limit(1) + .execute<{ order_index: number | null } | undefined>('get', false); + return row?.order_index ?? null; + } + + /** AC21 (`AccommodationsService.relocateOwnStop`) — `UPDATE day_assignments SET day_id = ?, place_id = ?, order_index = ? WHERE id = ?`. */ + async relocate(id: number, day_id: number, place_id: number, order_index: number): Promise { + await this.nativeUpdate({ id }, { day: day_id, place: place_id, order_index }); + } + + /** + * AC22 (`AccommodationsService.relocateOwnStop`) — `UPDATE day_assignments + * SET order_index = order_index + 1 WHERE day_id = ? AND order_index >= ? + * AND id != ?`, the same column-from-column shape as {@link shiftOrderFrom} + * with an extra exclusion. + */ + async shiftFromExcluding(day_id: number, from_index: number, exclude_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ order_index: columnIncrementedBy(platform, 'order_index', 1) }) + .where({ day: day_id, order_index: { $gte: from_index }, id: { $ne: exclude_id } }) + .execute('run'); + } + + /** AC25 (`AccommodationsService.ownStops`) — `SELECT id, day_id, place_id, order_index FROM day_assignments WHERE accommodation_id = ?`. `day_id`/`place_id` are `persist(false)` relation mirrors — `columnRef`, the same trap {@link getDayId} documents. */ + async listOwnedByStay(accommodation_id: number): Promise<{ id: number; day_id: number; place_id: number; order_index: number | null }[]> { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('da') + .select(['da.id', columnRef(platform, 'da.day_id').as('day_id'), columnRef(platform, 'da.place_id').as('place_id'), 'da.order_index']) + .where({ accommodation_id }) + .execute<{ id: number; day_id: number; place_id: number; order_index: number | null }[]>('all', false); + } + + /** AC26 (`AccommodationsService.releaseStops`, `keepStop` branch) — `UPDATE day_assignments SET accommodation_id = NULL WHERE id = ?`. */ + async clearStay(id: number): Promise { + await this.nativeUpdate({ id }, { accommodation_id: null }); + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 1 (`FilesService.findForeignLinkTarget`, R12) — additive, + // append-only per that task's own file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * FL3 (`FilesService.findForeignLinkTarget`'s assignment branch) — `SELECT + * 1 FROM day_assignments a JOIN days d ON a.day_id = d.id WHERE a.id = ? + * AND d.trip_id = ?`, re-expressed as "what trip does this row belong to" + * via its `day` relation (R12 — "DayAssignmentsRepository.findTripId via + * its days join"; `day_assignments` has no `trip_id` column of its own). + * The same join shape `ReservationsRepository.getAssignmentTripId` + * (RS22) already used from a DIFFERENT repository before this file was + * this task's own to append to — this copy lives here instead, per R12's + * "one method per target table, on that table's own repository" ruling. + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('da') + .join('da.day', 'd') + .select([columnRef(platform, 'd.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } +} diff --git a/server/src/db/repositories/DayNotes.repository.ts b/server/src/db/repositories/DayNotes.repository.ts new file mode 100644 index 000000000..2a97cf429 --- /dev/null +++ b/server/src/db/repositories/DayNotes.repository.ts @@ -0,0 +1,209 @@ +import type { DayNotes } from '../entities/DayNotes.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +export interface DayNoteRow { + id: number; + day_id: number; + trip_id: number; + text: string; + time: string | null; + icon: string | null; + sort_order: number | null; + created_at: string | null; + color: string | null; +} + +const _dayNoteRowKeys: AssertRowKeys = true; + +export class DayNotesRepository extends TrekRepository { + /** + * The column set of the legacy INSERT: + * `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order, color)`. + * + * The caller passes already-coerced values — every field is required and is + * written verbatim. The legacy defaults (`icon || '📝'`, `sortOrder ?? 9999`, + * the trimmed text, `normalizeNoteColor(color)`) stay in + * `nest/day-notes/day-notes.service.ts`, which is where that rule lives. + * + * `created_at` is not in the column set: it is left to the column's + * `DEFAULT CURRENT_TIMESTAMP`. It alone would not need the re-read either — + * a `defaultRaw` column the insert does not name is in its `returning` + * clause. The re-read is there for the columns that are not: one this + * insert never names would otherwise be `undefined` on the entity and + * missing from the row. + * + * `this.insert` (Plan 3b interlude B, finishing F2's sweep), never + * `create()` + `persist().flush()`: `flush()` commits the *whole* unit of + * work of the request's `EntityManager`, not just this row. The read-back + * uses `disableIdentityMap: true` by the base class's default. + */ + async createNote(input: { + day_id: number; + trip_id: number; + text: string; + time: string | null; + icon: string | null; + sort_order: number | null; + color: string | null; + }): Promise { + const id = await this.insert({ + day: input.day_id, + trip: input.trip_id, + text: input.text, + time: input.time, + icon: input.icon, + sort_order: input.sort_order, + color: input.color, + }); + const inserted = await this.findOne({ id }); + if (!inserted) { + throw new Error('createNote: read-back after insert found no row'); + } + return toRow(inserted) as DayNoteRow; + } + + /** + * DY4 (`days.service.ts::list`) — `` SELECT * FROM day_notes WHERE day_id + * IN (${dayPlaceholders}) ORDER BY sort_order ASC, created_at ASC ``. + * Empty-array short-circuit before any query, as the legacy dynamic-`IN` + * builder did. + */ + async listByDayIds(day_ids: number[]): Promise { + if (day_ids.length === 0) return []; + const notes = await this.find({ day: { $in: day_ids } }, { orderBy: { sort_order: 'asc', created_at: 'asc' } }); + return notes.map((n) => toRow(n) as DayNoteRow); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP70 (`trips.service.ts::copy`'s day-notes read) — `SELECT * FROM + * day_notes WHERE trip_id = ?`, no `ORDER BY` (matching the legacy + * statement — the copy loop only needs `dayMap` to remap `day_id`, never + * relies on read order). + */ + async listByTrip(trip_id: number | string): Promise { + return await this.qb('n') + .select(['n.*']) + .where('n.trip_id = ?', [trip_id]) + .execute('all', false); + } + + /** + * TP71 (`trips.service.ts::copy`'s day-note INSERT) — `INSERT INTO + * day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, + * ?, ?, ?, ?)`. A narrower column set than `createNote`'s (no `color` — + * the legacy copy statement never names that column, so it is left to + * its own nullable-with-no-default, i.e. `NULL`, the same way `createNote`'s + * own docstring describes an unnamed column). No read-back: the copy loop + * discards the note's own id. + */ + async insertNoteCopy(input: { + day_id: number; + trip_id: number; + text: string; + time: string | null; + icon: string | null; + sort_order: number | null; + }): Promise { + return await this.insert({ + day: input.day_id, + trip: input.trip_id, + text: input.text, + time: input.time, + icon: input.icon, + sort_order: input.sort_order, + }); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (`day-notes.service.ts`) — additive: the remaining raw + // `this.dbs.all/get/run` statements ruling 8 mis-filed as already clean. + // --------------------------------------------------------------------------- + + /** + * `day-notes.service.ts::list` — `SELECT * FROM day_notes WHERE day_id = ? + * AND trip_id = ? ORDER BY sort_order ASC, created_at ASC`. Raw-bind + * (`number | string`, D4's T5 escape hatch, `DaysRepository.existsInTrip`'s + * precedent): the legacy statement binds the route's raw params with no + * `Number()`/`toRowId` conversion of its own. + */ + async listByDayAndTrip(day_id: number | string, trip_id: number | string): Promise { + return await this.qb('n') + .select(['n.*']) + .where('n.day_id = ? AND n.trip_id = ?', [day_id, trip_id]) + .orderBy({ sort_order: 'asc', created_at: 'asc' }) + .execute('all', false); + } + + /** + * `day-notes.service.ts::getNote` — `SELECT * FROM day_notes WHERE id = ? + * AND day_id = ? AND trip_id = ?`. Same raw-bind seam as + * {@link listByDayAndTrip}. + */ + async findByIdDayTrip(id: number | string, day_id: number | string, trip_id: number | string): Promise { + return await this.qb('n') + .select(['n.*']) + .where('n.id = ? AND n.day_id = ? AND n.trip_id = ?', [id, day_id, trip_id]) + .execute('get', false); + } + + /** + * `day-notes.service.ts::update` — `UPDATE day_notes SET text = ?, time = + * ?, icon = ?, sort_order = ?, color = ? WHERE id = ?` then the same + * `SELECT * FROM day_notes WHERE id = ?` re-select {@link createNote} uses. + * Every field is required (the caller — `DayNotesService.update` — + * already resolves each one's `fields.x !== undefined ? fields.x : + * current.x` fallback, same shape as `createNote`'s docstring). + */ + async updateNote(id: number | string, fields: { + text: string; + time: string | null; + icon: string | null; + sort_order: number | null; + color: string | null; + }): Promise { + await this.qb('n') + .update({ text: fields.text, time: fields.time, icon: fields.icon, sort_order: fields.sort_order, color: fields.color }) + .where('n.id = ?', [id]) + .execute('run'); + return await this.qb('n') + .select(['n.*']) + .where('n.id = ?', [id]) + .execute('get', false); + } + + /** `day-notes.service.ts::remove` — `DELETE FROM day_notes WHERE id = ?`. */ + async deleteById(id: number | string): Promise { + await this.qb('n') + .delete() + .where('n.id = ?', [id]) + .execute('run'); + } + + /** + * `public-api.service.ts::dayNotesByDay` — `SELECT day_id, text, time FROM + * day_notes WHERE trip_id = ? ORDER BY day_id ASC, sort_order ASC`. Kysely: + * `day_id` is a `persist(false)` mirror of the `day` relation + * (`DayNotes.entity.ts`) and is PROJECTED here, so a bare `qb().select([...])` + * would silently drop it (the `ShareTokensRepository`/`CollectionPlacesRepository` + * class-docstring trap). + */ + async listForPublicApi(trip_id: number): Promise<{ day_id: number; text: string; time: string | null }[]> { + return await this.kysely() + .selectFrom('day_notes') + .select(['day_id', 'text', 'time']) + .where('trip_id', '=', trip_id) + .orderBy('day_id', 'asc') + .orderBy('sort_order', 'asc') + .execute(); + } +} + +/** {@link DayNotesRepository.listForPublicApi}'s narrow `day_notes` shape. */ +interface DayNotesPublicApiKyselyDB { + day_notes: { day_id: number; trip_id: number; text: string; time: string | null; sort_order: number | null }; +} diff --git a/server/src/db/repositories/Days.repository.ts b/server/src/db/repositories/Days.repository.ts new file mode 100644 index 000000000..9245d7eb6 --- /dev/null +++ b/server/src/db/repositories/Days.repository.ts @@ -0,0 +1,379 @@ +import type { Days } from '../entities/Days.entity'; +import { columnRef } from '../dialect/sql-functions'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `days` row as the API emits it. */ +export interface DayRow { + id: number; + trip_id: number; + day_number: number; + date: string | null; + notes: string | null; + title: string | null; + default_transport_mode: string | null; +} + +const _dayRowKeys: AssertRowKeys = true; + +/** The DY26/DY29 reorder/insert projection: `SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number`. */ +export interface DayOrderRow { + id: number; + day_number: number; + date: string | null; +} + +/** The DY21 re-anchor lookup: `SELECT id, day_number FROM days WHERE trip_id = ? AND date = ? LIMIT 1`. */ +export interface DayIdAndNumberRow { + id: number; + day_number: number; +} + +/** RPL1's projection: `SELECT id, day_number, date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY day_number`. */ +export interface PlanDayRow { + id: number; + day_number: number; + date: string | null; + title: string | null; + default_transport_mode: string | null; +} + +/** TP77's projection: a day as the shared planDayGrid reads it. `has_plan_items` is SQLite's 0/1. */ +export interface DayGridReadRow { + id: number; + day_number: number; + date: string | null; + has_plan_items: number; +} + +/** The narrow table shape TP77/TP78 read (`this.kysely()`'s typed `DB` argument). */ +interface DayGridKyselyDB { + days: { id: number; trip_id: number; day_number: number; date: string | null }; + day_assignments: { id: number; day_id: number }; + day_notes: { id: number; day_id: number }; + day_accommodations: { id: number; start_day_id: number; end_day_id: number }; +} + +export class DaysRepository extends TrekRepository { + /** `SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC` */ + async listByTrip(trip_id: number): Promise { + const days = await this.find({ trip: trip_id }, { orderBy: { day_number: 'asc' } }); + return days.map((d) => toRow(d) as DayRow); + } + + /** `SELECT * FROM days WHERE id = ? AND trip_id = ?` */ + async findInTrip(id: number, trip_id: number): Promise { + const day = await this.findOne({ id, trip: trip_id }); + return day ? (toRow(day) as DayRow) : undefined; + } + + /** + * CT1/CT2 (Plan 3j Task 5, `contributions/day-schedule.controller.ts` / + * `day-tints.controller.ts`, byte-identical text) — `SELECT id FROM days + * WHERE trip_id = ?`, the trip's own day-id set (used only for a + * membership check against a plugin-contributed `dayId`). + */ + async listIdsByTrip(trip_id: number): Promise { + const rows = await this.find({ trip: trip_id }, { fields: ['id'] }); + return rows.map((r) => r.id); + } + + /** + * MR9 (Plan 3j Task 5, `host/rpc/meta.rpc.ts#entityTrip`'s `'day'` arm, + * R12's "one method per target table, no dynamic identifier dispatch" + * precedent — `PlacesRepository.findTripId`/`ReservationsRepository + * .findTripId`'s own docstrings) — `SELECT trip_id FROM days WHERE id = + * ?`. `trip_id` is a `persist(false)` mirror of the `trip` relation — + * `columnRef`, not a bare select (the program-wide trap). + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('d') + .select([columnRef(platform, 'd.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + /** + * The trip's highest `day_number`, 0 when it has no days. + * + * The legacy statement was `SELECT MAX(day_number) ... WHERE trip_id = ?`; + * this reads the same value as the first row of a descending order, which + * needs no SQL function spelled outside `db/dialect/sql-functions.ts` and no + * entity hydrated into the identity map. + */ + async maxDayNumber(trip_id: number): Promise { + const row = await this.qb('d') + .select('d.day_number') + .where({ trip: trip_id }) + .orderBy({ day_number: 'desc' }) + .limit(1) + .execute<{ day_number: number | null } | undefined>('get', false); + return row?.day_number ?? 0; + } + + /** + * The column set of the legacy INSERT: + * `INSERT INTO days (trip_id, day_number, date, notes) VALUES (?, ?, ?, ?)`. + * + * The caller passes already-coerced values — every field is required and is + * written verbatim, because a default or a coercion belongs to the service + * that owns the rule, not to the statement that stores it. + * + * `this.insert` (Plan 3b interlude B, finishing F2's sweep — Task 1 fix + * round's re-review flagged this method as a leftover whole-request- + * UnitOfWork `flush()`), never `create()` + `persist().flush()`: `flush()` + * commits the *whole* unit of work of the request's `EntityManager`, not + * just this row. The insert is followed by a read-back + * (`disableIdentityMap: true` by the base class's default), so the caller + * gets the stored row rather than what it asked to store — not optional: + * the insert's `returning` clause carries only the generated and + * `defaultRaw` columns, so a column this insert never names (`title`, + * `default_transport_mode`) would otherwise simply be missing from the + * row. + */ + async createDay(input: { + trip_id: number; + day_number: number; + date: string | null; + notes: string | null; + }): Promise { + const id = await this.insert({ + trip: input.trip_id, + day_number: input.day_number, + date: input.date, + notes: input.notes, + }); + const inserted = await this.findOne({ id }); + if (!inserted) { + throw new Error('createDay: read-back after insert found no row'); + } + return toRow(inserted) as DayRow; + } + + /** + * `SELECT * FROM days WHERE id = ?` — DY7/DY10/DY12/DY32/DY36's shared + * re-select, one method for all five sites (DY32/DY36 read after their + * transaction resolves, so this is a fresh statement against committed + * rows, no `refresh` needed). + */ + async findById(id: number): Promise { + const day = await this.findOne({ id }); + return day ? (toRow(day) as DayRow) : undefined; + } + + /** + * DY9 — `UPDATE days SET notes = ?, title = ? WHERE id = ?`. The presence + * sentinel that decides `notes`/`title` (the legacy's asymmetric `||` + * vs. `??` coercion) stays in `DaysService.update`; this takes the final, + * already-decided values and writes them verbatim. + */ + async updateNotesAndTitle(id: number, notes: string | null, title: string | null): Promise { + await this.nativeUpdate({ id }, { notes, title }); + } + + /** DY11 — `UPDATE days SET default_transport_mode = ? WHERE id = ?` (#1281). */ + async setDefaultTransportMode(id: number, mode: string | null): Promise { + await this.nativeUpdate({ id }, { default_transport_mode: mode }); + } + + /** DY13 — `DELETE FROM days WHERE id = ?`, unscoped: the caller proved trip access. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * DY26/DY29 — `SELECT id, day_number, date FROM days WHERE trip_id = ? + * ORDER BY day_number`, the read `reorder`/`insert` both take before + * opening their `uow.transactional` block (same text, two legacy methods, + * one repository method here). + */ + async listOrderedForReorder(trip_id: number): Promise { + return await this.qb('d') + .select(['d.id', 'd.day_number', 'd.date']) + .where({ trip: trip_id }) + .orderBy({ day_number: 'asc' }) + .execute('all', false); + } + + /** DY27/DY30 — `UPDATE days SET day_number = ? WHERE id = ?` (the two-phase renumber's per-row write). */ + async setDayNumber(id: number, day_number: number): Promise { + await this.nativeUpdate({ id }, { day_number }); + } + + /** DY28/DY33 — `UPDATE days SET day_number = ?, date = ? WHERE id = ?`. */ + async setDayNumberAndDate(id: number, day_number: number, date: string | null): Promise { + await this.nativeUpdate({ id }, { day_number, date }); + } + + /** + * DY31/DY34 — `INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, + * ?)` (no `notes` column named, unlike `createDay`'s DY6 — the row's + * `notes` is left to the column's own NULL default). Returns the inserted + * id (`em.insert()`'s returned PK, R6's `lastInsertRowid` replacement); + * the caller re-selects separately (DY32/DY36), after its transaction + * resolves, so no read-back happens here. + */ + async insertDay(input: { trip_id: number; day_number: number; date: string | null }): Promise { + return await this.insert({ + trip: input.trip_id, + day_number: input.day_number, + date: input.date, + }); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP39 (`trips.service.ts::copy`'s day INSERT) — `INSERT INTO days + * (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)`. A + * different column set from both `insertDay` (DY31/DY34, no `notes`/ + * `title`) and `createDay` (no `title`): the copy carries a source day's + * `notes` AND `title` verbatim, with no read-back — the caller only needs + * the generated id for `dayMap`. + */ + async insertDayCopy(input: { + trip_id: number; + day_number: number; + date: string | null; + notes: string | null; + title: string | null; + }): Promise { + return await this.insert({ + trip: input.trip_id, + day_number: input.day_number, + date: input.date, + notes: input.notes, + title: input.title, + }); + } + + /** DY21 — `SELECT id, day_number FROM days WHERE trip_id = ? AND date = ? LIMIT 1`. */ + async findByTripAndDate(trip_id: number, date: string): Promise { + const row = await this.qb('d') + .select(['d.id', 'd.day_number']) + .where({ trip: trip_id, date }) + .limit(1) + .execute('get', false); + return row ?? undefined; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 1 (`RoadtripPlanService`) — additive, per this task's own + // file-ownership rule ("all additive methods on Days/Places/DayAssignments + // repositories"). + // --------------------------------------------------------------------------- + + /** + * RPL1 (`roadtrip-plan.service.ts::context`) — `SELECT id, day_number, + * date, title, default_transport_mode FROM days WHERE trip_id = ? ORDER BY + * day_number`. + */ + async listPlanDays(trip_id: number): Promise { + return await this.qb('d') + .select(['d.id', 'd.day_number', 'd.date', 'd.title', 'd.default_transport_mode']) + .where({ trip: trip_id }) + .orderBy({ day_number: 'asc' }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 3 (`AssignmentsService`) — appended per the task's own + // file-ownership rule ("additive methods on Days/Places repositories if + // needed"). + // --------------------------------------------------------------------------- + + /** + * AS4 (`AssignmentsService.dayExists`) — `SELECT id FROM days WHERE id = ? + * AND trip_id = ?`. Raw-bind (`number | string`, D4's T5 escape hatch, + * `TripsRepository.findAccessible`'s precedent): the legacy guard binds + * the route's raw params with no `Number()`/`toRowId` conversion of its + * own, so this must accept and bind exactly what it's handed. + */ + async existsInTrip(id: number | string, trip_id: number | string): Promise { + const row = await this.qb('d') + .select(['d.id']) + .where('d.id = ? AND d.trip_id = ?', [id, trip_id]) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 7 (`TripsService.generateDays`) — appended per the task's own + // file-ownership rule ("additive methods on Days/Places/Users repositories"). + // --------------------------------------------------------------------------- + + /** + * TP77 (`trips.service.ts::generateDays`, the shared planDayGrid input) — + * `SELECT d.id, d.day_number, d.date, EXISTS (SELECT 1 FROM day_assignments + * da WHERE da.day_id = d.id) OR EXISTS (SELECT 1 FROM day_notes dn WHERE + * dn.day_id = d.id) AS has_plan_items FROM days d WHERE d.trip_id = ?`. + * Ordered by `day_number` here where the legacy statement has no `ORDER BY`: + * harmless, the plan sorts by `day_number` itself. `$castTo` for the same + * reason `DayAssignmentsRepository.listForTimeSort` gives: Kysely types the + * `eb.or(...)` column as its own `SqlBool`, not the `0`/`1` SQLite returns. + */ + async listForDayGrid(trip_id: number): Promise { + return await this.kysely() + .selectFrom('days as d') + .select(['d.id', 'd.day_number', 'd.date']) + .select((eb) => + eb.or([ + eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.day_id', '=', 'd.id')), + eb.exists(eb.selectFrom('day_notes as dn').select('dn.id').whereRef('dn.day_id', '=', 'd.id')), + ]).as('has_plan_items'), + ) + .where('d.trip_id', '=', trip_id) + .orderBy('d.day_number', 'asc') + .$castTo() + .execute(); + } + + /** + * TP78 (`trips.service.ts::generateDays`) — `SELECT dac.start_day_id, + * dac.end_day_id FROM day_accommodations dac WHERE dac.start_day_id IN + * (SELECT id FROM days WHERE trip_id = ?) OR dac.end_day_id IN (SELECT id + * FROM days WHERE trip_id = ?)`: every stay touching one of the trip's days. + */ + async listDayGridStays(trip_id: number): Promise<{ start_day_id: number; end_day_id: number }[]> { + return await this.kysely() + .selectFrom('day_accommodations as dac') + .select(['dac.start_day_id', 'dac.end_day_id']) + .where((eb) => + eb.or([ + eb('dac.start_day_id', 'in', eb.selectFrom('days').select('id').where('trip_id', '=', trip_id)), + eb('dac.end_day_id', 'in', eb.selectFrom('days').select('id').where('trip_id', '=', trip_id)), + ]), + ) + .execute(); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (`public-api.service.ts::buildDays`) — additive. + // --------------------------------------------------------------------------- + + /** + * `SELECT id, day_number, date, title, notes FROM days WHERE trip_id = ? + * ORDER BY day_number ASC` — the same shape {@link listPlanDays} already + * establishes for this table, a narrower column set. + */ + async listForPublicApi(trip_id: number): Promise { + return await this.qb('d') + .select(['d.id', 'd.day_number', 'd.date', 'd.title', 'd.notes']) + .where({ trip: trip_id }) + .orderBy({ day_number: 'asc' }) + .execute('all', false); + } +} + +/** {@link DaysRepository.listForPublicApi}'s projection. */ +export interface PublicApiDayProjectionRow { + id: number; + day_number: number; + date: string | null; + title: string | null; + notes: string | null; +} diff --git a/server/src/db/repositories/DemoRepository.ts b/server/src/db/repositories/DemoRepository.ts new file mode 100644 index 000000000..6190a99ff --- /dev/null +++ b/server/src/db/repositories/DemoRepository.ts @@ -0,0 +1,288 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Users } from '../entities/Users.entity'; +import type { NewAdminUserRow } from './Users.repository'; +import { AppSettings } from '../entities/AppSettings.entity'; +import { Trips } from '../entities/Trips.entity'; + +/** DMR1's projection (`resetDemoUser`'s pre-close credential read). */ +export interface DemoAdminCredentialsRow { + password_hash: string; + maps_api_key: string | null; + openweather_api_key: string | null; + unsplash_api_key: string | null; + avatar: string | null; +} + +/** DMR2's projection (`resetDemoUser`'s pre-close instance-API-key read) — a row per matched key, `value` preserved even when it is SQL NULL (unlike `AppSettingsRepository#getValues`, which drops a NULL-valued row from its Map; DMR6 re-writes exactly what DMR2 read, so a NULL must round-trip, not disappear). */ +export interface DemoInstanceKeyRow { + key: string; + value: string | null; +} + +/** + * DMS9's 13-column `places` insert tuple — kept as a tuple, not an object, + * so `demo-seed.ts`'s three ~30-row example-trip data arrays (Tokyo/Kyoto, + * Barcelona, New York) pass through unchanged: `trip_id, name, lat, lng, + * address, category_id, place_time, duration_minutes, notes, image_url, + * google_place_id, website, phone`. `category_id` is HARD-CODED 1-9 in the + * caller's data, coupled to `CategorySeeder`'s row order — a documented + * fragility (`db/orm.ts`), not something this repository fixes. + */ +export type NewDemoPlaceRow = readonly [ + tripId: number, + name: string, + lat: number, + lng: number, + address: string, + categoryId: number, + placeTime: string, + durationMinutes: number, + notes: string, + imageUrl: string | null, + googlePlaceId: string | null, + website: string | null, + phone: string | null, +]; + +/** + * Plan 3i Task 3 — `demo-seed.ts`'s 14 distinct statement texts (17 call + * sites) and `demo-reset.ts`'s reset-side statements (per R3's SAFE spike + * verdict, `task-0-report.md`), named ahead of this plan by the program + * brief's own rule 4. + * + * Not a `TrekRepository` — it backs no table and no entity of its + * own (it reaches `users`/`app_settings`/`trips` and, for the bulk + * example-trip seed, `days`/`places`/`day_assignments`/`packing_items`/ + * `budget_items`/`reservations`/`day_notes`/`trip_members`, none of which it + * "owns" the way `Users.repository.ts` owns `users`). Where an existing + * repository already has the exact shape (`UsersRepository#findByEmailExact`/ + * `#insertAdminCreatedUser`, `AppSettingsRepository#upsertOrReplace`) this + * repository composes it via `this.em.getRepository(Entity)` rather than + * duplicating the statement — `Users.repository.ts`/`AppSettings.repository.ts` + * are Plan 3i Task 1's territory this task and never edits. The remaining + * shapes (a `trips` count/list, the shared `trip_members` "insert or ignore" + * membership row, and the seven bulk INSERT loops for the three example + * trips) go through `connection.execute()` — rule 4's last-tier escape + * hatch, reserved for exactly this repository — because a prepared + * statement run in a loop over hand-written seed data is the natural shape + * here, and forcing ~90 rows of literal data through per-entity `insert()` + * calls (importing eight more entity classes this task does not own) buys + * nothing a raw, parameterised statement doesn't already give. + * + * Constructed directly (`new DemoRepository(em)`), never through Nest DI: + * its only two callers — `demo/demo-seed.ts` and `demo/demo-reset.ts` — are + * plain function modules, not Nest providers (the first Nest-DI-*shaped* + * home either has ever had, not a Nest-DI-*resolved* one), and obtain the + * `EntityManager` via `RequestContext.getEntityManager()` (the same static + * accessor MikroORM's own per-request middleware and + * `CronRegistrarService`'s `wrappedTick` populate) rather than constructor + * injection. No module registers this class as a provider. + */ +export class DemoRepository { + constructor(private readonly em: EntityManager) {} + + /** See `MaintenanceRepository`'s twin — throws outside a request context. */ + private validateRequestContext(): void { + this.em.getContext(); + } + + /** `this.em.getConnection().execute(sql, params, 'run')`, validated first — the one place every raw statement below funnels through. */ + private async run(sql: string, params: readonly unknown[]): Promise<{ insertId: number }> { + this.validateRequestContext(); + const result = await this.em.getConnection().execute<{ id: number }>(sql, [...params], 'run'); + return { insertId: Number(result.insertId) }; + } + + // --------------------------------------------------------------------- + // demo-seed.ts + // --------------------------------------------------------------------- + + /** + * DMS1 — `SELECT id FROM users WHERE email = ?`. Two call sites in + * `seedDemoData` (the admin lookup, the demo-user lookup) share this one + * method. Composes `UsersRepository#findByEmailExact` (case-sensitive, no + * guest filter, full row) rather than a new statement — exact shape match. + */ + async findUserByEmail(email: string): Promise<{ id: number } | null> { + const row = await this.em.getRepository(Users).findByEmailExact(email); + return row ? { id: row.id } : null; + } + + /** + * DMS2 — `INSERT INTO users (username, email, password_hash, role) VALUES + * (?, ?, ?, ?)`. Two call sites in `seedDemoData` (admin creation, demo-user + * creation) share this one method. Composes + * `UsersRepository#insertAdminCreatedUser` — byte-identical legacy text + * (AD4's own statement), which already leaves every other column + * (`first_seen_version`, `login_count`, …) to the entity's default, the + * same as the legacy INSERT's omitted columns fell back to the schema's + * `DEFAULT`. + */ + async createUser(row: NewAdminUserRow): Promise { + return await this.em.getRepository(Users).insertAdminCreatedUser(row); + } + + /** DMS3 — `INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')`. Composes `AppSettingsRepository#upsertOrReplace`, the same dialect the legacy statement used. */ + async setAllowRegistrationFalse(): Promise { + await this.em.getRepository(AppSettings).upsertOrReplace('allow_registration', 'false'); + } + + /** DMS4 — `SELECT COUNT(*) as count FROM trips WHERE user_id = ?` (the "already seeded" idempotency check). */ + async countTripsByUser(userId: number): Promise { + this.validateRequestContext(); + return await this.em.getRepository(Trips).count({ user_id: userId }); + } + + /** DMS5 — `SELECT id FROM trips WHERE user_id = ?` (`ensureDemoMembership`'s trip list). */ + async listTripIdsByUser(userId: number): Promise { + this.validateRequestContext(); + const rows = await this.em.getRepository(Trips).find({ user_id: userId }, { fields: ['id'] }); + return rows.map((r) => r.id); + } + + /** + * DMS6 — `INSERT OR IGNORE INTO trip_members (trip_id, user_id, + * invited_by) VALUES (?, ?, ?)`. Shared between `ensureDemoMembership` + * (looped over an admin's existing trips) and `seedExampleTrips` (one call + * per newly seeded trip) — one repository method, called from both, per + * the legacy code's own dup-text shape. + */ + async addTripMember(tripId: number, userId: number, invitedBy: number): Promise { + await this.run('INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)', [tripId, userId, invitedBy]); + } + + /** DMS7 — `INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)`. Returns the generated trip id. */ + async insertTrip(userId: number, title: string, description: string, startDate: string, endDate: string, currency: string): Promise { + const { insertId } = await this.run( + 'INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)', + [userId, title, description, startDate, endDate, currency], + ); + return insertId; + } + + /** DMS8 — `INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)`. Returns the generated day id. */ + async insertDay(tripId: number, dayNumber: number, date: string): Promise { + const { insertId } = await this.run('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)', [tripId, dayNumber, date]); + return insertId; + } + + /** DMS9 — the 13-column `places` insert (see {@link NewDemoPlaceRow}). Returns the generated place id. */ + async insertPlace(row: NewDemoPlaceRow): Promise { + const { insertId } = await this.run( + 'INSERT INTO places (trip_id, name, lat, lng, address, category_id, place_time, duration_minutes, notes, image_url, google_place_id, website, phone) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + row, + ); + return insertId; + } + + /** DMS10 — `INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)`. */ + async insertDayAssignment(dayId: number, placeId: number, orderIndex: number): Promise { + await this.run('INSERT INTO day_assignments (day_id, place_id, order_index) VALUES (?, ?, ?)', [dayId, placeId, orderIndex]); + } + + /** DMS11 — `INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`. */ + async insertPackingItem(tripId: number, name: string, checked: number, category: string, sortOrder: number): Promise { + await this.run('INSERT INTO packing_items (trip_id, name, checked, category, sort_order, updated_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)', [tripId, name, checked, category, sortOrder]); + } + + /** DMS12 — `INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)`. */ + async insertBudgetItem(tripId: number, category: string, name: string, totalPrice: number, persons: number, note: string | null): Promise { + await this.run('INSERT INTO budget_items (trip_id, category, name, total_price, persons, note) VALUES (?, ?, ?, ?, ?, ?)', [tripId, category, name, totalPrice, persons, note]); + } + + /** + * DMS13 — `INSERT INTO reservations (trip_id, day_id, title, + * reservation_time, confirmation_number, status, type, location) VALUES + * (?, ?, ?, ?, ?, ?, ?, ?)`. `reservationTime` carries the full date + * (`'2026-04-15T15:00'`), never a bare clock time — pinned parity, #1934. + */ + async insertReservation( + tripId: number, + dayId: number, + title: string, + reservationTime: string, + confirmationNumber: string, + status: string, + type: string, + location: string, + ): Promise { + await this.run( + 'INSERT INTO reservations (trip_id, day_id, title, reservation_time, confirmation_number, status, type, location) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [tripId, dayId, title, reservationTime, confirmationNumber, status, type, location], + ); + } + + /** DMS14 — `INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)`. */ + async insertDayNote(dayId: number, tripId: number, text: string, time: string, icon: string, sortOrder: number): Promise { + await this.run('INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)', [dayId, tripId, text, time, icon, sortOrder]); + } + + // --------------------------------------------------------------------- + // demo-reset.ts + // --------------------------------------------------------------------- + + /** + * DMR1 — `SELECT password_hash, maps_api_key, openweather_api_key, + * unsplash_api_key, avatar FROM users WHERE email = ?` (the pre-close + * credential read). Generic inherited `findOne` with a narrower `fields` + * projection — no new method on `Users.repository.ts` (Task 1's + * territory), same reasoning as `findUserByEmail` above. + */ + async getAdminCredentials(email: string): Promise { + const row = await this.em + .getRepository(Users) + .findOne({ email }, { fields: ['password_hash', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'avatar'] }); + return row + ? { + password_hash: row.password_hash, + maps_api_key: row.maps_api_key ?? null, + openweather_api_key: row.openweather_api_key ?? null, + unsplash_api_key: row.unsplash_api_key ?? null, + avatar: row.avatar ?? null, + } + : null; + } + + /** + * DMR2 — `SELECT key, value FROM app_settings WHERE key IN + * ('maps_api_key', 'unsplash_api_key')` (the pre-close instance-API-key + * read). Generic inherited `find` — deliberately NOT + * `AppSettingsRepository#getValues`, which drops a NULL-valued row; this + * caller's own DMR6 counterpart re-writes exactly what it read. + */ + async getInstanceApiKeys(): Promise { + const rows = await this.em.getRepository(AppSettings).find({ key: { $in: ['maps_api_key', 'unsplash_api_key'] } }); + return rows.map((r) => ({ key: r.key ?? '', value: r.value ?? null })); + } + + /** + * DMR5 — `UPDATE users SET password_hash = ?, maps_api_key = ?, + * openweather_api_key = ?, unsplash_api_key = ?, avatar = ? WHERE email = + * ?` (the post-reopen credential restore). Generic inherited + * `nativeUpdate`, writing exactly these five columns — no `updated_at` + * bump, matching the legacy statement (unlike `UsersRepository`'s own + * `updateApiKeys`/`updateMapsKey`, which do touch it for a different + * caller). + */ + async restoreAdminCredentials(email: string, patch: DemoAdminCredentialsRow): Promise { + this.validateRequestContext(); + await this.em.getRepository(Users).nativeUpdate({ email }, { ...patch }); + } + + /** + * DMR6 — `INSERT INTO app_settings (key, value) VALUES (?, ?) ON + * CONFLICT(key) DO UPDATE SET value = excluded.value`, looped over the + * rows DMR2 read (the post-reopen instance-API-key restore). Generic + * inherited `upsert` (the SAME statement `AppSettingsRepository#setValue` + * issues) rather than that method directly — `value` may be SQL NULL here + * (preserved by DMR2 above), and `setValue`'s own signature types `value` + * as a non-nullable `string` for its own callers. + */ + async restoreInstanceApiKeys(rows: readonly DemoInstanceKeyRow[]): Promise { + this.validateRequestContext(); + const repo = this.em.getRepository(AppSettings); + for (const row of rows) { + await repo.upsert({ key: row.key, value: row.value }, { onConflictFields: ['key'], onConflictAction: 'merge' }); + } + } +} diff --git a/server/src/db/repositories/DocumentConnections.repository.ts b/server/src/db/repositories/DocumentConnections.repository.ts new file mode 100644 index 000000000..1df1b8333 --- /dev/null +++ b/server/src/db/repositories/DocumentConnections.repository.ts @@ -0,0 +1,195 @@ +import { currentTimestamp, coalesceOverride } from '../dialect/sql-functions'; +import type { DocumentConnections } from '../entities/DocumentConnections.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A bare `document_connections` row. `secrets` is opaque encrypted TEXT here + * and everywhere in this repository (R7): encryption/decryption stays in + * `DocSyncConfigService`/`doc-sync-secrets.ts`, never in this file — a + * repository method takes and returns the ciphertext exactly as stored, + * calling no crypto function of its own. + */ +export interface DocumentConnectionRow { + id: number; + trip_id: number; + provider_id: string; + owner_user_id: number; + base_url: string; + secrets: string | null; + settings: string; + allow_insecure_tls: number; + capabilities: string | null; + last_probe_at: string | null; + last_probe_state: string; + last_probe_error: string | null; + created_at: string | null; + updated_at: string | null; +} + +const _connectionRowKeys: AssertRowKeys = true; + +interface OwnerLeftKyselyDB { + document_connections: { id: number; trip_id: number; owner_user_id: number }; + trip_members: { trip_id: number; user_id: number }; + trips: { id: number; user_id: number }; +} + +/** + * `document_connections` — one provider credential per trip (R7's encrypted + * `secrets` column; see its class docstring for the encrypt-in-service rule). + */ +export class DocumentConnectionsRepository extends TrekRepository { + /** DSC4 (`getConnection`) — `SELECT * FROM document_connections WHERE id = ?`. */ + async findById(id: number): Promise { + const row = await this.findOne({ id }); + return row ? toRow(row) : undefined; + } + + /** DSC5 (`listConnections`) — `SELECT * FROM document_connections WHERE trip_id = ? ORDER BY id`. */ + async listForTrip(tripId: number): Promise { + const rows = await this.find({ trip_id: tripId }, { orderBy: { id: 'asc' } }); + return rows.map(toRow); + } + + /** DSC7 (`upsertConnection`'s pre-check) — `SELECT * FROM document_connections WHERE trip_id = ? AND provider_id = ?`. */ + async findForTripAndProvider(tripId: number, providerId: string): Promise { + const row = await this.findOne({ trip_id: tripId, provider_id: providerId }); + return row ? toRow(row) : undefined; + } + + /** + * DSC6 (`saveEarnedSecret`, inside `uow.transactional`) — `UPDATE + * document_connections SET secrets = ? WHERE id = ?`. `secrets` arrives + * already encrypted — this method never calls `encryptSecrets` itself. + */ + async updateSecrets(id: number, secrets: string | null): Promise { + await this.nativeUpdate({ id }, { secrets }); + } + + /** + * DSC8 (`upsertConnection`, existing row, inside `uow.transactional`) — + * `UPDATE document_connections SET base_url = ?, secrets = ?, settings = + * ?, allow_insecure_tls = ?, owner_user_id = ?, updated_at = + * CURRENT_TIMESTAMP WHERE id = ?`. + */ + async updateConnection( + id: number, + data: { base_url: string; secrets: string | null; settings: string; allow_insecure_tls: number; owner_user_id: number }, + ): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + base_url: data.base_url, + secrets: data.secrets, + settings: data.settings, + allow_insecure_tls: data.allow_insecure_tls, + ownerUser: data.owner_user_id, + updated_at: currentTimestamp(platform), + }, + ); + } + + /** + * DSC9 (`upsertConnection`, new row, inside `uow.transactional`) — + * `INSERT INTO document_connections (trip_id, provider_id, owner_user_id, + * base_url, secrets, settings, allow_insecure_tls) VALUES (?×7)`. + */ + async insertConnection(data: { + trip_id: number; + provider_id: string; + owner_user_id: number; + base_url: string; + secrets: string | null; + settings: string; + allow_insecure_tls: number; + }): Promise { + return await this.insert({ + trip: data.trip_id, + provider: data.provider_id, + ownerUser: data.owner_user_id, + base_url: data.base_url, + secrets: data.secrets, + settings: data.settings, + allow_insecure_tls: data.allow_insecure_tls, + }); + } + + /** + * DSC10 (`recordProbe`) — `UPDATE document_connections SET last_probe_at = + * CURRENT_TIMESTAMP, last_probe_state = ?, last_probe_error = ?, + * capabilities = COALESCE(?, capabilities), updated_at = CURRENT_TIMESTAMP + * WHERE id = ?`. + */ + async recordProbe(id: number, state: string, error: string | null, capabilitiesJson: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + last_probe_at: currentTimestamp(platform), + last_probe_state: state, + last_probe_error: error, + capabilities: coalesceOverride(platform, capabilitiesJson, 'capabilities'), + updated_at: currentTimestamp(platform), + }, + ); + } + + /** DSC11 (`deleteConnection`) — `DELETE FROM document_connections WHERE id = ?`. Its bindings cascade (ON DELETE CASCADE). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * DSC20/DSC21's shared predicate — "which connections' credential owner is + * no longer on the connection's trip (neither via `trip_members` nor as + * the trip's own owner)". The legacy code re-implemented this `NOT IN + * (... UNION ...)` subquery inline at BOTH call sites + * (`ownerLeft`'s single-connection check, `markOrphanedLinks`'s bulk + * sweep); this is the ONE typed version both now consume — + * `DocSyncConfigService.ownerLeft(id)` checks membership of this list, + * `.markOrphanedLinks()` hands the whole list to + * `TripDocumentLinksRepository.markOrphanedByConnectionIds`. `UNION` (not + * `UNION ALL`) — the legacy text dedupes, matched exactly: Kysely's + * `.union()`, not `.unionAll()`. + */ + async listOrphanedIds(): Promise { + const rows = await this.kysely() + .selectFrom('document_connections as c') + .select('c.id') + .where((eb) => + eb( + 'c.owner_user_id', + 'not in', + (qb) => + qb + .selectFrom('trip_members as tm') + .select('tm.user_id') + .whereRef('tm.trip_id', '=', 'c.trip_id') + .union(qb.selectFrom('trips as t').select('t.user_id').whereRef('t.id', '=', 'c.trip_id')), + ), + ) + .execute(); + return rows.map((r) => r.id); + } +} + +function toRow(entity: DocumentConnections): DocumentConnectionRow { + return { + id: entity.id, + trip_id: entity.trip_id, + provider_id: entity.provider_id, + owner_user_id: entity.owner_user_id, + base_url: entity.base_url, + secrets: entity.secrets ?? null, + settings: entity.settings, + allow_insecure_tls: entity.allow_insecure_tls, + capabilities: entity.capabilities ?? null, + last_probe_at: entity.last_probe_at ?? null, + last_probe_state: entity.last_probe_state, + last_probe_error: entity.last_probe_error ?? null, + created_at: entity.created_at ?? null, + updated_at: entity.updated_at ?? null, + }; +} diff --git a/server/src/db/repositories/DocumentProviderFields.repository.ts b/server/src/db/repositories/DocumentProviderFields.repository.ts new file mode 100644 index 000000000..307275dde --- /dev/null +++ b/server/src/db/repositories/DocumentProviderFields.repository.ts @@ -0,0 +1,57 @@ +import type { DocumentProviderFields } from '../entities/DocumentProviderFields.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** DSC2's projection — every column the connection form needs, minus `id`/`provider_id`. */ +export interface DocumentProviderFieldRow { + field_key: string; + /** i18n key suffix, never display text: the client resolves `docsync.- ?)` + * with `value` already bound inside the fragment (`sql-functions.ts`'s own + * docstring) — used as a filter KEY paired with `{ $lte: tolerance }`, so + * the emitted SQL and bind order match the legacy statement exactly: + * `ABS(lat - ?) <= ?` then `ABS(lng - ?) <= ?`, the SAME `tolerance` value + * on both. + */ + async findDuplicateByCoords(trip_id: string, lat: number, lng: number, tolerance: number): Promise<{ id: number; google_ftid: string | null } | undefined> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('p') + .select(['p.id', 'p.google_ftid']) + .where('p.trip_id = ?', [trip_id]) + .andWhere({ + lat: { $ne: null }, + lng: { $ne: null }, + [absDifference(platform, 'p.lat', lat)]: { $lte: tolerance }, + [absDifference(platform, 'p.lng', lng)]: { $lte: tolerance }, + }) + .orderBy({ 'p.id': 'asc' }) + .limit(1) + .execute<{ id: number; google_ftid: string | null } | undefined>('get', false); + } + + /** + * PL29 (`exportGpx`'s waypoint read) — `SELECT p.name, p.description, + * p.address, p.lat, p.lng, p.route_geometry, c.name AS category FROM + * places p LEFT JOIN categories c ON c.id = p.category_id WHERE p.trip_id + * = ? ORDER BY p.id`. PL30 (the `days`/`day_assignments` itinerary half) + * is `DayAssignmentsRepository.listItineraryForGpx` — a different table + * root, kept there per the task-4 brief's own pointer. + */ + async listForGpx(trip_id: string): Promise<{ + name: string; description: string | null; address: string | null; + lat: number | null; lng: number | null; route_geometry: string | null; + category: string | null; + }[]> { + return this.qb('p') + .leftJoin('p.category', 'c') + .select(['p.name', 'p.description', 'p.address', 'p.lat', 'p.lng', 'p.route_geometry', 'c.name as category']) + .where('p.trip_id = ?', [trip_id]) + .orderBy({ 'p.id': 'asc' }) + .execute<{ name: string; description: string | null; address: string | null; lat: number | null; lng: number | null; route_geometry: string | null; category: string | null }[]>('all', false); + } + + /** + * PL24 (`PlacesService.buildDedupSet`'s read) — `SELECT name, lat, lng, + * google_place_id, google_ftid, osm_id, amap_poi_id FROM places WHERE + * trip_id = ?`, the exact projection the legacy statement named. Task 4's + * ruling left this raw under `PlacesService` because the dedup semantics + * (names lowercased+trimmed in JS, coordinates collected only for unnamed + * rows, provider ids collected for every row) are a JS decision, not a SQL + * one — Task 5 review L6 pointed out that leaving the SQL itself raw + * still contradicted the deliverable's "PL15/16/18/22 + PI2 are the only + * allowed survivors" grep. This method is the additive repository seam + * that resolves that: it carries only the projection, none of the JS + * folding logic, which stays in `PlacesService.buildDedupSet` exactly as + * it was. + */ + async listDedupInputs(trip_id: string): Promise<{ + name: string | null; lat: number | null; lng: number | null; + google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null; + }[]> { + return this.qb('p') + .select(['p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id', 'p.amap_poi_id']) + .where('p.trip_id = ?', [trip_id]) + .execute<{ + name: string | null; lat: number | null; lng: number | null; + google_place_id: string | null; google_ftid: string | null; osm_id: string | null; amap_poi_id: string | null; + }[]>('all', false); + } + + /** + * PI1 (`place-image.ts:27`) — `SELECT 1 FROM places WHERE image_url = ? + * LIMIT 1`. Trip-agnostic on purpose (an uploaded image is ref-counted + * across every trip, not just the one it was uploaded from — a place + * copied or shared across trips can share the file). + */ + async existsByImageUrl(imageUrl: string): Promise { + const row = await this.qb('p') + .select(['p.id']) + .where({ image_url: imageUrl }) + .limit(1) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * PL3 (`PlacesService.list`) — the trip's places, joined with their + * category's flat columns, filtered by up to four optional fragments + * built as conditional `andWhere`s rather than a hand-assembled SQL + * string (§18.3's surprise: the legacy statement's own text is not + * knowable from the source without tracing four `if`s — this makes each + * fragment a single, separately readable `andWhere` call instead). + * + * `filters.searchPattern` arrives PRE-BUILT (`%${escapeLikePattern(term)}%`, + * the service's job, matching every other "coercions stay in the service" + * split in this program) — this binds it verbatim into all three `LIKE … + * ESCAPE '\'` fragments, matching the legacy's one-param-three-times bind. + * The `ESCAPE '\'` clause is written into the raw fragment text because + * MikroORM's `$like` operator does not add one (§18.3). + * + * `filters.category`/`filters.tag` bind their raw string values with no + * `Number()` conversion (matching the legacy statement's own untyped + * bind); the two `assignment` fragments reference `place_tags`/ + * `day_assignments`/`days` by physical table name in a subquery (neither + * has, or needs, an entity-relation path for this shape) — the same + * raw-SQL-subquery shape `PlacesService.list`'s own legacy string used. + * + * `DISTINCT` kept via `.select([...], true)` even though a place has at + * most one category row (so it changes nothing about THIS join) — parity + * with the legacy statement's own `SELECT DISTINCT`, not a functional + * requirement. + */ + async listForTrip(trip_id: string, filters: { + searchPattern?: string; + category?: string; + tag?: string; + assignment?: 'all' | 'unassigned' | 'assigned'; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + const qb = this.qb('p') + .leftJoin('p.category', 'c') + // #2537: the atlas' cached region rides along (`LEFT JOIN place_regions pr ON pr.place_id = p.id`). + .leftJoin('p.place_regions', 'pr') + // Tours: `LEFT JOIN tours t ON t.place_id = p.id` marks the Tour-backed + // places. `columnRef`, because the bare `t.place_id` twin is + // persist(false) and selects nothing. + .leftJoin('p.tours', 't') + .select([ + 'p.*', columnRef(platform, 't.place_id').as('tour_place_id'), + 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', + 'pr.country_code as country_code', 'pr.region_name as region_name', + ], true) + .where('p.trip_id = ?', [trip_id]); + + if (filters.searchPattern) { + qb.andWhere( + "(p.name LIKE ? ESCAPE '\\' OR p.address LIKE ? ESCAPE '\\' OR p.description LIKE ? ESCAPE '\\')", + [filters.searchPattern, filters.searchPattern, filters.searchPattern], + ); + } + if (filters.category) { + qb.andWhere('p.category_id = ?', [filters.category]); + } + if (filters.tag) { + qb.andWhere('p.id IN (SELECT place_id FROM place_tags WHERE tag_id = ?)', [filters.tag]); + } + if (filters.assignment === 'unassigned') { + qb.andWhere( + 'p.id NOT IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)', + [trip_id], + ); + } else if (filters.assignment === 'assigned') { + qb.andWhere( + 'p.id IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)', + [trip_id], + ); + } + + qb.orderBy({ 'p.created_at': 'desc' }); + + return qb.execute('all', false); + } + + /** + * PL53 (`PlacesService.setImageFromFile`, #1242) — `SELECT filename, + * original_name, mime_type, file_size FROM trip_files WHERE id = ? AND + * trip_id = ? AND deleted_at IS NULL`: the attachment a place takes its + * picture from, trash excluded. + */ + async findActiveTripFile(id: number, trip_id: number): Promise<{ filename: string; original_name: string; mime_type: string | null; file_size: number | null } | undefined> { + return await this.kysely() + .selectFrom('trip_files') + .select(['filename', 'original_name', 'mime_type', 'file_size']) + .where('id', '=', id) + .where('trip_id', '=', trip_id) + .where('deleted_at', 'is', null) + .executeTakeFirst(); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 5 (`PlacesService` import paths) — every method below is + // additive: `insertPlace`/`updatePlace`/`findWithTagsAndRatings`/the + // `findDuplicateBy*` trio above are Task 4's, unchanged and reused verbatim + // by the four importers (GPX/KML/Google/Naver) this task converts. + // --------------------------------------------------------------------------- + + /** + * PL36 (`colorizeImportedTracks`'s read half) — `SELECT DISTINCT + * route_color AS c FROM places WHERE trip_id = ? AND route_color IS NOT + * NULL`. `DISTINCT` via `.select([...], true)` (the same shape + * `listForTrip`/PL3 uses for its own `SELECT DISTINCT`), not `$ne: null` + * folded into a single-object filter, so the emitted `WHERE` clause reads + * in the legacy's own `trip_id = ? AND route_color IS NOT NULL` order. + * + * Called and its result acted on inside the SAME `uow.transactional(...)` + * block `setRouteColor` below writes in (the service's own PL36+PL37 + * pairing) — read and write share one transaction so two concurrent + * imports can never both observe the same set of free colours. + */ + async distinctRouteColors(trip_id: string | number): Promise { + const rows = await this.qb('p') + .select(['p.route_color as c'], true) + .where('p.trip_id = ? AND p.route_color IS NOT NULL', [trip_id]) + .execute<{ c: string }[]>('all', false); + return rows.map((r) => r.c); + } + + /** + * PL37 (`colorizeImportedTracks`'s write half) — `UPDATE places SET + * route_color = ? WHERE id = ?`. No `updated_at` stamp — the legacy + * statement never wrote one for this column (#776's own deliberate + * omission, restated in `PlacesService`'s class docstring), and this + * method reproduces exactly that, not the "every write stamps + * `updated_at`" shape `updatePlace`/`backfillFtid`/`fillIfEmpty` below + * follow. + */ + async setRouteColor(id: number, route_color: string): Promise { + await this.nativeUpdate({ id }, { route_color }); + } + + /** + * PL39 (`storeGooglePlaces`'s dedup-backfill half) — `UPDATE places SET + * google_ftid = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, unscoped + * by `trip_id` (matching the legacy statement exactly — the candidate id + * was already resolved against this trip by the `findDuplicatePlace` call + * immediately before it). + */ + async backfillFtid(id: number, google_ftid: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { google_ftid, updated_at: currentTimestamp(platform) }); + } + + /** + * PL43/PL44/PL46 (`enrichOne`'s two `UPDATE`s and + * `backfillMissingAddresses`'s one) — three legacy statements, one shape: + * `UPDATE places SET = COALESCE(, ?), ..., updated_at = + * CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`. One `nativeUpdate` per + * CALL, built from whichever of the six columns the caller passes + * (`fields`'s keys are all optional — a key left OFF `fields` entirely is + * not touched at all, not even COALESCE'd against itself); every key + * that IS present renders `COALESCE(, ?)` through `coalesceParam` + * (never an inline `raw()`), so a column already holding a value is never + * overwritten and an empty one is filled from `fields`'s value. + * `updated_at` is stamped unconditionally — all three legacy statements + * stamp it, none of them conditionally. + * + * The three call shapes this serves (Task 5's "×3 shapes"): + * - PL43 (`enrichOne`'s main update) — `google_place_id`, `google_ftid`, + * `address`, `website`, `phone` all at once. + * - PL44 (`enrichOne`'s photo update) — `image_url` alone. + * - PL46 (`backfillMissingAddresses`) — `address` alone. + */ + async fillIfEmpty(id: number, trip_id: number, fields: { + google_place_id?: string | null; + google_ftid?: string | null; + address?: string | null; + website?: string | null; + phone?: string | null; + image_url?: string | null; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + const data: { + google_place_id?: ReturnType; + google_ftid?: ReturnType; + address?: ReturnType; + website?: ReturnType; + phone?: ReturnType; + image_url?: ReturnType; + updated_at: ReturnType; + } = { updated_at: currentTimestamp(platform) }; + if (fields.google_place_id !== undefined) data.google_place_id = coalesceParam(platform, 'google_place_id', fields.google_place_id); + if (fields.google_ftid !== undefined) data.google_ftid = coalesceParam(platform, 'google_ftid', fields.google_ftid); + if (fields.address !== undefined) data.address = coalesceParam(platform, 'address', fields.address); + if (fields.website !== undefined) data.website = coalesceParam(platform, 'website', fields.website); + if (fields.phone !== undefined) data.phone = coalesceParam(platform, 'phone', fields.phone); + if (fields.image_url !== undefined) data.image_url = coalesceParam(platform, 'image_url', fields.image_url); + await this.nativeUpdate({ id, trip: trip_id }, data); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 7 (`trips.rpc.ts`) — additive (per `task-4-report.md`'s own + // note that this method "may not exist"): every column above is Task 4/5's, + // unchanged. + // --------------------------------------------------------------------------- + + /** + * RP2 (`trips.rpc.ts::getPlaces`) — `SELECT * FROM places WHERE trip_id = ? + * ORDER BY created_at DESC`. The trip's place POOL has no itinerary + * position of its own (`day_id`/`order_index` live on `day_assignments`), + * so this orders by `created_at` like the REST list does — the RPC + * method's own comment (`trips.rpc.ts`). + */ + async listForTripOrdered(trip_id: number): Promise { + return await this.qb('p') + .select(['p.*']) + .where({ trip: trip_id }) + .orderBy({ 'p.created_at': 'desc' }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 6 (`share.service.ts`) — additive. + // --------------------------------------------------------------------------- + + /** + * `share.service.ts:340` SH12 (`getSharedTripData`'s share_map place-pool + * read) — **SECURITY-CRITICAL (#2320)**: a named 20-column allow-list, + * never `p.*`. Withheld on purpose (matching `PUBLIC_PLACE_COLUMNS`'s own + * doc comment in `share.service.ts`): the owner's booking-status columns + * (`reservation_status`/`reservation_notes`/`reservation_datetime`), every + * routing/dedup id (`google_place_id`, `google_ftid`, `osm_id`, + * `amap_poi_id`), `route_geometry`/`route_color`/`stop_type`/ + * `fill_percent`, and `source`. Kysely, not `qb()`+`columnRef`: this read + * joins `p.category` under alias `c`, and both `trip_id`/`category_id` are + * `persist(false)` mirror columns — the exact trap + * `DayAssignmentsRepository.assignmentWithPlaceSelect`'s docstring + * documents. `ORDER BY p.created_at DESC`, matching the legacy statement. + */ + async listPublicForShare(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('places as p') + .leftJoin('categories as c', 'c.id', 'p.category_id') + .select([ + 'p.id', 'p.trip_id', 'p.name', 'p.description', 'p.lat', 'p.lng', 'p.address', 'p.category_id', + 'p.price', 'p.currency', 'p.place_time', 'p.end_time', 'p.duration_minutes', 'p.notes', + 'p.image_url', 'p.website', 'p.phone', 'p.transport_mode', 'p.created_at', 'p.updated_at', + 'c.name as category_name', 'c.color as category_color', 'c.icon as category_icon', + ]) + .where('p.trip_id', '=', trip_id as number) + .orderBy('p.created_at', 'desc') + .execute(); + } + + /** + * `share.service.ts:461` SH18 (`getSharedPlacePhotoKey`) — + * **SECURITY-CRITICAL**: the anonymous photo-serving ownership gate. + * `SELECT 1 FROM places WHERE trip_id = ? AND image_url = ?` — both must + * match, proving the requested proxy URL belongs to a place on the SAME + * trip the share token covers, never just any place anywhere with that + * `image_url`. + */ + async existsByTripAndImageUrl(trip_id: number | string, imageUrl: string): Promise { + const row = await this.kysely<{ places: { id: number; trip_id: number; image_url: string | null } }>() + .selectFrom('places') + .select('id') + .where('trip_id', '=', trip_id as number) + .where('image_url', '=', imageUrl) + .limit(1) + .executeTakeFirst(); + return !!row; + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP40 (`trips.service.ts::copy`'s places read) — `SELECT * FROM places + * WHERE trip_id = ?`, no `ORDER BY` at all (unlike RP2's `listForTripOrdered` + * above, which adds `ORDER BY created_at DESC` for the RPC list view). The + * legacy statement relies on SQLite's own rowid-ascending scan order for a + * plain, unindexed `WHERE trip_id = ?` — reproduced here the same way, by + * NOT adding an `ORDER BY`, so the copy's places are inserted (and so + * relatively ordered) the same way the legacy loop did. + */ + async listAllForTrip(trip_id: number | string): Promise { + return await this.qb('p') + .select(['p.*']) + .where('p.trip_id = ?', [trip_id]) + .execute('all', false); + } + + /** + * TP41 (`trips.service.ts::copy`'s place INSERT) — the 28-column + * `INSERT INTO places (...)` the copy loop builds, three columns wider + * than PL4's `insertPlace` (`reservation_status`, `reservation_notes`, + * `reservation_datetime` — a copy carries the source place's reservation + * fields verbatim; `PlacesService.create` never sets them at creation + * time, which is why PL4's own column set omits them). Every value here + * is copied straight off the source row with no coercion of its own — the + * copy loop's job is to remap `trip_id`, nothing else. + */ + async insertPlaceCopy(input: { + trip_id: number; + name: string; + description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + currency: string | null; + reservation_status: string | null; + reservation_notes: string | null; + reservation_datetime: string | null; + place_time: string | null; + end_time: string | null; + duration_minutes: number | null; + notes: string | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + website: string | null; + phone: string | null; + transport_mode: string | null; + osm_id: string | null; + amap_poi_id: string | null; + route_geometry: string | null; + route_color: string | null; + stop_type: string | null; + fill_percent: number | null; + }): Promise { + return await this.insert({ + trip: input.trip_id, + name: input.name, + description: input.description, + lat: input.lat, + lng: input.lng, + address: input.address, + category: input.category_id, + price: input.price, + currency: input.currency, + reservation_status: input.reservation_status, + reservation_notes: input.reservation_notes, + reservation_datetime: input.reservation_datetime, + place_time: input.place_time, + end_time: input.end_time, + duration_minutes: input.duration_minutes, + notes: input.notes, + image_url: input.image_url, + google_place_id: input.google_place_id, + google_ftid: input.google_ftid, + website: input.website, + phone: input.phone, + transport_mode: input.transport_mode, + osm_id: input.osm_id, + amap_poi_id: input.amap_poi_id, + route_geometry: input.route_geometry, + route_color: input.route_color, + stop_type: input.stop_type, + fill_percent: input.fill_percent, + }); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 1 (`RoadtripService`/`ChargingService`) — additive, per this + // task's own file-ownership rule ("all additive methods on Days/Places/ + // DayAssignments repositories"). + // --------------------------------------------------------------------------- + + /** + * RT13 (`RoadtripService.trackExists`) — `SELECT id FROM places WHERE id = + * ? AND trip_id = ? AND route_geometry IS NOT NULL AND route_geometry != + * ''`. Two `.andWhere()` calls (rule 23 — typed conditions, not an inline + * SQL string) rather than one `$and` array: the two operators (`$ne: null` + * for `IS NOT NULL`, `$ne: ''` for `!= ''`) target the SAME property, so a + * single filter object literal cannot carry both keys. + */ + async isTrackInTrip(id: number, trip_id: number): Promise { + const row = await this.qb('p') + .select(['p.id']) + .where({ id, trip: trip_id }) + .andWhere({ route_geometry: { $ne: null } }) + .andWhere({ route_geometry: { $ne: '' } }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** CH1 (`ChargingService.read`) — `SELECT name, lat, lng, stop_type FROM places WHERE id = ? AND trip_id = ?`. */ + async findChargingProbe(id: number, trip_id: number): Promise<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined> { + const row = await this.qb('p') + .select(['p.name', 'p.lat', 'p.lng', 'p.stop_type']) + .where({ id, trip: trip_id }) + .execute<{ name: string; lat: number | null; lng: number | null; stop_type: string | null } | undefined>('get', false); + return row ?? undefined; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 3 (`AccommodationsService`) — additive, per this task's own + // file-ownership rule ("additive methods on Days/Places/DayAssignments/ + // Reservations repositories where a read belongs there"). + // --------------------------------------------------------------------------- + + /** AC9 (`AccommodationsService.stampLodging`) — `SELECT stop_type FROM places WHERE id = ?`. */ + async getStopType(id: number): Promise<{ stop_type: string | null } | undefined> { + return await this.qb('p') + .select(['p.stop_type']) + .where({ id }) + .execute<{ stop_type: string | null } | undefined>('get', false); + } + + /** AC10 (`AccommodationsService.stampLodging`) — `UPDATE places SET stop_type = 'hotel' WHERE id = ?`. Does NOT stamp `places.updated_at` (matches the legacy statement, R7 class — flagged, not fixed). */ + async stampHotel(id: number): Promise { + await this.nativeUpdate({ id }, { stop_type: 'hotel' }); + } + + /** AC31 (`AccommodationsService.createAccommodation`) — `SELECT name FROM places WHERE id = ?`, the auto-created partner booking's title source. */ + async getName(id: number): Promise<{ name: string } | undefined> { + return await this.qb('p') + .select(['p.name']) + .where({ id }) + .execute<{ name: string } | undefined>('get', false); + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 1 (`FilesService.findForeignLinkTarget`, R12) — additive, + // append-only per that task's own file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * FL2 (`FilesService.findForeignLinkTarget`'s place branch) — `SELECT 1 + * FROM places WHERE id = ? AND trip_id = ?`, re-expressed as "what trip + * does this row belong to" (R12, the same shape as + * `ReservationsRepository.findTripId`'s docstring). + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('p') + .select([columnRef(platform, 'p.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 2 (budget) — additive, append-only per that task's own + // file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * BG20 (`BudgetService.rebaseTripCurrency`'s `pinPlaces`) — `UPDATE places + * SET currency = ?, updated_at = CURRENT_TIMESTAMP WHERE trip_id = ? AND + * price IS NOT NULL AND (currency IS NULL OR currency = '')`. Only priced + * places are denominated; `updated_at` doubles as the optimistic- + * concurrency token (#1135). + */ + async pinCurrencyForTrip(trip_id: number | string, prev_currency: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { trip: trip_id as number, price: { $ne: null }, $or: [{ currency: null }, { currency: '' }] }, + { currency: prev_currency, updated_at: currentTimestamp(platform) }, + ); + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 1 (`AtlasService`) — additive, append-only per that task's + // own file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * AT2 (`AtlasService#getPlacesForTrips`) — `SELECT * FROM places WHERE + * trip_id IN (...)`. Dynamic `IN`, empty-array short-circuit (the + * `listAllForTrip`/single-id precedent above, widened to a batch of trip + * ids — every atlas read that resolves a user's trips first passes the + * whole id list through here in one call rather than one query per trip). + */ + async listForTripIds(trip_ids: number[]): Promise { + if (trip_ids.length === 0) return []; + return await this.qb('p') + .select(['p.*']) + .where({ trip: { $in: trip_ids } }) + .execute('all', false); + } + + /** + * AT41 (`AtlasService#getTravelStats`) — `SELECT DISTINCT p.address, + * p.lat, p.lng, pr.region_name FROM places p JOIN trips t ON p.trip_id = + * t.id LEFT JOIN trip_members tm ON t.id = tm.trip_id LEFT JOIN + * place_regions pr ON pr.place_id = p.id WHERE t.user_id = ? OR tm.user_id + * = ?`. The resolved region rides along so `cityFromAddress` can tell the + * city apart from the region sitting right above it in the same address + * (#1115) — unchanged from the legacy projection. + */ + async listAddressesForUser(user_id: number): Promise<{ address: string | null; lat: number | null; lng: number | null; region_name: string | null }[]> { + return await this.kysely() + .selectFrom('places as p') + .innerJoin('trips as t', 't.id', 'p.trip_id') + .leftJoin('trip_members as tm', 'tm.trip_id', 't.id') + .leftJoin('place_regions as pr', 'pr.place_id', 'p.id') + .select(['p.address', 'p.lat', 'p.lng', 'pr.region_name']) + .distinct() + .where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', '=', user_id)])) + .execute(); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 3 (`DawarichSuggestionsService`) — additive. + // --------------------------------------------------------------------------- + + /** + * DWS7 (`dawarich-suggestions.service.ts::acceptAsPlace`) — `UPDATE places + * SET source = 'dawarich' WHERE id = ?`. No `updated_at` stamp — the + * legacy statement never wrote one for this column (same "no stamp" + * shape {@link setRouteColor} above already documents for a different + * column). Cross-domain write from the Dawarich integration (3h) into + * this 3c-owned table. + */ + async setSource(id: number, source: string): Promise { + await this.nativeUpdate({ id }, { source }); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 2 (`CollectionsService`) — additive, cross-domain. Flagged in + // the task's own report per its brief: these touch `places`, a 3c-owned + // table, from the collections cluster's own copy/import surface. + // --------------------------------------------------------------------------- + + /** + * CL45 (`importablePlaces`) — the trip's places as offered to the + * collection import dialog, one row per place with two correlated scalar + * subqueries resolving the EARLIEST day it is assigned to (a place can + * sit on several days): `SELECT p.id AS place_id, p.name, p.address, + * p.lat, p.lng, p.category_id, p.image_url, p.google_place_id, + * p.google_ftid, p.osm_id, (SELECT MIN(d.day_number) FROM day_assignments + * da JOIN days d ON d.id=da.day_id WHERE da.place_id=p.id AND + * d.trip_id=p.trip_id) AS day_number, (SELECT d.date FROM day_assignments + * da JOIN days d ON d.id=da.day_id WHERE da.place_id=p.id AND + * d.trip_id=p.trip_id ORDER BY d.day_number ASC LIMIT 1) AS date FROM + * places p WHERE p.trip_id=? ORDER BY p.name COLLATE NOCASE`. Kysely — a + * correlated scalar subquery has no MikroORM QueryBuilder expression + * (`JourneysRepository.listForUser`'s own `eb.selectFrom(...).whereRef + * (...).as(...)` precedent, the closest analog: five correlated + * subqueries over a `LEFT JOIN`). `ORDER BY ... COLLATE NOCASE` is + * Kysely's own native `OrderByItemBuilder.collate('nocase')` — a portable + * Kysely API, not a raw SQLite fragment, so it needs no + * `sql-functions.ts` dialect wrapper (rule 5 covers dialect FUNCTIONS, + * not Kysely's own builder surface). + */ + async listImportable(tripId: number): Promise { + return await this.kysely() + .selectFrom('places as p') + .select((eb) => [ + 'p.id as place_id', 'p.name', 'p.address', 'p.lat', 'p.lng', 'p.category_id', 'p.image_url', + 'p.google_place_id', 'p.google_ftid', 'p.osm_id', + eb + .selectFrom('day_assignments as da') + .innerJoin('days as d', 'd.id', 'da.day_id') + .select((eb2) => eb2.fn.min('d.day_number').as('m')) + .whereRef('da.place_id', '=', 'p.id') + .whereRef('d.trip_id', '=', 'p.trip_id') + .as('day_number'), + eb + .selectFrom('day_assignments as da2') + .innerJoin('days as d2', 'd2.id', 'da2.day_id') + .select('d2.date') + .whereRef('da2.place_id', '=', 'p.id') + .whereRef('d2.trip_id', '=', 'p.trip_id') + .orderBy('d2.day_number', 'asc') + .limit(1) + .as('date'), + ]) + .where('p.trip_id', '=', tripId) + .orderBy('p.name', (ob) => ob.collate('nocase').asc()) + .execute(); + } + + /** + * CL57 (`setStatusFromTrip`) — `SELECT id, name, lat, lng, + * google_place_id, google_ftid, osm_id FROM places WHERE trip_id=? AND id + * IN (...)`. `$in` for the dynamic list (rule 17b), the same + * `scopedIds`-shaped raw `trip_id` condition above. + */ + async listByTripAndIds(trip_id: number, ids: number[]): Promise { + if (ids.length === 0) return []; + return await this.qb('p') + .select(['p.id', 'p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id']) + .where({ 'p.trip_id': trip_id }) + .andWhere({ id: { $in: ids } }) + .execute('all', false); + } + + /** + * CL63 (`copyToTrip`'s trip dedup set) — `SELECT name, lat, lng, + * google_place_id, google_ftid, osm_id FROM places WHERE trip_id=?`. + */ + async dedupCandidatesForTrip(trip_id: number): Promise[]> { + return await this.qb('p') + .select(['p.name', 'p.lat', 'p.lng', 'p.google_place_id', 'p.google_ftid', 'p.osm_id']) + .where({ trip: trip_id }) + .execute[]>('all', false); + } + + /** + * CL64 (`copyToTrip`, PREPARED/looped, TX) — `INSERT INTO places (trip_id, + * name, description, lat, lng, address, category_id, price, currency, + * notes, image_url, google_place_id, google_ftid, website, phone, + * osm_id) VALUES (...)` — a genuinely NARROWER 16-column insert than + * {@link insertPlace}'s full 27-column shape (no `place_time`/`end_time`/ + * `duration_minutes`/`transport_mode`/`route_geometry`/`route_color`/ + * `stop_type`/`fill_percent`/`amap_poi_id`): the legacy statement leaves + * those to the table's own `DEFAULT` clauses + * (`Migration20200101000000_baseline_schema.ts`: `duration_minutes + * INTEGER DEFAULT 60`, `transport_mode TEXT DEFAULT 'walking'`, + * `reservation_status TEXT DEFAULT 'none'`), unlike `insertPlace`'s own + * caller (`PlacesService.create`), which always resolves and writes every + * one of those columns itself. Kysely (a narrower insert-only interface, + * `CollectionsRepository`'s own `CollectionPlacesWriteKyselyDB` + * precedent for the identical "narrower than the full entity" shape) so + * this genuinely different column set never widens `insertPlace`'s own + * required-field list. Returns the generated id. + */ + async insertFromCollectionPlace(row: { + trip_id: number; + name: string; + description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + currency: string | null; + notes: string | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + website: string | null; + phone: string | null; + osm_id: string | null; + }): Promise { + const result = await this.kysely().insertInto('places').values(row).executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 4 (`maps.service.ts`'s photo-fetch method, MAP9) — additive, + // per this task's own file-ownership rule ("additive methods on + // Reservations/ReservationEndpoints/Days/Places/Users repositories"). + // --------------------------------------------------------------------------- + + /** + * MAP9 — `UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP + * WHERE google_place_id = ? AND (image_url IS NULL OR image_url = '')`. + * **SECURITY/PARITY**: the `$or` guard is what stops this from ever + * clobbering a place's already-set/custom image — preserved exactly. + * Unscoped by `trip_id`/`id` (matching the legacy statement): a + * `google_place_id` can appear on more than one place row across + * different trips, and every empty-image row sharing it is filled in one + * statement. + */ + async setImageUrlIfUnset(google_place_id: string, image_url: string): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.nativeUpdate( + { google_place_id, $or: [{ image_url: null }, { image_url: '' }] }, + { image_url, updated_at: currentTimestamp(platform) }, + ); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (`public-api.service.ts::placesByDay`) — additive. + // --------------------------------------------------------------------------- + + /** + * `SELECT da.day_id, p.name, p.address, p.lat, p.lng, p.place_time, + * p.end_time, p.duration_minutes, p.notes, p.transport_mode, c.name AS + * category FROM day_assignments da JOIN places p ON p.id = da.place_id + * LEFT JOIN categories c ON c.id = p.category_id WHERE p.trip_id = ? AND + * da.accommodation_id IS NULL ORDER BY da.day_id ASC, da.order_index ASC`. + * Kysely — the SAME cross-table-read escape hatch `ReservationsRepository + * .listUnplannedPlacesForPublicApi`'s precedent (3d Task 4) uses for this + * exact `public-api.service.ts` surface; landed on `PlacesRepository` + * rather than grouped there because the projection is place-shaped, per + * this domain plan's own ruling. + */ + async listAssignedForPublicApi(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_assignments as da') + .innerJoin('places as p', 'p.id', 'da.place_id') + .leftJoin('categories as c', 'c.id', 'p.category_id') + .select([ + 'da.day_id', + 'p.name', 'p.address', 'p.lat', 'p.lng', 'p.place_time', 'p.end_time', + 'p.duration_minutes', 'p.notes', 'p.transport_mode', 'c.name as category', + ]) + .where('p.trip_id', '=', trip_id) + .where('da.accommodation_id', 'is', null) + .orderBy('da.day_id', 'asc') + .orderBy('da.order_index', 'asc') + .execute(); + return rows as PublicApiAssignedPlaceRow[]; + } + + /** + * TO10 (`ToursService.createTour`) — `INSERT INTO places (trip_id, name, + * lat, lng, transport_mode, route_geometry) VALUES (?, ?, ?, ?, 'walking', + * ?)`. The place a drawn tour lives on starts at the route's first point; + * every other column keeps its default. Returns the generated id. + */ + async insertTourPlace(input: { trip_id: number; name: string; lat: number; lng: number; route_geometry: string }): Promise { + return await this.insert({ + trip: input.trip_id, + name: input.name, + lat: input.lat, + lng: input.lng, + transport_mode: 'walking', + route_geometry: input.route_geometry, + }); + } + + /** + * TO11 (`ToursService.updateTour`) — `UPDATE places SET name = ?, lat = ?, + * lng = ?, transport_mode = 'walking', route_geometry = ?, updated_at = + * CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?`. Returns whether a place + * of that trip was updated, so a tour deleted or moved between the check + * and the write answers 404 instead of writing nothing. + */ + async updateTourRoute( + id: number, + trip_id: number, + input: { name: string; lat: number; lng: number; route_geometry: string }, + ): Promise { + const platform = this.getEntityManager().getPlatform(); + const updated = await this.nativeUpdate( + { id, trip: trip_id }, + { + name: input.name, + lat: input.lat, + lng: input.lng, + transport_mode: 'walking', + route_geometry: input.route_geometry, + updated_at: currentTimestamp(platform), + }, + ); + return updated > 0; + } +} + +/** The `places` columns {@link PlacesRepository.listAssignedForPublicApi} selects, typed off {@link PlaceRow}. */ +type PublicApiPlaceColumns = Pick< + PlaceRow, + 'name' | 'address' | 'lat' | 'lng' | 'place_time' | 'end_time' | 'duration_minutes' | 'notes' | 'transport_mode' +>; + +/** {@link PlacesRepository.listAssignedForPublicApi}'s projection. */ +export interface PublicApiAssignedPlaceRow extends PublicApiPlaceColumns { + day_id: number; + category: string | null; +} + +/** {@link PlacesRepository.listAssignedForPublicApi}'s narrow `day_assignments`/`places`/`categories` shape. */ +interface PublicApiAssignedPlacesKyselyDB { + day_assignments: { day_id: number; place_id: number; accommodation_id: number | null; order_index: number | null }; + places: PublicApiPlaceColumns & Pick; + categories: { id: number; name: string }; +} + +/** {@link PlacesRepository.listImportable}'s row shape (CL45). */ +export interface ImportablePlaceRow { + place_id: number; + name: string; + address: string | null; + lat: number | null; + lng: number | null; + category_id: number | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + osm_id: string | null; + day_number: number | null; + date: string | null; +} + +/** {@link PlacesRepository.listByTripAndIds}/{@link PlacesRepository.dedupCandidatesForTrip}'s shared narrow projection (CL57/CL63). */ +export interface PlaceMatchRow { + id: number; + name: string; + lat: number | null; + lng: number | null; + google_place_id: string | null; + google_ftid: string | null; + osm_id: string | null; +} + +/** {@link PlacesRepository.listImportable}'s narrow `places`/`day_assignments`/`days` shape (CL45). */ +interface ImportablePlacesKyselyDB { + places: { id: number; trip_id: number; name: string; address: string | null; lat: number | null; lng: number | null; category_id: number | null; image_url: string | null; google_place_id: string | null; google_ftid: string | null; osm_id: string | null }; + day_assignments: { place_id: number; day_id: number }; + days: { id: number; trip_id: number; day_number: number; date: string }; +} + +/** {@link PlacesRepository.insertFromCollectionPlace}'s narrow insert-only shape (CL64). */ +interface PlacesNarrowInsertKyselyDB { + places: { + trip_id: number; + name: string; + description: string | null; + lat: number | null; + lng: number | null; + address: string | null; + category_id: number | null; + price: number | null; + currency: string | null; + notes: string | null; + image_url: string | null; + google_place_id: string | null; + google_ftid: string | null; + website: string | null; + phone: string | null; + osm_id: string | null; + }; +} + +/** {@link PlacesRepository.listAddressesForUser}'s narrow `places`/`trips`/`trip_members`/`place_regions` shape. */ +interface PlacesAddressesForUserKyselyDB { + places: { id: number; trip_id: number; address: string | null; lat: number | null; lng: number | null }; + trips: { id: number; user_id: number }; + trip_members: { trip_id: number; user_id: number }; + place_regions: { place_id: number; region_name: string | null }; +} diff --git a/server/src/db/repositories/PluginActions.repository.ts b/server/src/db/repositories/PluginActions.repository.ts new file mode 100644 index 000000000..226bc9b6d --- /dev/null +++ b/server/src/db/repositories/PluginActions.repository.ts @@ -0,0 +1,58 @@ +import type { PluginActions } from '../entities/PluginActions.entity'; +import type { PluginActionScope } from '@trek/shared'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `actionsOf`'s own projection — mapped to `PluginActionDescriptor` in the service. */ +export interface PluginActionRow { + action_key: string; + label: string; + hint: string | null; + danger: number; + scope: PluginActionScope; +} + +export class PluginActionsRepository extends TrekRepository { + /** PS3 (`plugins.service.ts#instanceActionsCount`) — `SELECT COUNT(*) AS n FROM plugin_actions WHERE plugin_id = ? AND scope = 'instance'`. Caller wraps this in its own try/catch → 0. */ + async countForPluginScope(pluginId: string, scope: PluginActionScope): Promise { + return await this.count({ plugin_id: pluginId, scope }); + } + + /** `actionsOf` — `SELECT action_key, label, hint, danger, scope FROM plugin_actions WHERE plugin_id = ? AND scope = ? ORDER BY sort_order`. Caller wraps this in its own try/catch → []. */ + async listForPluginScope(pluginId: string, scope: PluginActionScope): Promise { + const rows = await this.find( + { plugin_id: pluginId, scope }, + { fields: ['action_key', 'label', 'hint', 'danger', 'scope'], orderBy: { sort_order: 'asc' } }, + ); + return rows.map((r) => ({ action_key: r.action_key, label: r.label, hint: r.hint ?? null, danger: r.danger, scope: r.scope as PluginActionScope })); + } + + /** PR35 (uninstall cascade) — `DELETE FROM plugin_actions WHERE plugin_id = ?`. The caller wraps this in its own try/catch (table absent on a slimmed test schema is tolerated here). */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** + * DI6 (Plan 3j Task 3, `install/discovery.ts#upsert`) — `INSERT INTO plugin_actions + * (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?,?,?,?,?,?,?)`, + * looped once per manifest action, paired with DI5's `deleteAllForPlugin` above (the + * same delete-then-reinsert re-declare sequence). `insertMany` over a per-row loop: + * one batched native insert, same rows the legacy per-row `.run()` loop produced. A + * no-op on an empty manifest `actions[]`. + */ + async insertActions(pluginId: string, actions: NewPluginActionRow[]): Promise { + if (!actions.length) return; + await this.insertMany( + actions.map((a) => ({ plugin_id: pluginId, action_key: a.action_key, label: a.label, hint: a.hint, danger: a.danger, scope: a.scope, sort_order: a.sort_order })), + ); + } +} + +/** DI6's own row shape — `discoverPlugins#upsert`'s per-manifest-action insert. */ +export interface NewPluginActionRow { + action_key: string; + label: string; + hint: string | null; + danger: number; + scope: PluginActionScope; + sort_order: number; +} diff --git a/server/src/db/repositories/PluginCapabilityAudit.repository.ts b/server/src/db/repositories/PluginCapabilityAudit.repository.ts new file mode 100644 index 000000000..fe3a5030d --- /dev/null +++ b/server/src/db/repositories/PluginCapabilityAudit.repository.ts @@ -0,0 +1,181 @@ +import type { PluginCapabilityAudit } from '../entities/PluginCapabilityAudit.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * Plan 3j Task 2 note: this repository's ONLY method today is the uninstall + * cascade's own delete (PR45) — R-hash-chain's own read/append/prune surface + * (`host/plugin-audit.ts`) belongs to Task 3, which adds its own additive + * methods to this same repository. Do NOT confuse this table with the global, + * admin-facing `audit_log` (3i's `AuditLogRepository`) — two unrelated "audit" + * systems (plan3j-inputs.md corrections §4). + * + * Plan 3j Task 3 note: the methods below are the promised additive surface — + * `pruneKeepingNewest`/`lastHash`/`insertRow`/`forUser`/`forPlugin`/`budgetSeed`, + * backing `host/plugin-audit.ts` (R-hash-chain) and `host/plugin-host-state.ts`'s + * `budgetFor` seed read (HS1). PR45's `deleteAllForPlugin` above is untouched. + */ +export class PluginCapabilityAuditRepository extends TrekRepository { + /** PR45 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_capability_audit WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — R-hash-chain (host/plugin-audit.ts) + HS1 (plugin-host-state.ts) + // ----------------------------------------------------------------------- + + /** + * PA1 (`pruneAudit`'s amortized retention) — `` DELETE FROM plugin_capability_audit + * WHERE plugin_id = ? AND id NOT IN (SELECT id FROM plugin_capability_audit WHERE + * plugin_id = ? ORDER BY id DESC LIMIT ?) ``. Same `NOT IN (subquery … ORDER BY … + * LIMIT)` shape as `PluginErrorLogRepository#pruneKeepingRecent` — no `nativeDelete`/ + * `FilterQuery` equivalent (no correlated-subquery support), so this goes through + * `this.kysely()` (`_shared/trek-repository.ts`'s escape hatch). The `keep` count is + * a `?`-bound `limit()` call, never interpolated into SQL text. + */ + async pruneKeepingNewest(pluginId: string, keep: number): Promise { + const db = this.kysely(); + await db + .deleteFrom('plugin_capability_audit') + .where('plugin_id', '=', pluginId) + .where( + 'id', + 'not in', + db.selectFrom('plugin_capability_audit').select('id').where('plugin_id', '=', pluginId).orderBy('id', 'desc').limit(keep), + ) + .execute(); + } + + /** + * PA2 (`appendAudit`'s chain read) — `SELECT hash FROM plugin_capability_audit + * WHERE plugin_id = ? ORDER BY id DESC LIMIT 1`. `null` means "no prior row for + * this plugin" (a fresh chain) — `appendAudit` folds that into `''` the same way + * the legacy `?.hash ?? ''` did. + */ + async lastHash(pluginId: string): Promise { + const row = await this.findOne({ plugin_id: pluginId }, { fields: ['hash'], orderBy: { id: 'desc' } }); + return row?.hash ?? null; + } + + /** + * PA3 (`appendAudit`'s own write) — `INSERT INTO plugin_capability_audit + * (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) + * VALUES (?,?,?,?,?,?,?,?)`. The hash itself (and `ts`, computed in JS as + * `new Date().toISOString()` BEFORE the hash) is computed by the caller — + * `appendAudit` in `host/plugin-audit.ts` — never here: this method persists + * the already-hashed row byte-for-byte, so R-hash-chain's construction stays + * visible and testable in one place. + */ + async insertRow(entry: NewAuditRow): Promise { + await this.insert({ + plugin_id: entry.plugin_id, + acting_user_id: entry.acting_user_id, + method: entry.method, + resource: entry.resource, + code: entry.code, + ts: entry.ts, + prev_hash: entry.prev_hash, + hash: entry.hash, + }); + } + + /** + * PA4 (`readAuditForUser`) — `SELECT a.ts, a.plugin_id, p.name AS plugin_name, + * a.method, a.resource, a.code FROM plugin_capability_audit a LEFT JOIN plugins + * p ON p.id = a.plugin_id WHERE a.acting_user_id = ? ORDER BY a.id DESC LIMIT ?`. + * Kysely against the literal `plugins` table, the same `AuditLogRepository + * #listPage` LEFT-JOIN precedent (a deleted/never-existing plugin leaves + * `plugin_name` null, the row itself still returned) — no `PluginsRepository` + * dependency: the join only needs the table, not that repository's own methods. + */ + async forUser(userId: number, limit: number): Promise { + const rows = await this.kysely() + .selectFrom('plugin_capability_audit as a') + .leftJoin('plugins as p', 'p.id', 'a.plugin_id') + .select(['a.ts', 'a.plugin_id', 'p.name as plugin_name', 'a.method', 'a.resource', 'a.code']) + .where('a.acting_user_id', '=', userId) + .orderBy('a.id', 'desc') + .limit(limit) + .execute(); + return rows.map((r) => ({ ts: r.ts, plugin_id: r.plugin_id, plugin_name: r.plugin_name ?? null, method: r.method, resource: r.resource, code: r.code })); + } + + /** + * PA5 (`readAudit`, admin per-plugin view) — `SELECT ts, acting_user_id, method, + * resource, code FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id + * DESC LIMIT ?`. + */ + async forPlugin(pluginId: string, limit: number): Promise { + const rows = await this.find( + { plugin_id: pluginId }, + { fields: ['ts', 'acting_user_id', 'method', 'resource', 'code'], orderBy: { id: 'desc' }, limit }, + ); + return rows.map((r) => ({ ts: r.ts, acting_user_id: r.acting_user_id ?? null, method: r.method, resource: r.resource ?? null, code: r.code })); + } + + /** + * HS1 (`plugin-host-state.ts#budgetFor`'s seed read) — `SELECT method, COUNT(*) + * AS n FROM plugin_capability_audit WHERE plugin_id = ? AND code = 'ok' AND + * ts >= ? AND method IN ('ai.complete','ai.extract','notify.send') GROUP BY + * method`. A GROUP BY aggregate — Kysely, same escape-hatch rule as PA1/PA4. + */ + async budgetSeed(pluginId: string, since: string): Promise> { + const rows = await this.kysely() + .selectFrom('plugin_capability_audit') + .select(({ fn }) => ['method', fn.count('id').as('n')]) + .where('plugin_id', '=', pluginId) + .where('code', '=', 'ok') + .where('ts', '>=', since) + .where('method', 'in', ['ai.complete', 'ai.extract', 'notify.send']) + .groupBy('method') + .execute(); + return rows.map((r) => ({ method: r.method, n: Number(r.n) })); + } +} + +/** The column set `insertRow` writes — everything `appendAudit` has already computed. */ +export interface NewAuditRow { + plugin_id: string; + acting_user_id: number | null; + method: string; + resource: string | null; + code: string; + ts: string; + prev_hash: string | null; + hash: string; +} + +/** `forUser`'s own projection (PA4). */ +export interface AuditForUserRow { + ts: string; + plugin_id: string; + plugin_name: string | null; + method: string; + resource: string | null; + code: string; +} + +/** `forPlugin`'s own projection (PA5). */ +export interface AuditForPluginRow { + ts: string; + acting_user_id: number | null; + method: string; + resource: string | null; + code: string; +} + +/** Kysely shape for `pruneKeepingNewest`/`forUser`/`budgetSeed`'s escape-hatch queries — only the columns/tables they actually touch. */ +interface PluginCapabilityAuditKyselyDB { + plugin_capability_audit: { + id: number; + plugin_id: string; + acting_user_id: number | null; + method: string; + resource: string | null; + code: string; + ts: string; + prev_hash: string | null; + hash: string; + }; + plugins: { id: string; name: string }; +} diff --git a/server/src/db/repositories/PluginEgressHosts.repository.ts b/server/src/db/repositories/PluginEgressHosts.repository.ts new file mode 100644 index 000000000..582ae9ce0 --- /dev/null +++ b/server/src/db/repositories/PluginEgressHosts.repository.ts @@ -0,0 +1,39 @@ +import type { PluginEgressHosts } from '../entities/PluginEgressHosts.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class PluginEgressHostsRepository extends TrekRepository { + /** PR20 — `SELECT host FROM plugin_egress_hosts WHERE plugin_id = ? ORDER BY host` (`operatorEgressHosts`). Caller wraps this in its own try/catch — a slimmed test schema without this table never blocks activation. */ + async listHostsForPlugin(pluginId: string): Promise { + const rows = await this.find({ plugin_id: pluginId }, { fields: ['host'], orderBy: { host: 'asc' } }); + return rows.map((r) => r.host); + } + + /** PS1 — `SELECT COUNT(*) AS n FROM plugin_egress_hosts WHERE plugin_id = ?` (`plugins.service.ts#egressHostCount`). Caller wraps this in its own try/catch → 0. */ + async countForPlugin(pluginId: string): Promise { + return await this.count({ plugin_id: pluginId }); + } + + /** + * PR22/PR23 — `setOperatorEgressHosts`'s DELETE+loop-INSERT: `DELETE FROM + * plugin_egress_hosts WHERE plugin_id = ?` then, per host, `INSERT OR IGNORE INTO + * plugin_egress_hosts (plugin_id, host) VALUES (?, ?)`. + * + * **R-uninstall's ONE named transaction** (the plan's "For the user" line): the CALLER + * (`PluginRuntimeService.setOperatorEgressHosts`) wraps this call in `uow.transactional` + * — this method issues the same two-step DELETE-then-INSERT-loop shape the legacy code + * did, but now atomically, so a crash mid-write leaves the OLD host set intact instead + * of a plugin with zero egress hosts. `INSERT OR IGNORE` -> `onConflictAction: 'ignore'` + * against the `(plugin_id, host)` unique pair, unchanged from the legacy statement. + */ + async replaceAllForPlugin(pluginId: string, hosts: string[]): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + for (const host of hosts) { + await this.upsert({ plugin_id: pluginId, host }, { onConflictFields: ['plugin_id', 'host'], onConflictAction: 'ignore' }); + } + } + + /** PR36 (uninstall cascade) — `DELETE FROM plugin_egress_hosts WHERE plugin_id = ?`. The caller wraps this in its own try/catch (table absent on a slimmed test schema is tolerated here). */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginEntityMetadata.repository.ts b/server/src/db/repositories/PluginEntityMetadata.repository.ts new file mode 100644 index 000000000..bdfc9aa0d --- /dev/null +++ b/server/src/db/repositories/PluginEntityMetadata.repository.ts @@ -0,0 +1,101 @@ +import type { PluginEntityMetadata } from '../entities/PluginEntityMetadata.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +export class PluginEntityMetadataRepository extends TrekRepository { + /** PR40 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_entity_metadata WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + // ----------------------------------------------------------------------- + // MR1–MR6 (Plan 3j Task 5, `plugins/host/rpc/meta.rpc.ts`'s `meta.get`/ + // `meta.set`/`meta.list`/`meta.delete` RPC handlers — a plugin's own + // namespaced key/value store on a core entity). + // ----------------------------------------------------------------------- + + /** + * MR1/MR2 — `SELECT value FROM plugin_entity_metadata WHERE plugin_id=? + * AND entity_type=? AND entity_id=? AND key=?` — MR2 (`set`'s pre-write + * existence check) is byte-identical text, existence-only; the caller + * checks `!== null` rather than this repository exposing a second, + * narrower method for the same 4-column read. + */ + async findValue(pluginId: string, entityType: string, entityId: number, key: string): Promise { + const row = await this.findOne({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key }, { fields: ['value'] }); + return row?.value ?? null; + } + + /** MR3 — `SELECT COUNT(*) AS n FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=?`, the `META_KEYS_MAX` per-entity quota. */ + async countForEntity(pluginId: string, entityType: string, entityId: number): Promise { + return await this.count({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId }); + } + + /** + * MR4 — `` INSERT INTO plugin_entity_metadata (plugin_id, entity_type, + * entity_id, key, value, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now')) + * ON CONFLICT(plugin_id, entity_type, entity_id, key) DO UPDATE SET value + * = excluded.value, updated_at = excluded.updated_at `` — the WIDEST + * composite-key upsert in the plan (4 key columns, the entity's own + * `uniques`). `PluginUserConfigRepository.upsertConfig`'s own precedent: + * `currentTimestamp(platform)` bound once so the inserted row and the + * `excluded.updated_at` merge target agree, matching the legacy + * single-statement `datetime('now')` call exactly. + */ + async upsertValue(pluginId: string, entityType: string, entityId: number, key: string, value: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.upsert( + { plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key, value, updated_at: currentTimestamp(platform) }, + { + onConflictFields: ['plugin_id', 'entity_type', 'entity_id', 'key'], + onConflictAction: 'merge', + onConflictMergeFields: ['value', 'updated_at'], + }, + ); + } + + /** MR5 — `SELECT key, value FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? ORDER BY key`. */ + async listForEntity(pluginId: string, entityType: string, entityId: number): Promise<{ key: string; value: string }[]> { + const rows = await this.find( + { plugin_id: pluginId, entity_type: entityType, entity_id: entityId }, + { fields: ['key', 'value'], orderBy: { key: 'asc' } }, + ); + return rows.map((r) => ({ key: r.key, value: r.value ?? '' })); + } + + /** MR6 — `DELETE FROM plugin_entity_metadata WHERE plugin_id=? AND entity_type=? AND entity_id=? AND key=?`, returning whether a row existed (`.changes > 0`). */ + async deleteValue(pluginId: string, entityType: string, entityId: number, key: string): Promise { + const changed = await this.nativeDelete({ plugin_id: pluginId, entity_type: entityType, entity_id: entityId, key }); + return changed > 0; + } + + /** + * Plan 3j Task 7 fix wave, must-land 2 (task-7-review.md): MR2 (the + * pre-write existence check), MR3 (the `META_KEYS_MAX` count gate) and MR4 + * (the upsert) made atomic. `meta.rpc.ts#set` used to run these as three + * separately-awaited calls — two concurrent `meta.set` calls for two + * DIFFERENT new keys on the same (plugin, entity) could both read + * `count < max` before either's write landed, so a 100-key cap let a live + * burst land 130 rows. Wrapped in `this.getEntityManager().transactional` + * — the same MikroORM API `UnitOfWork.transactional` itself calls (see + * that class's own docstring on why its default NESTED/savepoint + * propagation is safe even when a caller is already inside a transaction) + * — so `MetaRpc` needs no new constructor dependency: this fix wave found + * `tests/helpers/plugin-host.ts` (which hand-constructs `MetaRpc`) mid-edit + * by a concurrent Plan 4 task and could not touch its construction site, + * so the fix stays entirely inside the repository it already owns. + * Returns whether the write happened; `false` means a NEW key was blocked + * by the cap (the caller maps that to `BadParams`, same message as before). + */ + async upsertValueCapped(pluginId: string, entityType: string, entityId: number, key: string, value: string, maxKeys: number): Promise { + return this.getEntityManager().transactional(async () => { + const exists = (await this.findValue(pluginId, entityType, entityId, key)) !== null; + if (!exists) { + const n = await this.countForEntity(pluginId, entityType, entityId); + if (n >= maxKeys) return false; + } + await this.upsertValue(pluginId, entityType, entityId, key, value); + return true; + }); + } +} diff --git a/server/src/db/repositories/PluginErrorLog.repository.ts b/server/src/db/repositories/PluginErrorLog.repository.ts new file mode 100644 index 000000000..164210bd1 --- /dev/null +++ b/server/src/db/repositories/PluginErrorLog.repository.ts @@ -0,0 +1,57 @@ +import type { PluginErrorLog } from '../entities/PluginErrorLog.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** PR2 delivery row / plugins.service.ts `errors()`'s own projection. */ +export interface PluginErrorLogRow { + ts: string; + level: string; + message: string; +} + +/** `pruneKeepingRecent`'s own `this.kysely()` escape hatch (`_shared/trek-repository.ts`) — the `id`/`plugin_id` columns its correlated subquery needs. */ +interface PluginErrorLogKyselyDB { + plugin_error_log: { id: number; plugin_id: string }; +} + +export class PluginErrorLogRepository extends TrekRepository { + /** PR2 — `INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)` (the supervisor's `onLog` callback — §4a, lazily resolved, own try/catch swallow unchanged in the caller). */ + async insertLog(pluginId: string, level: string, message: string): Promise { + await this.insert({ plugin_id: pluginId, level, message }); + } + + /** + * PR3 — `` DELETE FROM plugin_error_log WHERE plugin_id = ? AND id NOT IN ( + * SELECT id FROM plugin_error_log WHERE plugin_id = ? ORDER BY id DESC LIMIT ${retention} + * ) `` (`pruneErrorLog`'s retention trim). The `NOT IN (subquery … ORDER BY … LIMIT)` + * shape has no `nativeDelete`/`FilterQuery` equivalent (no correlated-subquery support), + * so this is one of the few statements per repository that goes through `this.kysely()` + * (`_shared/trek-repository.ts`'s escape hatch) rather than the QueryBuilder — the + * `retention` count is a `?`-bound `limit()` call, never interpolated into SQL text. + */ + async pruneKeepingRecent(pluginId: string, retention: number): Promise { + const db = this.kysely(); + await db + .deleteFrom('plugin_error_log') + .where('plugin_id', '=', pluginId) + .where( + 'id', + 'not in', + db.selectFrom('plugin_error_log').select('id').where('plugin_id', '=', pluginId).orderBy('id', 'desc').limit(retention), + ) + .execute(); + } + + /** PR38 (uninstall cascade) / plugins.service.ts's `clearErrors` — `DELETE FROM plugin_error_log WHERE plugin_id = ?` (identical text, two call sites). */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** plugins.service.ts `errors()` — `SELECT ts, level, message FROM plugin_error_log WHERE plugin_id = ? ORDER BY ts DESC, id DESC LIMIT 200`. */ + async listRecent(pluginId: string, limit = 200): Promise { + const rows = await this.find( + { plugin_id: pluginId }, + { fields: ['ts', 'level', 'message'], orderBy: [{ ts: 'desc' }, { id: 'desc' }], limit }, + ); + return rows.map((r) => ({ ts: r.ts ?? '', level: r.level, message: r.message ?? '' })); + } +} diff --git a/server/src/db/repositories/PluginMetaMigrations.repository.ts b/server/src/db/repositories/PluginMetaMigrations.repository.ts new file mode 100644 index 000000000..1c32b44b6 --- /dev/null +++ b/server/src/db/repositories/PluginMetaMigrations.repository.ts @@ -0,0 +1,16 @@ +import type { PluginMetaMigrations } from '../entities/PluginMetaMigrations.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * Plan 3j Task 2 note: this repository's ONLY method today is the uninstall + * cascade's own delete (PR44) — the rest of this table's surface (a plugin's own + * `ctx.db.migrate` ledger) belongs to whichever later task converts `plugin-data + * .service.ts`'s host-side counterpart, if any (R-out-of-scope carve-out: the + * plugin's own sqlite file is untouched by this program). + */ +export class PluginMetaMigrationsRepository extends TrekRepository { + /** PR44 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_meta_migrations WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginOauthState.repository.ts b/server/src/db/repositories/PluginOauthState.repository.ts new file mode 100644 index 000000000..39731adf4 --- /dev/null +++ b/server/src/db/repositories/PluginOauthState.repository.ts @@ -0,0 +1,91 @@ +import type { PluginOauthState } from '../entities/PluginOauthState.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * Plan 3j Task 2 note: this repository's ONLY method today is the uninstall + * cascade's own delete (PR43) — the rest of this table's surface belongs to + * Task 4's `oauth/plugin-oauth.service.ts`, which adds its own additive methods. + */ + +/** `consumeByState`'s own projection — PO5's columns plus `plugin_id` (the + * legacy SELECT's own WHERE-bound column, re-checked in JS once the atomic + * delete below no longer filters on it — see that method's docstring). */ +export interface PluginOauthStateRow { + plugin_id: string; + user_id: number; + verifier: string; + created_at: number; +} + +/** `consumeByState`'s own Kysely DB shape. */ +interface PluginOauthStateKyselyDB { + plugin_oauth_state: { + state: string; + plugin_id: string; + user_id: number; + verifier: string; + created_at: number; + }; +} + +export class PluginOauthStateRepository extends TrekRepository { + /** PR43 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_oauth_state WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** + * PO3 (`startConnect`'s stale-state clear, before minting a fresh one) / PO10 + * (`disconnect`) — dup text `DELETE FROM plugin_oauth_state WHERE plugin_id = ? + * AND user_id = ?`, one method for both call sites. + */ + async deleteForUser(pluginId: string, userId: number): Promise { + await this.nativeDelete({ plugin_id: pluginId, user_id: userId }); + } + + /** PO4 (`startConnect`) — `INSERT INTO plugin_oauth_state (state, plugin_id, user_id, verifier, created_at) VALUES (?, ?, ?, ?, ?)`. */ + async insertState(state: string, pluginId: string, userId: number, verifier: string, createdAt: number): Promise { + await this.insert({ state, plugin_id: pluginId, user_id: userId, verifier, created_at: createdAt }); + } + + /** + * PO5+PO6+PO7 combined (`completeCallback`'s one-shot consume). The legacy + * sequence was SELECT-then-DELETE — `SELECT verifier, user_id, created_at + * FROM plugin_oauth_state WHERE state = ? AND plugin_id = ?`, then an + * UNCONDITIONAL `DELETE FROM plugin_oauth_state WHERE state = ?` on EVERY + * path out of `completeCallback` (the refused branch and the success + * branch both delete by `state` alone) — but every statement was a + * synchronous `better-sqlite3` call with no `await` anywhere between the + * read and the delete, so two concurrent `completeCallback` calls for the + * SAME state could never interleave: JS ran one call's check+delete to + * completion before the other's synchronous block even started. + * + * Splitting that into a `findOne` (PO5) and a separate `nativeDelete` + * (PO6/PO7) — each now genuinely async through MikroORM/Kysely — reopens + * exactly that window: a second concurrent caller's read can land before + * the first caller's delete does, and BOTH would see a live row and both + * would proceed to exchange the code, spending the one-shot state twice. + * Collapsed into ONE atomic `DELETE ... RETURNING`, unconditional on + * `state` alone (matching the legacy delete's own condition exactly — + * `plugin_id`/`user_id`/TTL are re-checked by the caller against the + * RETURNED row, same as it always did against the SELECTed one), the same + * `WebauthnChallengesRepository.claimChallenge` precedent this program + * already uses for an identical single-use-consume race. Whichever + * concurrent caller's DELETE the database serialises first gets the real + * row back; the other gets `null` and fails closed — restoring the + * legacy's effectively-atomic outcome instead of reopening a TOCTOU + * window the conversion would otherwise introduce. Built on `this.kysely()` + * (validates the request context first), not `em.getKysely()` directly, + * and `.returning([...])` (not the QueryBuilder's own `.returning()` — + * `claimChallenge`'s docstring records the installed MikroORM version's + * gap on a bare DELETE). + */ + async consumeByState(state: string): Promise { + const row = await this.kysely() + .deleteFrom('plugin_oauth_state') + .where('state', '=', state) + .returning(['plugin_id', 'user_id', 'verifier', 'created_at']) + .executeTakeFirst(); + return row ?? null; + } +} diff --git a/server/src/db/repositories/PluginOauthTokens.repository.ts b/server/src/db/repositories/PluginOauthTokens.repository.ts new file mode 100644 index 000000000..3f87a0d62 --- /dev/null +++ b/server/src/db/repositories/PluginOauthTokens.repository.ts @@ -0,0 +1,129 @@ +import type { PluginOauthTokens } from '../entities/PluginOauthTokens.entity'; +import { currentTimestampKysely } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * Plan 3j Task 2 note: this repository's ONLY method today is the uninstall + * cascade's own delete (`plugin-runtime.service.ts`'s `uninstall()`, PR42) — the + * statement lives in Task 2's file even though the rest of this table's surface + * (`storeToken`'s COALESCE-preserving composite upsert, R-oauth-upsert) belongs to + * Task 4's `oauth/plugin-oauth.service.ts`. Task 4 adds its own methods to this + * same repository; this one is additive and does not collide with them. + */ + +/** PO8 (`getAccessToken`)'s own projection. Ciphertext columns pass through + * untouched — `access_token`/`refresh_token` are encrypted/decrypted in + * `plugin-oauth.service.ts` only (3e's R6 boundary), never in this repository. */ +export interface PluginOauthTokenRow { + access_token: string | null; + refresh_token: string | null; + expires_at: number | null; +} + +/** + * PO11's insert-only shape (`storeToken`'s VALUES list — `updated_at` is always + * `datetime('now')`/`CURRENT_TIMESTAMP`, never caller-supplied, same reasoning + * `DocumentSyncItemsRepository`'s own write-only Kysely interface docstring gives + * for a SEPARATE interface rather than widening the read-shaped row above). + */ +interface PluginOauthTokensWriteKyselyDB { + plugin_oauth_tokens: { + plugin_id: string; + user_id: number; + access_token: string | null; + refresh_token: string | null; + expires_at: number | null; + scope: string | null; + updated_at: string; + }; +} + +export class PluginOauthTokensRepository extends TrekRepository { + /** PR42 (uninstall cascade, `deleteData` branch) — `DELETE FROM plugin_oauth_tokens WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** `exportUserData`'s GDPR fold — `SELECT DISTINCT plugin_id FROM plugin_oauth_tokens WHERE user_id = ?` (which plugins a user OAuth-linked; raw tokens are never exported). */ + async listDistinctPluginIdsForUser(userId: number): Promise { + const rows = await this.find({ user_id: userId }, { fields: ['plugin_id'] }); + return [...new Set(rows.map((r) => r.plugin_id))]; + } + + /** PO2 (`status`) — `SELECT 1 FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ? AND access_token IS NOT NULL`, as a boolean existence check. */ + async hasAccessToken(pluginId: string, userId: number): Promise { + const row = await this.findOne({ plugin_id: pluginId, user_id: userId, access_token: { $ne: null } }, { fields: ['plugin_id'] }); + return row !== null; + } + + /** PO8 (`getAccessToken`) — `SELECT access_token, refresh_token, expires_at FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?`. */ + async findTokenRow(pluginId: string, userId: number): Promise { + const row = await this.findOne({ plugin_id: pluginId, user_id: userId }, { fields: ['access_token', 'refresh_token', 'expires_at'] }); + return row ? { access_token: row.access_token ?? null, refresh_token: row.refresh_token ?? null, expires_at: row.expires_at ?? null } : null; + } + + /** PO9 (`disconnect`) — `DELETE FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = ?`. */ + async deleteForUser(pluginId: string, userId: number): Promise { + await this.nativeDelete({ plugin_id: pluginId, user_id: userId }); + } + + /** + * PO11 (`storeToken`, R-oauth-upsert) — the composite-key upsert on `(plugin_id, + * user_id)`. Legacy text: + * `` INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, + * refresh_token, expires_at, scope, updated_at) VALUES (?, ?, ?, ?, ?, ?, + * datetime('now')) ON CONFLICT(plugin_id, user_id) DO UPDATE SET + * access_token = excluded.access_token, refresh_token = COALESCE(excluded.refresh_token, + * plugin_oauth_tokens.refresh_token), expires_at = excluded.expires_at, + * scope = excluded.scope, updated_at = excluded.updated_at ``. + * + * The `COALESCE` operand order is load-bearing (R-oauth-upsert's own warning, + * read twice): NEW value (`excluded.refresh_token`) FIRST, EXISTING + * (`plugin_oauth_tokens.refresh_token`) SECOND — a refresh response that DOES + * supply a new refresh token uses it; one that omits it (many providers do on a + * plain refresh grant) keeps the row's existing token. Reversing the two operands + * would silently erase stored offline access on every refresh that omits the + * field, with nothing in the request/response cycle ever surfacing it. + * + * `em.upsert` cannot express a column-level COALESCE merge (`onConflictMergeFields` + * only ever re-assigns `excluded.` verbatim or omits the column, never "merge + * with the row's own current value") — hand-typed Kysely, the same + * `insertInto(...).onConflict(oc => oc.columns([...]).doUpdateSet({...}))` shape + * `DocumentSyncItemsRepository.insertOrUpsertOnConflict` established as this + * program's precedent for a COALESCE-preserving composite upsert. Ciphertext + * passes through untouched: `access_token`/`refresh_token` are already encrypted + * by the caller (`plugin-oauth.service.ts#storeToken`, 3e's R6 boundary) — this + * repository never calls `encrypt_api_key`/`decrypt_api_key`. + */ + async storeToken(data: { + plugin_id: string; + user_id: number; + access_token: string; + refresh_token: string | null; + expires_at: number | null; + scope: string | null; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.kysely() + .insertInto('plugin_oauth_tokens') + .values({ + plugin_id: data.plugin_id, + user_id: data.user_id, + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: data.expires_at, + scope: data.scope, + updated_at: currentTimestampKysely(platform), + }) + .onConflict((oc) => + oc.columns(['plugin_id', 'user_id']).doUpdateSet({ + access_token: (eb) => eb.ref('excluded.access_token'), + refresh_token: (eb) => eb.fn.coalesce(eb.ref('excluded.refresh_token'), eb.ref('plugin_oauth_tokens.refresh_token')), + expires_at: (eb) => eb.ref('excluded.expires_at'), + scope: (eb) => eb.ref('excluded.scope'), + updated_at: () => currentTimestampKysely(platform), + }), + ) + .execute(); + } +} diff --git a/server/src/db/repositories/PluginScheduledTasks.repository.ts b/server/src/db/repositories/PluginScheduledTasks.repository.ts new file mode 100644 index 000000000..ea3312573 --- /dev/null +++ b/server/src/db/repositories/PluginScheduledTasks.repository.ts @@ -0,0 +1,141 @@ +import type { PluginScheduledTasks } from '../entities/PluginScheduledTasks.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** HR7's insert-only shape (`schedulerSet`'s VALUES list — `payload`/`every_ms` are always caller-supplied, never defaulted the way `id`/`created_at` are). */ +export interface PluginScheduledTaskUpsertInput { + plugin_id: string; + name: string; + due_at: number; + payload: string; + every_ms: number | null; +} + +/** PR4 — the sweep's own due-task projection. */ +export interface DueScheduledTaskRow { + id: number; + plugin_id: string; + name: string; + payload: string; + every_ms: number | null; +} + +export class PluginScheduledTasksRepository extends TrekRepository { + /** + * PR4 — `` SELECT id, plugin_id, name, payload, every_ms FROM plugin_scheduled_tasks + * WHERE due_at <= ? AND plugin_id IN (${ph}) ORDER BY due_at LIMIT 200 `` (`fireDueScheduled`'s + * sweep window). `pluginIds` — never an empty array: the caller (`fireDueScheduled`) + * already returns early when `supervisor.activeIds()` is empty, the same short-circuit + * the legacy `IN (${ph})` string-building avoided running against. + */ + async findDueForPlugins(now: number, pluginIds: string[], limit = 200): Promise { + const rows = await this.find( + { due_at: { $lte: now }, plugin_id: { $in: pluginIds } }, + { fields: ['id', 'plugin_id', 'name', 'payload', 'every_ms'], orderBy: { due_at: 'asc' }, limit }, + ); + return rows.map((r) => ({ id: r.id, plugin_id: r.plugin_id, name: r.name, payload: r.payload, every_ms: r.every_ms ?? null })); + } + + /** + * PR5 — `UPDATE plugin_scheduled_tasks SET due_at = ? WHERE id = ?` — re-arm BEFORE + * the fire (crash-safety ordering, preserved exactly by the caller). + * + * **Deviation, named (task-2-report.md), the SAME spirit as R-uninstall's egress-host + * transaction — a second, small, deliberate correctness fix, not silent parity**: the + * legacy statement had no `due_at` guard, but the legacy CALL SITE never needed one — + * `better-sqlite3` is synchronous and the claim-then-act loop had no `await` between + * the SELECT and this UPDATE, so two "concurrent" JS calls to `fireDueScheduled` could + * never actually interleave at the DB level (the first ran to completion before the + * second's own SELECT even started). Converting the SELECT and this UPDATE into + * separately-awaited repository calls removes that accidental atomicity: two + * overlapping ticks (a real possibility once a tick can run longer than the 30s + * cadence) can both read the SAME due row before either claims it, delivering it + * TWICE (proven by `RACE-SCHED-001` before this guard was added). Adding `due_at <= + * now` here (the SAME condition the SELECT already used) makes the claim atomic and + * idempotent per row: whichever pass's UPDATE lands first moves `due_at` forward, so + * the LOSING pass's own `due_at <= now` no longer matches and its `nativeUpdate` + * affects zero rows — the caller uses that to skip delivery rather than double-fire. + */ + async rearm(id: number, dueAt: number, claimedFromDueAt: number): Promise { + const changed = await this.nativeUpdate({ id, due_at: { $lte: claimedFromDueAt } }, { due_at: dueAt }); + return changed > 0; + } + + /** PR6 — `DELETE FROM plugin_scheduled_tasks WHERE id = ?` (a one-shot task, deleted before the fire). Same atomic-claim guard as `rearm` above, same reason. */ + async deleteById(id: number, claimedFromDueAt: number): Promise { + const changed = await this.nativeDelete({ id, due_at: { $lte: claimedFromDueAt } }); + return changed > 0; + } + + /** PR37 (uninstall cascade) — `DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + // ----------------------------------------------------------------------- + // HR5–HR8 (Plan 3j Task 5, `plugins/host/rpc/host-surface.rpc.ts`'s + // `scheduler.set`/`scheduler.cancel` RPC handlers — a plugin's own + // request to schedule/cancel one of its named tasks, distinct from PR4– + // PR10's sweep-tick statements above). + // ----------------------------------------------------------------------- + + /** HR5 — `SELECT id FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?`, existence-only (the pre-quota check `schedulerSet` runs before counting). */ + async existsForPluginAndName(pluginId: string, name: string): Promise { + const row = await this.findOne({ plugin_id: pluginId, name }, { fields: ['id'] }); + return row !== null; + } + + /** HR6 — `SELECT COUNT(*) AS c FROM plugin_scheduled_tasks WHERE plugin_id = ?`, the `SCHED_MAX` DoS-quota count. */ + async countForPlugin(pluginId: string): Promise { + return await this.count({ plugin_id: pluginId }); + } + + /** + * HR7 — `` INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, + * payload, every_ms) VALUES (?, ?, ?, ?, ?) ON CONFLICT (plugin_id, name) + * DO UPDATE SET due_at = excluded.due_at, payload = excluded.payload, + * every_ms = excluded.every_ms `` — the composite-key upsert on + * `(plugin_id, name)` (the entity's own `uniques`, `PluginScheduledTasks + * .entity.ts`): re-scheduling the same name replaces it in place. No + * COALESCE — every non-key column is re-assigned verbatim from `excluded` + * — so `em.upsert`'s `onConflictMergeFields` expresses it directly, + * `PluginUserConfigRepository.upsertConfig`'s own precedent for a plain + * (non-merging) composite upsert. + */ + async upsertTask(input: PluginScheduledTaskUpsertInput): Promise { + await this.upsert( + { plugin_id: input.plugin_id, name: input.name, due_at: input.due_at, payload: input.payload, every_ms: input.every_ms }, + { onConflictFields: ['plugin_id', 'name'], onConflictAction: 'merge', onConflictMergeFields: ['due_at', 'payload', 'every_ms'] }, + ); + } + + /** HR8 — `DELETE FROM plugin_scheduled_tasks WHERE plugin_id = ? AND name = ?` (`scheduler.cancel`), returning whether a row existed (`.changes > 0`). */ + async deleteByPluginAndName(pluginId: string, name: string): Promise { + const changed = await this.nativeDelete({ plugin_id: pluginId, name }); + return changed > 0; + } + + /** + * Plan 3j Task 7 fix wave, must-land 3 (task-7-review.md): HR5 (the + * pre-write existence check), HR6 (the `SCHED_MAX` count gate) and HR7 + * (the upsert) made atomic — the SAME shape and SAME reason as + * `PluginEntityMetadataRepository.upsertValueCapped` (must-land 2): two + * concurrent `scheduler.set` calls for two different new task names on the + * same plugin could both read `count < max` before either's write landed + * (live: 130 rows on a 100 cap). `this.getEntityManager().transactional` — + * no new constructor dependency on `HostSurfaceRpc`, for the same + * `tests/helpers/plugin-host.ts` (dirty, mid-edit by a concurrent Plan 4 + * task) reason `upsertValueCapped`'s docstring gives. Returns whether the + * write happened; `false` means a NEW task name was blocked by the cap. + */ + async upsertTaskCapped(input: PluginScheduledTaskUpsertInput, maxTasks: number): Promise { + return this.getEntityManager().transactional(async () => { + const exists = await this.existsForPluginAndName(input.plugin_id, input.name); + if (!exists) { + const n = await this.countForPlugin(input.plugin_id); + if (n >= maxTasks) return false; + } + await this.upsertTask(input); + return true; + }); + } +} diff --git a/server/src/db/repositories/PluginSettingsFields.repository.ts b/server/src/db/repositories/PluginSettingsFields.repository.ts new file mode 100644 index 000000000..f04a50889 --- /dev/null +++ b/server/src/db/repositories/PluginSettingsFields.repository.ts @@ -0,0 +1,179 @@ +import type { PluginSettingsFields } from '../entities/PluginSettingsFields.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** PR#499 — `notificationChannels`' secret-user-field scan, across ALL plugins. */ +export interface PluginSecretFieldRow { + plugin_id: string; + field_key: string; +} + +/** `plugins.service.ts#settingsFields`'s own full projection (mapped to `PluginSettingsField` in the service, which also resolves `default`/`options`). */ +export interface PluginSettingsFieldRow { + field_key: string; + label: string | null; + input_type: string; + placeholder: string | null; + hint: string | null; + required: number; + secret: number; + options: string | null; + default_value: string | null; +} + +export class PluginSettingsFieldsRepository extends TrekRepository { + /** `notificationChannels`'s GDPR-export fold — `SELECT plugin_id, field_key FROM plugin_settings_fields WHERE scope = 'user' AND secret = 1`, across every plugin (no `plugin_id` filter — the caller groups by `plugin_id` itself). */ + async listSecretUserFields(): Promise { + return await this.find({ scope: 'user', secret: 1 }, { fields: ['plugin_id', 'field_key'] }); + } + + /** PS2 (`plugins.service.ts#instanceSettingsCount`) — `SELECT COUNT(*) AS n FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance'`. Caller wraps this in its own try/catch → 0. */ + async countForPluginScope(pluginId: string, scope: 'instance' | 'user'): Promise { + return await this.count({ plugin_id: pluginId, scope }); + } + + /** + * `updateInstanceConfig`'s secret-key set / `getInstanceConfig`'s secret-key set / + * `updateUserConfig`/`getUserConfig`'s `userSecretKeys` — `SELECT field_key FROM + * plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND secret = 1` (identical + * text shape across both scopes; ONE method for all four call sites). + */ + async listSecretFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise { + const rows = await this.find({ plugin_id: pluginId, scope, secret: 1 }, { fields: ['field_key'] }); + return rows.map((r) => r.field_key); + } + + /** + * `updateInstanceConfig`'s allowed-key set / `updateUserConfig`'s allowed-key set — + * `SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ?` + * (identical text shape across both scopes; ONE method for both call sites). + */ + async listFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise { + const rows = await this.find({ plugin_id: pluginId, scope }, { fields: ['field_key'] }); + return rows.map((r) => r.field_key); + } + + /** + * `plugins.service.ts#settingsFields` (backs `userSettingsFields`/`instanceSettingsFields`) — + * `SELECT field_key AS key, label, input_type, placeholder, hint, required, secret, + * options, default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? + * ORDER BY sort_order, id`. + */ + async listFields(pluginId: string, scope: 'instance' | 'user'): Promise { + const rows = await this.find( + { plugin_id: pluginId, scope }, + { + fields: ['field_key', 'label', 'input_type', 'placeholder', 'hint', 'required', 'secret', 'options', 'default_value'], + orderBy: [{ sort_order: 'asc' }, { id: 'asc' }], + }, + ); + return rows.map((r) => ({ + field_key: r.field_key, + label: r.label ?? null, + input_type: r.input_type, + placeholder: r.placeholder ?? null, + hint: r.hint ?? null, + required: r.required, + secret: r.secret, + options: r.options ?? null, + default_value: r.default_value ?? null, + })); + } + + /** `assertRequiredFilled` — `SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND required = 1 AND input_type != 'checkbox'`. */ + async listRequiredFieldKeys(pluginId: string, scope: 'instance' | 'user'): Promise { + const rows = await this.find( + { plugin_id: pluginId, scope, required: 1, input_type: { $ne: 'checkbox' } }, + { fields: ['field_key'] }, + ); + return rows.map((r) => r.field_key); + } + + /** PR34 (uninstall cascade) — `DELETE FROM plugin_settings_fields WHERE plugin_id = ?`. Unlike several of its cascade siblings, this one is NOT wrapped in try/catch by the caller — a slimmed test schema is expected to have this table. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** + * PU1 (Plan 3j Task 4, `plugin-user-settings.service.ts#readOne`) — `SELECT + * secret FROM plugin_settings_fields WHERE plugin_id = ? AND field_key = ? AND + * scope = 'user'`, as a boolean secret flag deciding whether `readOne` decrypts + * the stored value. + */ + async isUserFieldSecret(pluginId: string, fieldKey: string): Promise { + const row = await this.findOne({ plugin_id: pluginId, field_key: fieldKey, scope: 'user' }, { fields: ['secret'] }); + return row?.secret === 1; + } + + /** + * `plugin-user-settings.service.ts#readAll` (Plan 3j Task 4) — `SELECT + * field_key, secret FROM plugin_settings_fields WHERE plugin_id = ? AND scope = + * ?` (`scope` is always `'user'` at this call site; kept as a parameter to match + * this repository's own established convention on every other `scope`-taking + * method here). + */ + async listFieldKeysWithSecretFlag(pluginId: string, scope: 'instance' | 'user'): Promise> { + const rows = await this.find({ plugin_id: pluginId, scope }, { fields: ['field_key', 'secret'] }); + return rows.map((r) => ({ field_key: r.field_key, secret: r.secret })); + } + + /** + * DI8 (Plan 3j Task 3, `install/discovery.ts#upsert`) — `` INSERT INTO + * plugin_settings_fields (plugin_id, field_key, label, input_type, placeholder, hint, + * required, secret, scope, options, oauth_config, default_value, sort_order) VALUES + * (?,?,?,?,?,?,?,?,?,?,?,?,?) ``, looped once per manifest settings field, paired with + * DI7's `deleteAllForPlugin` above (the same delete-then-reinsert re-declare sequence). + * `insertMany`, one batched native insert — same rows the legacy per-row `.run()` loop + * produced. A no-op on an empty manifest `settings[]`. + */ + async insertFields(pluginId: string, fields: NewPluginSettingsFieldRow[]): Promise { + if (!fields.length) return; + await this.insertMany( + fields.map((f) => ({ + plugin_id: pluginId, + field_key: f.field_key, + label: f.label, + input_type: f.input_type, + placeholder: f.placeholder, + hint: f.hint, + required: f.required, + secret: f.secret, + scope: f.scope, + options: f.options, + oauth_config: f.oauth_config, + default_value: f.default_value, + sort_order: f.sort_order, + })), + ); + } + + /** + * SD1 (Plan 3j Task 3, `settings-defaults.ts#settingDefaults`) — `SELECT field_key, + * default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND + * secret = 0 AND default_value IS NOT NULL`. Secrets never carry a default (the + * manifest parse drops it before it ever reaches a row) — the `secret = 0` filter is + * belt-and-braces, kept exactly as the legacy statement had it. + */ + async listDefaults(pluginId: string, scope: 'instance' | 'user'): Promise> { + const rows = await this.find( + { plugin_id: pluginId, scope, secret: 0, default_value: { $ne: null } }, + { fields: ['field_key', 'default_value'] }, + ); + return rows.map((r) => ({ field_key: r.field_key, default_value: r.default_value ?? '' })); + } +} + +/** DI8's own row shape — `discoverPlugins#upsert`'s per-manifest-settings-field insert. */ +export interface NewPluginSettingsFieldRow { + field_key: string; + label: string | null; + input_type: string; + placeholder: string | null; + hint: string | null; + required: number; + secret: number; + scope: string; + options: string | null; + oauth_config: string | null; + default_value: string | null; + sort_order: number; +} diff --git a/server/src/db/repositories/PluginUserConfig.repository.ts b/server/src/db/repositories/PluginUserConfig.repository.ts new file mode 100644 index 000000000..f80ac6a48 --- /dev/null +++ b/server/src/db/repositories/PluginUserConfig.repository.ts @@ -0,0 +1,51 @@ +import type { PluginUserConfig } from '../entities/PluginUserConfig.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `exportUserData`'s own per-user config scan. */ +export interface PluginUserConfigRow { + plugin_id: string; + config: string; +} + +export class PluginUserConfigRepository extends TrekRepository { + /** + * PS7/PS9/PS11 — `SELECT config FROM plugin_user_config WHERE plugin_id = ? AND user_id + * = ?`, byte-identical text at THREE call sites in `plugins.service.ts` (`getUserConfig`'s + * own read, `setUserConfig`'s pre-write existence check, `getUserConfigDecrypted`'s read) + * — collapsed onto one method per the brief's own naming of this triple-duplicate. + */ + async findConfig(pluginId: string, userId: number): Promise { + const row = await this.findOne({ plugin_id: pluginId, user_id: userId }, { fields: ['config'] }); + return row?.config ?? null; + } + + /** `exportUserData`'s GDPR fold — `SELECT plugin_id, config FROM plugin_user_config WHERE user_id = ?`. */ + async listForUser(userId: number): Promise { + const rows = await this.find({ user_id: userId }, { fields: ['plugin_id', 'config'] }); + return rows.map((r) => ({ plugin_id: r.plugin_id, config: r.config })); + } + + /** + * PS10 — `` INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) + * VALUES (?, ?, ?, datetime('now')) ON CONFLICT(plugin_id, user_id) DO UPDATE SET + * config = excluded.config, updated_at = excluded.updated_at `` — the composite-key + * upsert on `(plugin_id, user_id)`, the entity's own primary key. `currentTimestamp` + * (`src/db/dialect/sql-functions.ts`) renders the same `CURRENT_TIMESTAMP`/`datetime('now')` + * text SQLite already treats as equivalent, bound once per statement execution so the + * inserted row and the `excluded.updated_at` merge target agree, matching the legacy + * single-statement `datetime('now')` call exactly. + */ + async upsertConfig(pluginId: string, userId: number, config: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.upsert( + { plugin_id: pluginId, user_id: userId, config, updated_at: currentTimestamp(platform) }, + { onConflictFields: ['plugin_id', 'user_id'], onConflictAction: 'merge', onConflictMergeFields: ['config', 'updated_at'] }, + ); + } + + /** PR41 (uninstall cascade) — `DELETE FROM plugin_user_config WHERE plugin_id = ?`. */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } +} diff --git a/server/src/db/repositories/PluginUserErasureQueue.repository.ts b/server/src/db/repositories/PluginUserErasureQueue.repository.ts new file mode 100644 index 000000000..772a452b2 --- /dev/null +++ b/server/src/db/repositories/PluginUserErasureQueue.repository.ts @@ -0,0 +1,65 @@ +import type { PluginUserErasureQueue } from '../entities/PluginUserErasureQueue.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** The drain's own pending-row projection. */ +export interface PendingErasureRow { + id: number; + plugin_id: string; + user_id: number; +} + +/** `plugin_id`/`id` — the columns the orphan-scan's correlated `NOT IN (SELECT id FROM plugins)` subquery needs. */ +interface PluginUserErasureQueueKyselyDB { + plugin_user_erasure_queue: { id: number; plugin_id: string; user_id: number }; + plugins: { id: string }; +} + +export class PluginUserErasureQueueRepository extends TrekRepository { + /** PR8 — `INSERT OR IGNORE INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?, ?)` (idempotent — a duplicate enqueue for the same pair is a no-op). */ + async insertIgnore(pluginId: string, userId: number): Promise { + await this.upsert({ plugin_id: pluginId, user_id: userId }, { onConflictFields: ['plugin_id', 'user_id'], onConflictAction: 'ignore' }); + } + + /** + * `runDrainOnce`'s orphan scan — `SELECT DISTINCT plugin_id FROM plugin_user_erasure_queue + * WHERE plugin_id NOT IN (SELECT id FROM plugins)`. A cross-table correlated `NOT IN` + * has no `FilterQuery` equivalent, so this goes through `this.kysely()`. + */ + async listOrphanPluginIds(): Promise { + const db = this.kysely(); + const rows = await db + .selectFrom('plugin_user_erasure_queue') + .select('plugin_id') + .distinct() + .where('plugin_id', 'not in', db.selectFrom('plugins').select('id')) + .execute(); + return rows.map((r) => r.plugin_id); + } + + /** + * `runDrainOnce`'s reap (a truly orphaned data dir) / `uninstall`'s `deleteData` branch — + * `DELETE FROM plugin_user_erasure_queue WHERE plugin_id = ?` (identical text, two call sites). + */ + async deleteAllForPlugin(pluginId: string): Promise { + await this.nativeDelete({ plugin_id: pluginId }); + } + + /** + * `runDrainOnce`'s delivery window — `` SELECT id, plugin_id, user_id FROM + * plugin_user_erasure_queue WHERE plugin_id IN (${ph}) ORDER BY id LIMIT 200 ``. + * `pluginIds` — never empty: the caller already returns early when + * `supervisor.activeIds()` is empty. + */ + async findPendingForPlugins(pluginIds: string[], limit = 200): Promise { + const rows = await this.find( + { plugin_id: { $in: pluginIds } }, + { fields: ['id', 'plugin_id', 'user_id'], orderBy: { id: 'asc' }, limit }, + ); + return rows.map((r) => ({ id: r.id, plugin_id: r.plugin_id, user_id: r.user_id })); + } + + /** `runDrainOnce`'s ACK — `DELETE FROM plugin_user_erasure_queue WHERE id = ?` (a delivered row). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/Plugins.repository.ts b/server/src/db/repositories/Plugins.repository.ts new file mode 100644 index 000000000..ee416f905 --- /dev/null +++ b/server/src/db/repositories/Plugins.repository.ts @@ -0,0 +1,617 @@ +import type { Plugins } from '../entities/Plugins.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * Plan 3j Task 2 — `plugins` is this plan's most-reused table (`plugin-runtime.service.ts` + * ~28 of 53 sites, `plugins.service.ts` 5 of 13). Every method below is named after the + * statement it replaces (PR#/PS# per `plan3j-sql-inventory.md` §4/§5); several methods + * serve more than one call site that shared byte-identical SQL text in the legacy file + * (documented per method). + */ + +/** PR15/PR16/PR25 — `SELECT id, version, enabled, dependencies FROM plugins`, the shape + * `dependencies.ts`'s dependency-graph helpers reason over (structurally compatible with + * `PluginDepRow` there; not imported here to keep `db/repositories` free of `nest/` imports). */ +export interface PluginDepListRow { + id: string; + version: string | null; + enabled: number; + dependencies: string | null; +} + +/** PR7/PR11 — `SELECT id, permissions FROM plugins` (the GDPR erasure enqueue's + the export's own full-table scan). */ +export interface PluginIdPermissionRow { + id: string; + permissions: string | null; +} + +/** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?` (`assertActivatable`'s one-row activation gate). */ +export interface PluginActivationGateRow { + permissions: string; + granted_permissions: string; + dependencies: string | null; + trek_range: string | null; + api_version: number | null; +} + +/** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?` (`spawnActivated`). */ +export interface PluginPermissionsConfigRow { + permissions: string; + config: string; +} + +/** PR26 — `SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?` (`update`'s pre-update snapshot). */ +export interface PluginPreUpdateRow { + enabled: number; + granted_permissions: string; + version: string | null; +} + +/** PR51 — `SELECT name, capabilities FROM plugins WHERE id = ?` (`notificationChannels`). */ +export interface PluginNameCapabilitiesRow { + name: string; + capabilities: string; +} + +/** PFC1 — `plugins-feed.controller.ts#list`'s own client-facing feed projection. */ +export interface PluginFeedRow { + id: string; + name: string; + type: string; + icon: string | null; + capabilities: string; + granted_permissions: string; +} + +/** PPS1 — `plugin-pois.service.ts#available`'s active-provider projection (#1781). */ +export interface PluginActiveNameCapabilitiesRow { + id: string; + name: string; + capabilities: string; +} + +/** PS-list — `list()`'s admin projection, every column `PluginsService.list()` re-shapes. */ +export interface PluginAdminRow { + id: string; + name: string; + description: string | null; + type: string; + icon: string | null; + version: string | null; + status: string; + enabled: number; + last_error: string | null; + reviewed_at: string | null; + source_repo: string | null; + permissions: string; + capabilities: string; + dependencies: string | null; + operator_egress: number; + trek_range: string | null; + author_pubkey: string | null; + update_block_code: string | null; + update_block_detail: string | null; + update_block_version: string | null; + update_hold: number; +} + +export class PluginsRepository extends TrekRepository { + // ----------------------------------------------------------------------- + // PR1 — status/log persistence (onStatus callback, lazily resolved — §4a) + // ----------------------------------------------------------------------- + + /** PR1 — `UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. */ + async updateStatusAndError(id: string, status: string, lastError: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { status, last_error: lastError, updated_at: currentTimestamp(platform) }); + } + + // ----------------------------------------------------------------------- + // PR7 / PR11 — GDPR erasure enqueue + export's own full-table scan + // ----------------------------------------------------------------------- + + /** PR7/PR11 — `SELECT id, permissions FROM plugins` (identical text, two call sites: `enqueueUserErasure`, `exportUserData`). */ + async listIdsAndPermissions(): Promise { + const rows = await this.find({}, { fields: ['id', 'permissions'] }); + // `Plugins.id` is typed `string | null` at the entity level (accommodates a + // pre-insert state MikroORM's own typing requires) but is the table's real + // PRIMARY KEY — never null on a row read back from the DB (same shape as + // `listForAdmin`'s `id: r.id ?? ''` below). + return rows.map((r) => ({ id: r.id ?? '', permissions: r.permissions ?? null })); + } + + // ----------------------------------------------------------------------- + // PR15 / PR16 / PR25 — dependency-graph reads + // ----------------------------------------------------------------------- + + /** PR15/PR16/PR25 — `SELECT id, version, enabled, dependencies FROM plugins` (identical text, three call sites: `installedDepRows`, `deactivateForDisabledAddon`, `deactivateWithDependents`). */ + async listDepRows(): Promise { + const rows = await this.find({}, { fields: ['id', 'version', 'enabled', 'dependencies'] }); + return rows.map((r) => ({ id: r.id ?? '', version: r.version ?? null, enabled: r.enabled, dependencies: r.dependencies ?? null })); + } + + // ----------------------------------------------------------------------- + // PR17 — activation gate + // ----------------------------------------------------------------------- + + /** PR17 — `SELECT permissions, granted_permissions, dependencies, trek_range, api_version FROM plugins WHERE id = ?`. */ + async findActivationGate(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['permissions', 'granted_permissions', 'dependencies', 'trek_range', 'api_version'] }); + return row + ? { + permissions: row.permissions ?? '[]', + granted_permissions: row.granted_permissions ?? '', + dependencies: row.dependencies ?? null, + trek_range: row.trek_range ?? null, + api_version: row.api_version ?? null, + } + : null; + } + + // ----------------------------------------------------------------------- + // PR18 — spawnActivated + // ----------------------------------------------------------------------- + + /** PR18 — `SELECT permissions, config FROM plugins WHERE id = ?`. */ + async findPermissionsAndConfig(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['permissions', 'config'] }); + return row ? { permissions: row.permissions ?? '[]', config: row.config ?? '{}' } : null; + } + + // ----------------------------------------------------------------------- + // PR19 — spawnActivated's admin-intent write + // ----------------------------------------------------------------------- + + /** PR19 — `UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?`. */ + async grantPermissionsAndEnable(id: string, grantedPermissionsJson: string): Promise { + await this.nativeUpdate({ id }, { granted_permissions: grantedPermissionsJson, enabled: 1 }); + } + + // ----------------------------------------------------------------------- + // PR21 — wantsOperatorEgress + // ----------------------------------------------------------------------- + + /** PR21 — `SELECT operator_egress FROM plugins WHERE id = ?`. */ + async findOperatorEgressFlag(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['operator_egress'] }); + return row?.operator_egress ?? null; + } + + // ----------------------------------------------------------------------- + // PR24 — deactivate / markInactive + // ----------------------------------------------------------------------- + + /** PR24 — `UPDATE plugins SET status = 'inactive', enabled = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. */ + async markInactive(id: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { status: 'inactive', enabled: 0, updated_at: currentTimestamp(platform) }); + } + + // ----------------------------------------------------------------------- + // PR26 — update's pre-update snapshot + // ----------------------------------------------------------------------- + + /** PR26 — `SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?`. */ + async findPreUpdateSnapshot(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['enabled', 'granted_permissions', 'version'] }); + return row ? { enabled: row.enabled, granted_permissions: row.granted_permissions ?? '', version: row.version ?? null } : null; + } + + // ----------------------------------------------------------------------- + // PR27 — update's post-install re-read of declared permissions + // ----------------------------------------------------------------------- + + /** PR27 — `SELECT permissions FROM plugins WHERE id = ?`. */ + async findPermissions(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['permissions'] }); + return row?.permissions ?? null; + } + + // ----------------------------------------------------------------------- + // PR28 — retrust's signing-key TOFU comparison + // ----------------------------------------------------------------------- + + /** PR28 — `SELECT author_pubkey FROM plugins WHERE id = ?`. Security-sensitive: feeds the TOFU comparison. */ + async findAuthorPubkey(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['author_pubkey'] }); + return row?.author_pubkey ?? null; + } + + // ----------------------------------------------------------------------- + // PR29 — sideload's replace-existing check + // ----------------------------------------------------------------------- + + /** PR29 — `SELECT id FROM plugins WHERE id = ?`, as a boolean existence check. */ + async existsById(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['id'] }); + return row !== null; + } + + // ----------------------------------------------------------------------- + // PR30 / PR32 — dev-link install + reload's source_repo read + // ----------------------------------------------------------------------- + + /** + * PR30/PR32 — `SELECT source_repo FROM plugins WHERE id = ?` (identical text, two call + * sites: `link`, `reload`). Returns the ROW, not the bare column: both callers need "no + * row" (`null`) told apart from "row present with `source_repo` unset" (`{ source_repo: + * null }`) — a plugin installed through the registry/sideload (never dev-linked) has a + * real row with a NULL `source_repo`, and both legacy call sites treat that as "already + * installed"/"not dev-linked", not as "not found". + */ + async findSourceRepoRow(id: string): Promise<{ source_repo: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['source_repo'] }); + return row ? { source_repo: row.source_repo ?? null } : null; + } + + // ----------------------------------------------------------------------- + // PR31 — dev-link install's cascade clear + // ----------------------------------------------------------------------- + + /** + * PR31 — `` UPDATE plugins SET source_repo = ?, source_commit = NULL, sha256 = NULL, + * author_pubkey = NULL, update_block_code = NULL, update_block_detail = NULL, + * update_block_version = NULL, status = 'inactive', enabled = 0 WHERE id = ? `` — the + * plugin has left the registry trust model, so every registry-trust column is cleared + * in the same statement that re-points `source_repo` to the dev-link marker. + */ + async clearForDevLink(id: string, sourceRepo: string): Promise { + await this.nativeUpdate( + { id }, + { + source_repo: sourceRepo, + source_commit: null, + sha256: null, + author_pubkey: null, + update_block_code: null, + update_block_detail: null, + update_block_version: null, + status: 'inactive', + enabled: 0, + }, + ); + } + + // ----------------------------------------------------------------------- + // PR33 — uninstall cascade's own row + // ----------------------------------------------------------------------- + + /** PR33 — `DELETE FROM plugins WHERE id = ?` (the uninstall cascade's first statement). */ + async deleteById(id: string): Promise { + await this.nativeDelete({ id }); + } + + // ----------------------------------------------------------------------- + // PR47 — the inter-plugin call/event router's grant check + // ----------------------------------------------------------------------- + + /** PR47 — `SELECT granted_permissions FROM plugins WHERE id = ?`. */ + async findGrantedPermissions(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['granted_permissions'] }); + return row?.granted_permissions ?? null; + } + + // ----------------------------------------------------------------------- + // PR48 / PR49 — dependsOnSatisfied (caller + target side) + // ----------------------------------------------------------------------- + + /** PR48 — `SELECT dependencies FROM plugins WHERE id = ?` (the caller side of `dependsOnSatisfied`). `null` return means "no row" — `dependsOnSatisfied` refuses on a missing caller the same as a missing target. */ + async findDependenciesRow(id: string): Promise<{ dependencies: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['dependencies'] }); + return row ? { dependencies: row.dependencies ?? null } : null; + } + + /** PR49 — `SELECT version FROM plugins WHERE id = ?` (the target side of `dependsOnSatisfied`). Distinct return shape from `findDependenciesColumn`: this one must tell "no row" apart from "row with a null version", which `dependsOnSatisfied` needs (a vanished target plugin refuses, a real one with no version falls back to `'0.0.0'` in the caller). */ + async findVersionRow(id: string): Promise<{ version: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['version'] }); + return row ? { version: row.version ?? null } : null; + } + + // ----------------------------------------------------------------------- + // PR51 — notificationChannels + // ----------------------------------------------------------------------- + + /** PR51 — `SELECT name, capabilities FROM plugins WHERE id = ?`. */ + async findNameAndCapabilities(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['name', 'capabilities'] }); + return row ? { name: row.name, capabilities: row.capabilities } : null; + } + + // ----------------------------------------------------------------------- + // PR52 / PR53 — capabilityList / mcpToolCapabilities + // ----------------------------------------------------------------------- + + /** PR52/PR53 — `SELECT capabilities FROM plugins WHERE id = ?` (identical text, two call sites: `capabilityList`, `mcpToolCapabilities`). */ + async findCapabilities(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['capabilities'] }); + return row?.capabilities ?? null; + } + + // ----------------------------------------------------------------------- + // PS-list — plugins.service.ts#list() + // ----------------------------------------------------------------------- + + /** + * PS-list — `plugins.service.ts#list()`'s own admin-listing SELECT: `SELECT id, name, + * description, type, icon, version, status, enabled, last_error, reviewed_at, + * source_repo, permissions, capabilities, dependencies, operator_egress, trek_range, + * author_pubkey, update_block_code, update_block_detail, update_block_version, + * update_hold FROM plugins ORDER BY sort_order, name`. + */ + async listForAdmin(): Promise { + const rows = await this.find( + {}, + { + fields: [ + 'id', 'name', 'description', 'type', 'icon', 'version', 'status', 'enabled', 'last_error', 'reviewed_at', 'source_repo', + 'permissions', 'capabilities', 'dependencies', 'operator_egress', 'trek_range', 'author_pubkey', + 'update_block_code', 'update_block_detail', 'update_block_version', 'update_hold', + ], + orderBy: [{ sort_order: 'asc' }, { name: 'asc' }], + }, + ); + return rows.map((r) => ({ + id: r.id ?? '', + name: r.name, + description: r.description ?? null, + type: r.type, + icon: r.icon ?? null, + version: r.version ?? null, + status: r.status, + enabled: r.enabled, + last_error: r.last_error ?? null, + reviewed_at: r.reviewed_at ?? null, + source_repo: r.source_repo ?? null, + permissions: r.permissions ?? '[]', + capabilities: r.capabilities, + dependencies: r.dependencies ?? null, + operator_egress: r.operator_egress, + trek_range: r.trek_range ?? null, + author_pubkey: r.author_pubkey ?? null, + update_block_code: r.update_block_code ?? null, + update_block_detail: r.update_block_detail ?? null, + update_block_version: r.update_block_version ?? null, + update_hold: r.update_hold, + })); + } + + // ----------------------------------------------------------------------- + // PS4 — resumeUpdates / clearUpdateHold + // ----------------------------------------------------------------------- + + /** PS4 — `UPDATE plugins SET update_hold = 0 WHERE id = ?`, returning whether a row existed (`.changes > 0`). */ + async clearUpdateHold(id: string): Promise { + const changed = await this.nativeUpdate({ id }, { update_hold: 0 }); + return changed > 0; + } + + // ----------------------------------------------------------------------- + // PS5 / PS13 — getInstanceConfig / updateInstanceConfig's config read + // ----------------------------------------------------------------------- + + /** PS5/PS13 — `SELECT config FROM plugins WHERE id = ?` (identical text, two call sites: `updateInstanceConfig`, `getInstanceConfig`). */ + async findConfig(id: string): Promise { + const row = await this.findOne({ id }, { fields: ['config'] }); + return row?.config ?? null; + } + + // ----------------------------------------------------------------------- + // PS6 — updateInstanceConfig's write / plugins.service's setConfig + // ----------------------------------------------------------------------- + + /** PS6 — `UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`. `config` already encrypted by the caller (3e's R6 pattern) — this repository never calls `encrypt_api_key`/`decrypt_api_key`. */ + async setConfig(id: string, config: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { config, updated_at: currentTimestamp(platform) }); + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — install/discovery.ts's manifest ingestion (DI2–DI4; + // DI2's existence check reuses `existsById` above) + // ----------------------------------------------------------------------- + + /** DI3/DI4 — the manifest-derived column set `discoverPlugins#upsert` writes on both the new-plugin INSERT and the existing-plugin UPDATE branches (the UPDATE branch is a strict subset — see `updateManifestFields`). */ + async insertManifest(m: PluginManifestRow): Promise { + await this.insert({ + id: m.id, + name: m.name, + description: m.description, + type: m.type, + icon: m.icon, + version: m.version, + api_version: m.api_version, + min_trek_version: m.min_trek_version, + trek_range: m.trek_range, + permissions: m.permissions, + capabilities: m.capabilities, + dependencies: m.dependencies, + operator_egress: m.operator_egress, + // '' (empty, not '[]') marks "never consented" so the first activation is + // distinguishable from a plugin consented to zero permissions. + granted_permissions: '', + status: 'inactive', + }); + } + + /** + * DI4 — `` UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, + * api_version = ?, min_trek_version = ?, trek_range = ?, permissions = ?, capabilities = ?, + * dependencies = ?, operator_egress = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? ``. + * Deliberately narrower than `insertManifest`'s column set — `status`/`granted_permissions`/ + * `config` are NEVER touched here, so a re-discovery of an already-installed plugin never + * silently re-activates it or wipes its settings/consent. + */ + async updateManifestFields(id: string, m: Omit): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + name: m.name, + description: m.description, + type: m.type, + icon: m.icon, + version: m.version, + api_version: m.api_version, + min_trek_version: m.min_trek_version, + trek_range: m.trek_range, + permissions: m.permissions, + capabilities: m.capabilities, + dependencies: m.dependencies, + operator_egress: m.operator_egress, + updated_at: currentTimestamp(platform), + }, + ); + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — signature-status.ts's setUpdateBlock/clearUpdateBlock (SG1/SG2) + // ----------------------------------------------------------------------- + + /** SG1 — `UPDATE plugins SET update_block_code = ?, update_block_detail = ?, update_block_version = ? WHERE id = ?` (`setUpdateBlock`, a signature-verification failure). */ + async setUpdateBlockColumns(id: string, code: string, detail: string, version: string | null): Promise { + await this.nativeUpdate({ id }, { update_block_code: code, update_block_detail: detail, update_block_version: version }); + } + + /** SG2 — `UPDATE plugins SET update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL WHERE id = ?` (`clearUpdateBlock`, the inverse — a successful install/update). */ + async clearUpdateBlockColumns(id: string): Promise { + await this.nativeUpdate({ id }, { update_block_code: null, update_block_detail: null, update_block_version: null }); + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 3 — registry/registry.service.ts's own statements (RG#). + // RG9 (`verifySignatureAndTofu`'s pinned-key read) reuses `findAuthorPubkey` + // above (identical text to PR28) rather than adding a duplicate method. + // ----------------------------------------------------------------------- + + /** RG2 (`install`'s post-discover provenance write) — `UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?`. */ + async setInstallProvenance(id: string, sourceRepo: string, sourceCommit: string, sha256: string, reviewedAt: string | null): Promise { + await this.nativeUpdate({ id }, { source_repo: sourceRepo, source_commit: sourceCommit, sha256, reviewed_at: reviewedAt }); + } + + /** RG3 (`install`'s TOFU pin) — `UPDATE plugins SET author_pubkey = ? WHERE id = ?`. Security-sensitive: only ever called with a key the artifact just verified under (see `registry.service.ts#install`'s own docstring) — never cleared to NULL by this method. */ + async setAuthorPubkey(id: string, authorPubkey: string): Promise { + await this.nativeUpdate({ id }, { author_pubkey: authorPubkey }); + } + + /** RG5 (`installWithDependencies`'s already-installed set) — `SELECT id FROM plugins`. */ + async listAllIds(): Promise { + const rows = await this.find({}, { fields: ['id'] }); + return rows.map((r) => r.id ?? ''); + } + + /** RG6 (`recomputeUpdateHold`) — `UPDATE plugins SET update_hold = ? WHERE id = ?`. */ + async setUpdateHold(id: string, hold: boolean): Promise { + await this.nativeUpdate({ id }, { update_hold: hold ? 1 : 0 }); + } + + /** + * RG8 (`commitUpload`, sideload step 2) — `` UPDATE plugins SET source_repo = ?, + * source_commit = NULL, sha256 = NULL, reviewed_at = NULL, author_pubkey = NULL, + * update_block_code = NULL, update_block_detail = NULL, update_block_version = NULL, + * status = 'inactive', enabled = 0 WHERE id = ? ``. The sideload twin of PR31's + * `clearForDevLink` above — same registry-trust-column clear, plus `reviewed_at` + * (a sideload was never registry-reviewed) — kept as its own method rather than + * widening `clearForDevLink`, since the two back separate legacy statements with + * different column sets, not one shared text. + */ + async clearForSideload(id: string, sourceRepo: string): Promise { + await this.nativeUpdate( + { id }, + { + source_repo: sourceRepo, + source_commit: null, + sha256: null, + reviewed_at: null, + author_pubkey: null, + update_block_code: null, + update_block_detail: null, + update_block_version: null, + status: 'inactive', + enabled: 0, + }, + ); + } + + /** RG11 (`assertRetrustable`) — `SELECT source_repo, author_pubkey FROM plugins WHERE id = ?`. */ + async findSourceRepoAndAuthorPubkey(id: string): Promise<{ source_repo: string | null; author_pubkey: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['source_repo', 'author_pubkey'] }); + return row ? { source_repo: row.source_repo ?? null, author_pubkey: row.author_pubkey ?? null } : null; + } + + // ----------------------------------------------------------------------- + // Plan 3j Task 5 — the small plugin controllers' own statements (PFC1, + // PUC1/POC1). PAC1/PRF1 (the audit-read/append delegates) are Task 3's + // `plugin-audit.ts` conversion, not this repository's. + // ----------------------------------------------------------------------- + + /** + * PFC1 (`plugins-feed.controller.ts#list`) — `SELECT id, name, type, icon, + * capabilities, granted_permissions FROM plugins WHERE status = 'active' + * ORDER BY sort_order, name`. This row goes to the CLIENT, not a plugin + * process — R-facade's `withCamelCase` wrapper is never applied here (the + * client already speaks snake_case per the wire contract, D1); stays + * exactly as read. + */ + async findActiveFeedRows(): Promise { + const rows = await this.find( + { status: 'active' }, + { fields: ['id', 'name', 'type', 'icon', 'capabilities', 'granted_permissions'], orderBy: [{ sort_order: 'asc' }, { name: 'asc' }] }, + ); + return rows.map((r) => ({ + id: r.id ?? '', + name: r.name, + type: r.type, + icon: r.icon ?? null, + capabilities: r.capabilities, + granted_permissions: r.granted_permissions ?? '', + })); + } + + /** + * PUC1/POC1 — `SELECT 1 FROM plugins WHERE id = ? AND status = 'active'`, + * byte-identical text, two call sites (`plugin-user-settings.controller.ts + * #activeWithUserFields`, `oauth/plugin-oauth.controller.ts#isActive`) — + * one shared method for both, existence-only. + */ + async existsActive(id: string): Promise { + const row = await this.findOne({ id, status: 'active' }, { fields: ['id'] }); + return row !== null; + } + + // ----------------------------------------------------------------------- + // PPS1 — plugin-pois.service.ts#available (#1781); PPS2 reuses PR52's + // findCapabilities. + // ----------------------------------------------------------------------- + + /** + * PPS1 (`plugin-pois.service.ts#available`, #1781) — `SELECT id, name, capabilities + * FROM plugins WHERE status = 'active' ORDER BY sort_order, name`. The explore + * pill's category list, in the order the feed lists plugins. PPS2 (`declared`) + * reuses `findCapabilities` (PR52's identical text). + */ + async listActiveNameCapabilities(): Promise { + const rows = await this.find( + { status: 'active' }, + { fields: ['id', 'name', 'capabilities'], orderBy: [{ sort_order: 'asc' }, { name: 'asc' }] }, + ); + return rows.map((r) => ({ id: r.id ?? '', name: r.name, capabilities: r.capabilities })); + } +} + +/** DI3/DI4 — the manifest-derived column set `discoverPlugins#upsert` writes on both the new-plugin INSERT and the existing-plugin UPDATE branches (the UPDATE branch is a strict subset — see `updateManifestFields`). */ +export interface PluginManifestRow { + id: string; + name: string; + description: string | null; + type: string; + icon: string; + version: string | null; + api_version: number; + min_trek_version: string | null; + trek_range: string | null; + permissions: string; + capabilities: string; + dependencies: string; + operator_egress: number; +} diff --git a/server/src/db/repositories/PushSubscriptions.repository.ts b/server/src/db/repositories/PushSubscriptions.repository.ts new file mode 100644 index 000000000..662d80ad2 --- /dev/null +++ b/server/src/db/repositories/PushSubscriptions.repository.ts @@ -0,0 +1,177 @@ +import type { PushSubscriptions } from '../entities/PushSubscriptions.entity'; +import { columnIncrementedBy, currentTimestamp, currentTimestampKysely } from '../dialect/sql-functions'; +import type { AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `push_subscriptions` row exactly as `SELECT *` reads it (Web Push, #894). */ +export interface PushSubscriptionRow { + id: number; + user_id: number; + endpoint: string; + p256dh: string; + auth: string; + vapid_public_key: string; + user_agent: string | null; + created_at: string; + last_success_at: string | null; + failure_count: number; +} + +const _pushSubscriptionRowKeys: AssertRowKeys = true; + +interface PushSubscriptionsKyselyDB { + push_subscriptions: PushSubscriptionRow; +} + +/** The columns PS1's INSERT binds; the rest come from their defaults. */ +interface PushSubscriptionsInsertKyselyDB { + push_subscriptions: { + user_id: number; + endpoint: string; + p256dh: string; + auth: string; + vapid_public_key: string; + user_agent: string | null; + created_at: string; + failure_count: number; + }; +} + +/** `push_subscriptions` — one row per browser a user switched Web Push on in. */ +export class PushSubscriptionsRepository extends TrekRepository { + private readDb() { + return this.kysely(); + } + + /** + * PS1 (`PushSubscriptionsService.upsert`) — + * ```sql + * INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, vapid_public_key, user_agent) + * VALUES (?, ?, ?, ?, ?, ?) + * ON CONFLICT(endpoint) DO UPDATE SET + * user_id = excluded.user_id, p256dh = excluded.p256dh, auth = excluded.auth, + * vapid_public_key = excluded.vapid_public_key, user_agent = excluded.user_agent, + * created_at = CURRENT_TIMESTAMP, failure_count = 0 + * ``` + * `created_at`/`failure_count` are bound on the insert half too, with the + * values their column defaults would give (`CURRENT_TIMESTAMP`, `0`), so the + * fresh row reads exactly as the legacy one did. + */ + async upsertSubscription(data: { + user_id: number; + endpoint: string; + p256dh: string; + auth: string; + vapid_public_key: string; + user_agent: string | null; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.kysely() + .insertInto('push_subscriptions') + .values({ ...data, created_at: currentTimestampKysely(platform), failure_count: 0 }) + .onConflict((oc) => + oc.column('endpoint').doUpdateSet({ + user_id: (eb) => eb.ref('excluded.user_id'), + p256dh: (eb) => eb.ref('excluded.p256dh'), + auth: (eb) => eb.ref('excluded.auth'), + vapid_public_key: (eb) => eb.ref('excluded.vapid_public_key'), + user_agent: (eb) => eb.ref('excluded.user_agent'), + created_at: () => currentTimestampKysely(platform), + failure_count: 0, + }), + ) + .execute(); + } + + /** + * PS2 (`PushSubscriptionsService.upsert`, the device cap) — + * ```sql + * DELETE FROM push_subscriptions WHERE id IN ( + * SELECT id FROM push_subscriptions WHERE user_id = ? AND endpoint <> ? + * ORDER BY created_at ASC, id ASC LIMIT ?) + * ``` + */ + async deleteOldestForUser(userId: number, keepEndpoint: string, limit: number): Promise { + await this.readDb() + .deleteFrom('push_subscriptions') + .where('id', 'in', (eb) => + eb + .selectFrom('push_subscriptions') + .select('id') + .where('user_id', '=', userId) + .where('endpoint', '<>', keepEndpoint) + .orderBy('created_at', 'asc') + .orderBy('id', 'asc') + .limit(limit), + ) + .execute(); + } + + /** PS3 (`removeForUser`) — `DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?`; answers whether a row went. */ + async deleteForUserEndpoint(userId: number, endpoint: string): Promise { + return (await this.nativeDelete({ user: userId, endpoint })) > 0; + } + + /** PS4 (`listForUser`) — `SELECT * FROM push_subscriptions WHERE user_id = ? ORDER BY id`. */ + async listForUser(userId: number): Promise { + return await this.readDb() + .selectFrom('push_subscriptions') + .selectAll() + .where('user_id', '=', userId) + .orderBy('id') + .execute(); + } + + /** PS5 (`countForUser`) — `SELECT COUNT(*) AS n FROM push_subscriptions WHERE user_id = ?`. */ + async countForUser(userId: number): Promise { + return await this.count({ user: userId }); + } + + /** PS6 (`hasAny`) — `SELECT 1 FROM push_subscriptions WHERE user_id = ? LIMIT 1`. */ + async hasAnyForUser(userId: number): Promise { + const row = await this.readDb() + .selectFrom('push_subscriptions') + .select('id') + .where('user_id', '=', userId) + .limit(1) + .executeTakeFirst(); + return row !== undefined; + } + + /** + * `DELETE FROM push_subscriptions WHERE user_id = ?` — a password change, a + * reset or an admin setting a password drops every device with the other + * credentials (AuthService, AdminService), since a device outlives every session. + */ + async deleteAllForUser(userId: number): Promise { + await this.nativeDelete({ user: userId }); + } + + /** PS7 (`deleteById`) — `DELETE FROM push_subscriptions WHERE id = ?`. */ + async deleteSubscription(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** PS8 (`recordSuccess`) — `UPDATE push_subscriptions SET last_success_at = CURRENT_TIMESTAMP, failure_count = 0 WHERE id = ?`. */ + async recordSuccess(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { last_success_at: currentTimestamp(platform), failure_count: 0 }); + } + + /** + * PS9 (`recordFailure`) — + * `UPDATE push_subscriptions SET failure_count = failure_count + 1 WHERE id = ? RETURNING failure_count`. + * One statement, so two sends at once cannot both see the old count. 0 when + * the row is gone already. + */ + async incrementFailureCount(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const result = await this.qb() + .update({ failure_count: columnIncrementedBy(platform, 'failure_count', 1) }) + .where({ id }) + .returning('failure_count') + .execute('run'); + const row = result.row as { failure_count?: number } | undefined; + return row?.failure_count ?? 0; + } +} diff --git a/server/src/db/repositories/ReservationDayPositions.repository.ts b/server/src/db/repositories/ReservationDayPositions.repository.ts new file mode 100644 index 000000000..0ac75b16d --- /dev/null +++ b/server/src/db/repositories/ReservationDayPositions.repository.ts @@ -0,0 +1,79 @@ +import type { ReservationDayPositions } from '../entities/ReservationDayPositions.entity'; +import { columnRef } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `reservation_day_positions`/`reservations`/`days`'s shape for RS31's Kysely upsert. */ +interface ReservationDayPositionsKyselyDB { + reservation_day_positions: { + reservation_id: number; + day_id: number; + position: number; + }; + reservations: { + id: number; + // `number | string`: the legacy statement raw-binds `tripId` exactly as + // the caller received it (D4's T5 escape hatch) — the Kysely column type + // here is widened to accept both without narrowing which non-canonical + // ids match, never actually persisted (this table has no `trip_id` + // column of its own). + trip_id: number | string; + }; + days: { + id: number; + trip_id: number | string; + }; +} + +export class ReservationDayPositionsRepository extends TrekRepository { + /** + * RS19 (`ReservationsService.list`) — `SELECT rdp.reservation_id, + * rdp.day_id, rdp.position FROM reservation_day_positions rdp JOIN + * reservations r ON rdp.reservation_id = r.id WHERE r.trip_id = ?`. The + * fold into `{[day_id]: position}` per reservation stays in the service. + */ + async listForTrip(trip_id: number): Promise<{ reservation_id: number; day_id: number; position: number }[]> { + const platform = this.getEntityManager().getPlatform(); + // `rdp.reservation_id`/`rdp.day_id` are `persist(false)` mirrors of the + // `reservation`/`day` relations — bare-selecting them alongside an + // active JOIN on `rdp.reservation` (aliased `r`) is the exact trap + // `DayAssignmentsRepository.assignmentWithPlaceSelect`'s `category_id` + // docstring documents (the column silently aliases off the JOINED + // entity instead); `columnRef` selects the literal physical column. + return this.qb('rdp') + .join('rdp.reservation', 'r') + .select([columnRef(platform, 'rdp.reservation_id').as('reservation_id'), columnRef(platform, 'rdp.day_id').as('day_id'), 'rdp.position']) + .where({ 'r.trip': trip_id }) + .execute<{ reservation_id: number; day_id: number; position: number }[]>('all', false); + } + + /** + * RS31 (`ReservationsService.updatePositions`'s day branch) — `INSERT OR + * REPLACE INTO reservation_day_positions (reservation_id, day_id, + * position) SELECT r.id, d.id, ? FROM reservations r JOIN days d ON + * d.trip_id = r.trip_id WHERE r.id = ? AND d.id = ? AND r.trip_id = ?`. + * + * Kysely `insertInto().orReplace().columns().expression(selectFrom…)` + * (the MikroORM QueryBuilder's `insertFrom` has no `OR REPLACE` flag — + * `.onConflict().merge()` is semantically different, a real UPDATE rather + * than REPLACE's delete-then-insert). The trip scoping lives IN the + * SELECT (a foreign `reservation_id`/`day_id` materialises zero rows to + * insert, never a foreign-key error) — preserved exactly, not hoisted + * into a pre-check. + */ + async upsertScoped(trip_id: number | string, reservation_id: number, day_id: number, position: number): Promise { + await this.kysely() + .insertInto('reservation_day_positions') + .orReplace() + .columns(['reservation_id', 'day_id', 'position']) + .expression((eb) => + eb + .selectFrom('reservations as r') + .innerJoin('days as d', 'd.trip_id', 'r.trip_id') + .select(['r.id as reservation_id', 'd.id as day_id', eb.val(position).as('position')]) + .where('r.id', '=', reservation_id) + .where('d.id', '=', day_id) + .where('r.trip_id', '=', trip_id), + ) + .execute(); + } +} diff --git a/server/src/db/repositories/ReservationEndpoints.repository.ts b/server/src/db/repositories/ReservationEndpoints.repository.ts new file mode 100644 index 000000000..9907e5cad --- /dev/null +++ b/server/src/db/repositories/ReservationEndpoints.repository.ts @@ -0,0 +1,286 @@ +import type { ReservationEndpoints } from '../entities/ReservationEndpoints.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { columnRef } from '../dialect/sql-functions'; +import { travelerOwnsExpr, type ReservationTravelersOwnsKyselyDB } from './_shared/reservation-travelers-owns'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `reservation_endpoints` row exactly as `r.*`/`SELECT *` read it (every scalar column). */ +export interface ReservationEndpointRow { + id: number; + reservation_id: number; + role: string; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; + timezone: string | null; + local_time: string | null; + local_date: string | null; + created_at: string | null; +} + +const _reservationEndpointRowKeys: AssertRowKeys = true; + +export class ReservationEndpointsRepository extends TrekRepository { + /** + * RS3 (`ReservationsService.loadEndpointsByTrip`) — `SELECT e.* FROM + * reservation_endpoints e JOIN reservations r ON e.reservation_id = r.id + * WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`. The + * per-reservation grouping into a `Map` stays in the service (JS, not SQL). + */ + async listForTrip(trip_id: number): Promise { + return this.qb('e') + .join('e.reservation', 'r') + .select(['e.*']) + .where({ 'r.trip': trip_id }) + .orderBy({ 'e.reservation': 'asc', 'e.sequence': 'asc' }) + .execute('all', false); + } + + /** + * RR2 (`ReservationsReadService.loadEndpoints`) — `SELECT * FROM + * reservation_endpoints WHERE reservation_id = ? ORDER BY sequence`. + */ + async listForReservation(reservation_id: number): Promise { + return this.qb('e') + .select(['e.*']) + .where({ reservation: reservation_id }) + .orderBy({ sequence: 'asc' }) + .execute('all', false); + } + + /** RS16 (`ReservationsService.saveEndpoints`'s replace-all) — `DELETE FROM reservation_endpoints WHERE reservation_id = ?`. */ + async deleteForReservation(reservation_id: number): Promise { + await this.nativeDelete({ reservation: reservation_id }); + } + + /** + * RS17 — `INSERT INTO reservation_endpoints (reservation_id, role, + * sequence, name, code, lat, lng, timezone, local_time, local_date) VALUES + * (?×10)`, one row per call (the legacy `prepare` + per-row `run`). The + * lat/lng-null skip and the `sequence ?? i` (index AFTER the filter) stay + * in the service — this writes exactly the row it is handed. + */ + async insertEndpoint(input: { + reservation_id: number; + role: string; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; + timezone: string | null; + local_time: string | null; + local_date: string | null; + }): Promise { + return await this.insert({ + reservation: input.reservation_id, + role: input.role, + sequence: input.sequence, + name: input.name, + code: input.code, + lat: input.lat, + lng: input.lng, + timezone: input.timezone, + local_time: input.local_time, + local_date: input.local_date, + }); + } + + /** DY17 (`DaysService.restampReservationDates`) — `SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?`. */ + async listIdAndDate(reservation_id: number): Promise<{ id: number; local_date: string | null }[]> { + return this.qb('e') + .select(['e.id', 'e.local_date']) + .where({ reservation: reservation_id }) + .execute<{ id: number; local_date: string | null }[]>('all', false); + } + + /** DY18 — `UPDATE reservation_endpoints SET local_date = ? WHERE id = ?`. */ + async setLocalDate(id: number, local_date: string): Promise { + await this.nativeUpdate({ id }, { local_date }); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 4 (`airtrail-import.service.ts`) — additive, per this task's + // own file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * ATI3 (`airtrail-import.service.ts#importAirtrailFlights`) — `SELECT + * e.reservation_id, e.code, e.local_date, e.sequence FROM + * reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id + * WHERE r.trip_id = ? AND r.type = 'flight' ORDER BY e.sequence`. Distinct + * from {@link listForTrip} above (RS3): a narrower column list, a + * `r.type = 'flight'` filter and a single-key `ORDER BY e.sequence` (not + * `e.reservation_id, e.sequence`) — a genuinely different statement, per + * D4. + */ + async listFlightEndpointsForTrip(trip_id: number): Promise<{ reservation_id: number; code: string | null; local_date: string | null; sequence: number }[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('e') + .join('e.reservation', 'r') + .select([columnRef(platform, 'e.reservation_id').as('reservation_id'), 'e.code', 'e.local_date', 'e.sequence']) + .where({ 'r.trip': trip_id, 'r.type': 'flight' }) + .orderBy({ sequence: 'asc' }) + .execute<{ reservation_id: number; code: string | null; local_date: string | null; sequence: number }[]>('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 1 (`AtlasService`) — additive, append-only per that task's + // own file-ownership rule. AT6/AT45/AT46 all consume Task 0's shared + // `travelerOwnsExpr` (`_shared/reservation-travelers-owns.ts`, R7) rather + // than re-deriving the `TRAVELER_OWNS` fragment — one predicate, three + // call sites, per the plan's own instruction. + // --------------------------------------------------------------------------- + + /** + * AT6 (`AtlasService#stats`, countries reached only by a flight — + * #1366/#1486/#1535/#1966) — `SELECT DISTINCT e.id, e.reservation_id, + * r.trip_id, e.role, e.code, e.lat, e.lng, e.local_date, e.local_time, + * r.type AS reservation_type, r.status AS reservation_status, CASE e.role + * WHEN 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time) + * ELSE COALESCE(r.reservation_time, r.reservation_end_time) END AS + * fallback_time FROM reservation_endpoints e JOIN reservations r ON + * e.reservation_id = r.id WHERE r.trip_id IN (...) AND e.role IN ('from', + * 'to') AND ${TRAVELER_OWNS}`. Dynamic `IN`, empty-array short-circuit — + * the caller (`getUserTrips`'s own tripIds) already guards the zero-trip + * case before this ever runs, but the guard is repeated here to match + * every other dynamic-`IN` site in this cluster (AT2/AT3/AT23/AT28). + */ + async listOwnedEndpointsForTrips(tripIds: number[], userId: number): Promise { + if (tripIds.length === 0) return []; + const rows = await this.kysely() + .selectFrom('reservation_endpoints as e') + .innerJoin('reservations as r', 'r.id', 'e.reservation_id') + .select((eb) => [ + 'e.id', 'e.reservation_id', 'r.trip_id', 'e.role', 'e.code', 'e.lat', 'e.lng', 'e.local_date', 'e.local_time', + 'r.type as reservation_type', 'r.status as reservation_status', + eb.case().when('e.role', '=', 'to').then(eb.fn.coalesce('r.reservation_end_time', 'r.reservation_time')).else(eb.fn.coalesce('r.reservation_time', 'r.reservation_end_time')).end().as('fallback_time'), + ]) + .distinct() + .where('r.trip_id', 'in', tripIds) + .where('e.role', 'in', ['from', 'to']) + .where((eb) => travelerOwnsExpr(eb, userId)) + .execute(); + return rows as TravelerOwnedEndpointRow[]; + } + + /** + * AT45 (`AtlasService#getTravelStats`) — the widest single statement in + * this plan: `SELECT DISTINCT e.id, e.reservation_id, r.trip_id, e.role, + * e.code, e.lat, e.lng, e.local_date, e.local_time, r.type AS + * reservation_type, r.status AS reservation_status, CASE e.role WHEN + * 'to' THEN COALESCE(r.reservation_end_time, r.reservation_time) ELSE + * COALESCE(r.reservation_time, r.reservation_end_time) END AS + * fallback_time FROM reservation_endpoints e JOIN reservations r ON + * e.reservation_id = r.id JOIN trips t ON r.trip_id = t.id LEFT JOIN + * trip_members tm ON t.id = tm.trip_id WHERE (t.user_id = ? OR tm.user_id + * = ?) AND e.role IN ('from', 'to') AND COALESCE(t.start_date, t.end_date) + * IS NOT NULL AND COALESCE(t.start_date, t.end_date) <= date('now') AND + * ${TRAVELER_OWNS}`. `today` resolved by the caller once (`todayUtc()`), + * the `TripsRepository.activeTrip`/`lastStartedTrip` precedent. + */ + async listOwnedEndpointsForUser(userId: number, today: string): Promise { + const rows = await this.kysely() + .selectFrom('reservation_endpoints as e') + .innerJoin('reservations as r', 'r.id', 'e.reservation_id') + .innerJoin('trips as t', 't.id', 'r.trip_id') + .leftJoin('trip_members as tm', 'tm.trip_id', 't.id') + .select((eb) => [ + 'e.id', 'e.reservation_id', 'r.trip_id', 'e.role', 'e.code', 'e.lat', 'e.lng', 'e.local_date', 'e.local_time', + 'r.type as reservation_type', 'r.status as reservation_status', + eb.case().when('e.role', '=', 'to').then(eb.fn.coalesce('r.reservation_end_time', 'r.reservation_time')).else(eb.fn.coalesce('r.reservation_time', 'r.reservation_end_time')).end().as('fallback_time'), + ]) + .distinct() + .where((eb) => eb.or([eb('t.user_id', '=', userId), eb('tm.user_id', '=', userId)])) + .where('e.role', 'in', ['from', 'to']) + .where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), 'is not', null)) + .where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), '<=', today)) + .where((eb) => travelerOwnsExpr(eb, userId)) + .execute(); + return rows as TravelerOwnedEndpointRow[]; + } + + /** + * AT46 (`AtlasService#flightDistanceKm`) — `SELECT re.reservation_id, + * re.lat, re.lng FROM reservation_endpoints re JOIN reservations r ON + * r.id = re.reservation_id JOIN trips t ON t.id = r.trip_id LEFT JOIN + * trip_members tm ON tm.trip_id = t.id AND tm.user_id = ? WHERE + * (t.user_id = ? OR tm.user_id IS NOT NULL) AND r.type = 'flight' AND + * r.status != 'cancelled' AND ${TRAVELER_OWNS} ORDER BY re.reservation_id, + * re.sequence`. The haversine summing across ordered legs stays in the + * SERVICE (unchanged JS). + */ + async listOwnedFlightLegsForUser(userId: number): Promise<{ reservation_id: number; lat: number; lng: number }[]> { + return await this.kysely() + .selectFrom('reservation_endpoints as re') + .innerJoin('reservations as r', 'r.id', 're.reservation_id') + .innerJoin('trips as t', 't.id', 'r.trip_id') + .leftJoin('trip_members as tm', (join) => join.onRef('tm.trip_id', '=', 't.id').on('tm.user_id', '=', userId)) + .select(['re.reservation_id', 're.lat', 're.lng']) + .where((eb) => eb.or([eb('t.user_id', '=', userId), eb('tm.user_id', 'is not', null)])) + .where('r.type', '=', 'flight') + .where('r.status', '!=', 'cancelled') + .where((eb) => travelerOwnsExpr(eb, userId)) + .orderBy('re.reservation_id', 'asc') + .orderBy('re.sequence', 'asc') + .execute(); + } + + /** + * RPL6 (`RoadtripPlanService.withTerminals`) — `SELECT e.reservation_id, + * e.role, e.sequence, e.name, e.code, e.lat, e.lng FROM + * reservation_endpoints e JOIN reservations r ON r.id = e.reservation_id + * WHERE r.trip_id = ? ORDER BY e.reservation_id, e.sequence`. Same join and + * order as {@link listForTrip} (RS3) but its own column list, per D4. + */ + async listRoadtripTerminals(trip_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.qb('e') + .join('e.reservation', 'r') + .select([columnRef(platform, 'e.reservation_id').as('reservation_id'), 'e.role', 'e.sequence', 'e.name', 'e.code', 'e.lat', 'e.lng']) + .where({ 'r.trip': trip_id }) + .orderBy({ 'e.reservation': 'asc', 'e.sequence': 'asc' }) + .execute('all', false); + } +} + +/** RPL6's row: a terminal of a booking the road trip's seam reads, keyed by its reservation. */ +export interface RoadtripTerminalRow { + reservation_id: number; + role: string; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; +} + +/** AT6/AT45's shared projection — `EndpointRow`'s own shape in `atlas.service.ts` (kept independent here so this repository doesn't import a `nest/` type). */ +export interface TravelerOwnedEndpointRow { + id: number; + reservation_id: number; + trip_id: number; + role: string; + code: string | null; + lat: number; + lng: number; + local_date: string | null; + local_time: string | null; + reservation_type: string | null; + reservation_status: string | null; + fallback_time: string | null; +} + +/** {@link ReservationEndpointsRepository.listOwnedEndpointsForTrips}'s narrow `reservation_endpoints`/`reservations`/`reservation_travelers` shape (`r` aliased for `travelerOwnsExpr`, R7). */ +interface TravelerOwnedEndpointsKyselyDB extends ReservationTravelersOwnsKyselyDB { + reservation_endpoints: { id: number; reservation_id: number; role: string; sequence: number; code: string | null; lat: number; lng: number; local_date: string | null; local_time: string | null }; + reservations: ReservationTravelersOwnsKyselyDB['reservations'] & { trip_id: number; type: string | null; status: string | null; reservation_time: string | null; reservation_end_time: string | null }; +} + +/** {@link ReservationEndpointsRepository.listOwnedEndpointsForUser}/{@link ReservationEndpointsRepository.listOwnedFlightLegsForUser}'s narrow shape, adding `trips`/`trip_members` to {@link TravelerOwnedEndpointsKyselyDB}. */ +interface TravelerOwnedEndpointsWithTripsKyselyDB extends TravelerOwnedEndpointsKyselyDB { + trips: { id: number; user_id: number; start_date: string | null; end_date: string | null }; + trip_members: { trip_id: number; user_id: number }; +} diff --git a/server/src/db/repositories/ReservationTravelers.repository.ts b/server/src/db/repositories/ReservationTravelers.repository.ts new file mode 100644 index 000000000..c72e39de7 --- /dev/null +++ b/server/src/db/repositories/ReservationTravelers.repository.ts @@ -0,0 +1,77 @@ +import type { ReservationTravelers } from '../entities/ReservationTravelers.entity'; +import { coalesce, columnRef } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A traveler row as RS6/RR3 project it (before `toTraveler()`'s `avatar_url` add). */ +export interface TravelerJoinRow { + reservation_id: number; + user_id: number; + username: string; + avatar: string | null; + is_guest: number | null; +} + +export class ReservationTravelersRepository extends TrekRepository { + /** + * RS6 (`ReservationsService.loadTravelersByTrip`) — `SELECT + * rt.reservation_id, rt.user_id, COALESCE(u.display_name, u.username) AS + * username, u.avatar, u.is_guest FROM reservation_travelers rt JOIN + * reservations r ON rt.reservation_id = r.id JOIN users u ON rt.user_id = + * u.id WHERE r.trip_id = ? ORDER BY rt.reservation_id`. + */ + async listForTrip(trip_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + // `rt.reservation_id`/`rt.user_id` are `persist(false)` mirrors of the + // `reservation`/`user` relations, both ALSO actively joined here + // (aliased `r`/`u`) — the exact trap `DayAssignmentsRepository + // .assignmentWithPlaceSelect`'s `category_id` docstring documents + // (a bare select silently aliases off the joined entity instead); + // `columnRef` selects the literal physical columns. + return this.qb('rt') + .join('rt.reservation', 'r') + .join('rt.user', 'u') + .select([columnRef(platform, 'rt.reservation_id').as('reservation_id'), columnRef(platform, 'rt.user_id').as('user_id'), coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.avatar', 'u.is_guest']) + .where({ 'r.trip': trip_id }) + .orderBy({ 'rt.reservation': 'asc' }) + .execute('all', false); + } + + /** + * RR3 (`ReservationsReadService.loadTravelers`) — `SELECT rt.user_id, + * COALESCE(u.display_name, u.username) AS username, u.avatar, u.is_guest + * FROM reservation_travelers rt JOIN users u ON rt.user_id = u.id WHERE + * rt.reservation_id = ?` — **no ORDER BY** (unlike `listForTrip`), matching + * the legacy statement's own row order (SQLite's). + */ + async listForReservation(reservation_id: number): Promise[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('rt') + .join('rt.user', 'u') + .select([columnRef(platform, 'rt.user_id').as('user_id'), coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.avatar', 'u.is_guest']) + .where({ reservation: reservation_id }) + .execute[]>('all', false); + } + + /** RS8 (`ReservationsService.setReservationTravelers`'s replace-all) — `DELETE FROM reservation_travelers WHERE reservation_id = ?`. */ + async deleteForReservation(reservation_id: number): Promise { + await this.nativeDelete({ reservation: reservation_id }); + } + + /** + * RS9 — `INSERT OR IGNORE INTO reservation_travelers (reservation_id, + * user_id) VALUES (?, ?)`, run once per id. `upsertMany` with + * `onConflictAction: 'ignore'` on the table's real unique key + * (`UNIQUE(reservation_id, user_id)`, `ReservationTravelers.entity.ts`'s + * `uniques`), named by entity property (`reservation`/`user`, the + * `AssignmentParticipantsRepository.insertIgnore` precedent). The caller + * roster-scopes and dedupes the ids before calling; the empty-array guard + * here is defensive, matching the service's own guard. + */ + async insertIgnore(reservation_id: number, user_ids: number[]): Promise { + if (user_ids.length === 0) return; + await this.upsertMany( + user_ids.map((user_id) => ({ reservation: reservation_id, user: user_id })), + { onConflictFields: ['reservation', 'user'], onConflictAction: 'ignore' }, + ); + } +} diff --git a/server/src/db/repositories/Reservations.repository.ts b/server/src/db/repositories/Reservations.repository.ts new file mode 100644 index 000000000..96c8781fd --- /dev/null +++ b/server/src/db/repositories/Reservations.repository.ts @@ -0,0 +1,1756 @@ +import type { Reservations } from '../entities/Reservations.entity'; +import { DayAssignments } from '../entities/DayAssignments.entity'; +import { Days } from '../entities/Days.entity'; +import { castIntegerKysely, coalesceOverride, columnRef, concatKysely, dayDistance, startsWithIsoDateKysely, substringKysely } from '../dialect/sql-functions'; +import { publicReservationExpr, publicStayExists, type ReservationVisibilityKyselyDB } from './_shared/reservation-visibility'; +import type { DayAssignmentRow } from './DayAssignments.repository'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * RS18/RR1's shared joined projection — `SELECT r.*, d.day_number, p.name as + * place_name, r.assignment_id, ap.place_id as accommodation_place_id, + * acc_p.name as accommodation_name, ap.start_day_id as + * accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id FROM + * reservations r LEFT JOIN days d ON r.day_id = d.id LEFT JOIN places p ON + * r.place_id = p.id LEFT JOIN day_accommodations ap ON r.accommodation_id = + * ap.id LEFT JOIN places acc_p ON ap.place_id = acc_p.id`. The service + * folds `accommodation_id` (`Math.trunc(Number(x))`), `day_positions`, + * `endpoints` and `travelers` on afterward (unchanged from the legacy split). + */ +export interface ReservationJoinRow { + id: number; + trip_id: number; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + title: string; + accommodation_id: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + status: string | null; + type: string | null; + created_at: string | null; + metadata: string | null; + day_plan_position: number | null; + needs_review: number; + external_source: string | null; + external_id: string | null; + external_owner_user_id: number | null; + external_synced_at: string | null; + sync_enabled: number | null; + external_hash: string | null; + url: string | null; + ingest_state: string; + day_number: number | null; + place_name: string | null; + accommodation_place_id: number | null; + accommodation_name: string | null; + accommodation_start_day_id: number | null; + accommodation_end_day_id: number | null; +} + +/** + * Kysely typing for the RS18/RR1 join (`joinedQuery` below) — `reservations + * .accommodation_id` is a bare `p.text().nullable()` column with no FK to + * `day_accommodations` (§18.1 of the inventory), so the QueryBuilder's + * relation-path `.join()` cannot express `LEFT JOIN day_accommodations ap ON + * r.accommodation_id = ap.id` at all — the same escape hatch + * `DayAssignmentsRepository.effectiveStart`/`TripsRepository.tripSelectQuery` + * use. `id`/`trip_id` stay `number | string` (raw-bind, D4's T5 seam): a + * Kysely `.where(col, '=', value)` call is a TYPED condition regardless of + * how loosely its value is typed (program rule 23 bans a raw SQL-text + * condition string, not a loosely-typed bound value), so this join keeps the + * legacy statement's exact raw-bind flexibility. + */ +interface ReservationJoinKyselyDB { + reservations: { + id: number | string; + trip_id: number | string; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + title: string; + accommodation_id: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + status: string | null; + type: string | null; + created_at: string | null; + metadata: string | null; + day_plan_position: number | null; + needs_review: number; + external_source: string | null; + external_id: string | null; + external_owner_user_id: number | null; + external_synced_at: string | null; + sync_enabled: number | null; + external_hash: string | null; + url: string | null; + ingest_state: string; + }; + days: { id: number; day_number: number }; + places: { id: number; name: string }; + day_accommodations: { id: number; place_id: number | null; start_day_id: number; end_day_id: number }; +} + +/** + * Kysely typing for `restampLinkedReservation` (DY23). `accommodation_id: + * string | number | null` — R2's ruling: the legacy statement binds `accId` + * (a plain JS `number`, `stay.id`) straight into the `:accId` named param + * compared against the TEXT column, so this method's caller passes the SAME + * number, unconverted, and the interface is widened only so that bind + * type-checks (SQLite applies its own TEXT-affinity conversion at compare + * time regardless of which JS type bound the value — see the method's own + * docstring). + */ +interface ReservationRestampKyselyDB { + reservations: { + id: number; + accommodation_id: string | number | null; + type: string | null; + day_id: number | null; + reservation_time: string | null; + }; +} + +/** + * Kysely typing for AC37/AC40 (`listIdMetadataByStay`/`listIdsByStay`). + * + * **Revert to the REAL-bound compare (Plan 3d Task 7 whole-plan review, M3; + * corrects this docstring's earlier claim).** A bound-parameter `WHERE + * accommodation_id = ?` with a plain JS `number` is EXACTLY the comparison + * the legacy statement's own `Number(id)` bind produced: `better-sqlite3` + * binds every plain JS number as SQLite REAL, integer-valued or not + * (`typeof(?)` on a bound `14` reads back `'real'`) — including through + * MikroORM's Kysely dialect — and SQLite's TEXT-affinity conversion of a + * REAL for a comparison renders its decimal form (`14` → `'14.0'`), so a + * bound `WHERE accommodation_id = ?` matches `'14.0'` ONLY, same as the + * legacy statement — never `'14'`. The earlier version of this docstring + * claimed the opposite (that `em.insert()`/`nativeUpdate()` calls in this + * cluster store the bare `'14'` shape and a REAL-bound compare would + * therefore miss them): that was true only because RS28/RS41 + * (`reservations.service.ts`, outside this repository) used to write + * `String(n)` instead of the legacy's REAL-bound shape (H1, the same + * review). H1 fixes every write in this cluster to store `'.0'` + * (`legacyBoundIntegerText`), so the widening below has no remaining + * reason to exist, and parity is law once it doesn't: `CAST(accommodation_id + * AS INTEGER) = ?` (via {@link castIntegerKysely}) matches `'14'`, `'14.0'`, + * `'14 '` and `'14abc'` alike — a WIDER row set than the legacy's own + * `'14.0'`-only compare (measured, task-7-review.md M3) — which is an + * unpinned parity break, not a fix, on a read the legacy also bound + * `Number(id)` for. `castIntegerKysely` stays in use elsewhere in this file + * (RS20/RV2's correlated subqueries) where the legacy statement's OWN + * comparison used a `CAST`, not a bound number — this interface's two + * consumers are not that case. + */ +interface ReservationsByAccommodationKyselyDB { + reservations: { + id: number; + // `string | number | null`, not just `string` (R2's ruling, the same + // widening {@link ReservationRestampKyselyDB} documents for DY23): the + // column is TEXT, but the bind below is a plain `number`, matching the + // legacy's own `Number(id)` bind — the interface only needs to + // type-check that bind, not describe the column's storage type. + accommodation_id: string | number | null; + metadata: string | null; + }; +} + +// --------------------------------------------------------------------------- +// Plan 3d Task 4 (calendar, `listUpcoming`, the visibility-predicate +// consumers incl. `share`, the `public-api` pickup) — additive. `calendar/**` +// and `share/**`'s reads on `day_accommodations`/`reservation_endpoints`/ +// `reservation_day_positions` land here rather than on those tables' own +// repositories: `RoadtripVias`/`RoadtripDayTracks`/`RoadtripPreferences`/ +// `RoadtripDayBoundaries`/`DayAccommodations`/`ReservationEndpoints`/ +// `ReservationTravelers` repositories are Task 6's file set for this window +// (trip copy's additive methods), so a cross-table Kysely read — the SAME +// escape hatch `joinedQuery()`/`getAssignmentTripId`/`findNearestDayId` +// above already use to reach `days`/`places`/`day_accommodations` from this +// file — lands the read here instead of opening a second implementer's file. +// `DayAssignments.repository.ts` is not in Task 6's set, but CL4's read is +// kept alongside its RS20/CL2/CL7 siblings for the same "one file, one +// diff" reason. +// --------------------------------------------------------------------------- + +/** + * CL2 (`CalendarService.buildTripCalendar`'s reservation read) — `SELECT + * r.*, pl.lat AS place_lat, pl.lng AS place_lng, sd.date AS stay_start_date, + * ed.date AS stay_end_date, a.check_in AS stay_check_in, a.check_out AS + * stay_check_out, (SELECT MIN(r2.id) FROM reservations r2 WHERE + * r2.accommodation_id = a.id) AS stay_first_reservation_id, rd.date AS + * day_date, red.date AS end_day_date FROM reservations r LEFT JOIN places + * pl … LEFT JOIN day_accommodations a ON r.accommodation_id = a.id LEFT + * JOIN days sd/ed/rd/red … WHERE r.trip_id = ? AND `, no `ORDER + * BY`. `r.accommodation_id = a.id` and `r2.accommodation_id = a.id` are + * plain column-to-column joins (no `CAST`) — SQLite applies numeric + * affinity to the TEXT side against a genuine INTEGER column on its own + * (§18.1), the same reasoning `joinedQuery()`'s `ap`/`acc_p` joins above + * already rely on; a `CAST` is only needed where one side is a BOUND value + * (RV2, RS20's correlated title subquery). The `stay_first_reservation_id` + * subquery is deliberately **not** filtered by RV1 — inventory §18.2's + * documented, unfixed hole (a staged sibling booking with a lower id can + * win the slot and suppress a public booking's all-day block); kept + * byte-for-byte, pinned by CAL-CL2-HOLE-001 in the repository test. + */ +export interface CalendarReservationRow extends ReservationAllColumnsRow { + place_lat: number | null; + place_lng: number | null; + stay_start_date: string | null; + stay_end_date: string | null; + stay_check_in: string | null; + stay_check_out: string | null; + stay_first_reservation_id: number | null; + day_date: string | null; + end_day_date: string | null; +} + +interface CalendarReservationKyselyDB { + reservations: ReservationJoinKyselyDB['reservations']; + places: { id: number; lat: number | null; lng: number | null }; + day_accommodations: { id: number; start_day_id: number | null; end_day_id: number | null; check_in: string | null; check_out: string | null }; + days: { id: number; date: string | null }; +} + +/** + * CL4 (`CalendarService.buildTripCalendar`'s per-day assignment read, + * previously run once per dated day) — `SELECT da.*, p.name as place_name, + * p.address as place_address, p.lat as place_lat, p.lng as place_lng, + * COALESCE(da.assignment_time, p.place_time) as effective_time, + * COALESCE(da.assignment_end_time, p.end_time) as effective_end_time FROM + * day_assignments da JOIN places p ON da.place_id = p.id WHERE da.day_id = + * ? AND da.accommodation_id IS NULL ORDER BY da.order_index ASC, + * da.created_at ASC` — the booked-night stop (its own accommodation-linked + * `day_assignments` row) is excluded, matching the legacy comment ("that + * stop is the booking … reading it here as well would put the hotel on the + * day a second time"). Kept per-day (the caller's own loop), not batched — + * result-identical either way per the inventory's own note; per-day is the + * smaller diff against the legacy statement shape. + */ +export interface CalendarStopRow extends DayAssignmentRow { + place_name: string; + place_address: string | null; + place_lat: number | null; + place_lng: number | null; + effective_time: string | null; + effective_end_time: string | null; +} + +interface CalendarStopsKyselyDB { + day_assignments: { + id: number; day_id: number; place_id: number; order_index: number | null; notes: string | null; + reservation_status: string | null; reservation_notes: string | null; reservation_datetime: string | null; + created_at: string | null; assignment_time: string | null; assignment_end_time: string | null; + leg_transport_mode: string | null; incoming_leg_transport_mode: string | null; end_day: number; accommodation_id: number | null; + }; + places: { id: number; name: string; address: string | null; lat: number | null; lng: number | null; place_time: string | null; end_time: string | null }; +} + +/** + * CL7 (`CalendarService.buildTripCalendar`'s check-in/check-out stay read) + * — `SELECT a.id, a.check_in, a.check_in_end, a.check_out, sd.date AS + * start_date, ed.date AS end_date, p.name AS place_name, p.address AS + * place_address, p.lat AS place_lat, p.lng AS place_lng, (SELECT r.title + * FROM reservations r WHERE r.accommodation_id = a.id AND ORDER + * BY r.id ASC LIMIT 1) AS reservation_title FROM day_accommodations a LEFT + * JOIN days sd … LEFT JOIN days ed … LEFT JOIN places p … WHERE a.trip_id = + * ? AND ORDER BY a.id ASC`. The `reservation_title` subquery is + * a plain column-to-column compare too (no `CAST`), same reasoning as CL2. + * `.where('a.trip_id', ...).where(publicStayExists)` are chained BEFORE the + * `sd`/`ed`/`p` joins below (Kysely renders WHERE after JOIN regardless of + * call order — the AST, not the call sequence, decides clause order) so + * `publicStayExists`'s narrow `ExpressionBuilder<…, 'a'>` signature sees + * exactly the `a` alias it requires (Task 0 review's own finding: a + * differently-joined outer query typechecks as long as the alias stays + * `a`). + */ +export interface CalendarStayRow { + id: number; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + start_date: string | null; + end_date: string | null; + place_name: string | null; + place_address: string | null; + place_lat: number | null; + place_lng: number | null; + reservation_title: string | null; +} + +interface CalendarStayKyselyDB extends ReservationVisibilityKyselyDB { + day_accommodations: { + id: number; trip_id: number | string; place_id: number | null; start_day_id: number | null; end_day_id: number | null; + check_in: string | null; check_in_end: string | null; check_out: string | null; + }; + reservations: ReservationVisibilityKyselyDB['reservations'] & { title: string }; + days: { id: number; date: string | null }; + places: { id: number; name: string | null; address: string | null; lat: number | null; lng: number | null }; +} + +/** + * RS20 (`ReservationsService.listUpcoming`) — the CTE + `UNION ALL` + * statement, inventory §2e. Ruling (ii): ONE fully typed Kysely statement + * (no `connection.execute()` exception, no JS merge/re-sort — the reviewer's + * prototype matched legacy rows across 5 `(today, now, limit)` settings + * incl. the cross-table `id` tiebreak, and the plan adopted that). GLOB + * through `startsWithIsoDateKysely`; `CAST` through `castIntegerKysely` + * (the correlated title subquery compares a BOUND `a.id` — an outer-query + * value, not a plain column-to-column join, unlike CL2/CL7's `MIN`/title + * subqueries — so the legacy statement casts here too: + * `CAST(res.accommodation_id AS INTEGER) = a.id`); `||` through + * `concatKysely`; `substr` through `substringKysely`. `LIMIT` stays inside + * SQL; the SQLite collation `ORDER BY at_date ASC, COALESCE(at_time, + * '00:00') ASC, id ASC` (the cross-table `id` tiebreak — a stay id and a + * reservation id can collide) is reproduced exactly, never a JS `.sort()`. + * + * Each of the three `entries` arms is `$castTo()`'d before the + * `unionAll` chain: Kysely infers a literal type (`'checkin'`) for + * `eb.val('checkin').as('type')` in arms 2/3 against `r.type: string | + * null` in arm 1, and the three arms' `title`/`reservation_time` sources + * differ in nullability shape too — the union of literal/nullable variants + * doesn't collapse into one column type on its own, the same + * `DayStopRow`/`AssignmentTimeSortKyselyDB` precedent + * (`DayAssignments.repository.ts::listForTimeSort`) uses for its own + * `eb.and([...])`-inferred boolean column. + */ +export interface UpcomingReservationRow { + id: number; + trip_id: number; + title: string; + type: string | null; + status: string | null; + location: string | null; + reservation_time: string | null; + confirmation_number: string | null; + trip_title: string; + trip_cover: string | null; + day_date: string | null; + place_name: string | null; + place_image: string | null; +} + +/** RS20's `entries` CTE row shape — see {@link UpcomingReservationRow}'s docstring for why every arm is cast to this. */ +interface EntriesRow extends UpcomingReservationRow { + at_date: string | null; + at_time: string | null; +} + +interface UpcomingReservationsKyselyDB { + trips: { id: number; user_id: number; title: string; cover_image: string | null; is_archived: number }; + trip_members: { trip_id: number; user_id: number }; + reservations: { + id: number; trip_id: number; title: string; type: string | null; status: string | null; + location: string | null; reservation_time: string | null; confirmation_number: string | null; + day_id: number | null; place_id: number | null; accommodation_id: string | null; + }; + days: { id: number; date: string | null }; + places: { id: number; name: string | null; image_url: string | null }; + day_accommodations: { + id: number; trip_id: number; place_id: number | null; start_day_id: number; end_day_id: number; + check_in: string | null; check_out: string | null; confirmation: string | null; + }; +} + +/** + * `share.service.ts:251` (`publicEndpointsByReservation`) — `SELECT + * e.reservation_id, e.role, e.sequence, e.name, e.code, e.lat, e.lng, + * e.timezone, e.local_date, e.local_time FROM reservation_endpoints e JOIN + * reservations r ON r.id = e.reservation_id WHERE r.trip_id = ? ORDER BY + * e.reservation_id ASC, e.sequence ASC`. Not filtered by RV1 — the legacy + * statement loads every reservation's endpoints regardless of + * `ingest_state`, and the caller only ever looks one up for a reservation + * `share.service.ts`'s OWN filtered read already returned (parity, matching + * `CalendarService.loadEndpointsByTrip`'s identical shape, §4's CL6 note). + */ +export interface ShareEndpointRow { + reservation_id: number; + role: string; + sequence: number; + name: string; + code: string | null; + lat: number; + lng: number; + timezone: string | null; + local_date: string | null; + local_time: string | null; +} + +interface ShareEndpointsKyselyDB { + reservation_endpoints: { + reservation_id: number; role: string; sequence: number; name: string; code: string | null; + lat: number; lng: number; timezone: string | null; local_date: string | null; local_time: string | null; + }; + reservations: { id: number; trip_id: number | string }; +} + +/** + * `share.service.ts:368` (`getSharedTripData`'s day-position read) — + * `SELECT rdp.reservation_id, rdp.day_id, rdp.position FROM + * reservation_day_positions rdp JOIN reservations r ON rdp.reservation_id = + * r.id WHERE r.trip_id = ?`, no `ORDER BY`, no RV1 filter (same reasoning + * as the endpoints read above — the caller only consults `posMap` for a + * reservation its own filtered read already returned). + */ +export interface ShareDayPositionRow { + reservation_id: number; + day_id: number; + position: number; +} + +interface ShareDayPositionsKyselyDB { + reservation_day_positions: { reservation_id: number; day_id: number; position: number }; + reservations: { id: number; trip_id: number | string }; +} + +/** + * `share.service.ts:387` (`getSharedTripData`'s public booking read) — + * `SELECT ${PUBLIC_RESERVATION_COLUMNS} FROM reservations r WHERE r.trip_id + * = ? AND ORDER BY r.reservation_time ASC` — + * `PUBLIC_RESERVATION_COLUMNS` is `id, trip_id, day_id, end_day_id, + * place_id, accommodation_id, title, type, status, location, + * reservation_time, reservation_end_time, notes, url, metadata, + * created_at`, the allow-list a public link may see (never the + * confirmation number, the import trail or who is travelling). + */ +export interface SharePublicReservationRow { + id: number; + trip_id: number; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + accommodation_id: string | null; + title: string; + type: string | null; + status: string | null; + location: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + notes: string | null; + url: string | null; + metadata: string | null; + created_at: string | null; +} + +interface SharePublicReservationKyselyDB { + reservations: ReservationVisibilityKyselyDB['reservations'] & { + trip_id: number | string; + day_id: number | null; end_day_id: number | null; place_id: number | null; title: string; + type: string | null; status: string | null; location: string | null; + reservation_time: string | null; reservation_end_time: string | null; + notes: string | null; url: string | null; metadata: string | null; created_at: string | null; + }; +} + +/** + * `share.service.ts:401` (`getSharedTripData`'s public stay read) — + * `SELECT ${PUBLIC_ACCOMMODATION_COLUMNS}, p.name as place_name, p.address + * as place_address, p.lat as place_lat, p.lng as place_lng FROM + * day_accommodations a JOIN places p ON a.place_id = p.id WHERE a.trip_id = + * ? AND ` — an INNER `JOIN`, not `LEFT`, unlike CL7: a stay with + * no linked place is silently excluded from a public link (parity, kept + * exactly). `PUBLIC_ACCOMMODATION_COLUMNS` is `id, trip_id, place_id, + * start_day_id, end_day_id, check_in, check_in_end, check_out, notes`. + */ +export interface SharePublicAccommodationRow { + id: number; + trip_id: number; + place_id: number | null; + start_day_id: number | null; + end_day_id: number | null; + check_in: string | null; + check_in_end: string | null; + check_out: string | null; + notes: string | null; + place_name: string | null; + place_address: string | null; + place_lat: number | null; + place_lng: number | null; +} + +interface SharePublicAccommodationKyselyDB extends ReservationVisibilityKyselyDB { + day_accommodations: { + id: number; trip_id: number | string; place_id: number | null; start_day_id: number | null; end_day_id: number | null; + check_in: string | null; check_in_end: string | null; check_out: string | null; notes: string | null; + }; + places: { id: number; name: string | null; address: string | null; lat: number | null; lng: number | null }; +} + +/** + * `public-api.service.ts::reservationsByDay` (Task 5's `// Task 2` pickup) + * — `SELECT day_id, type, title, location, reservation_time, + * reservation_end_time, status, notes FROM reservations WHERE trip_id = ? + * AND day_id IS NOT NULL ORDER BY day_id ASC, reservation_time ASC`. + */ +export interface PublicApiScheduledReservationRow { + day_id: number; + type: string | null; + title: string | null; + location: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + status: string | null; + notes: string | null; +} + +/** + * `public-api.service.ts::buildUnscheduledReservations` (Task 5's `// Task + * 2` pickup) — the same 7 columns minus `day_id`, `WHERE trip_id = ? AND + * day_id IS NULL ORDER BY reservation_time ASC, id ASC`. + */ +export type PublicApiUnscheduledReservationRow = Omit; + +interface PublicApiReservationKyselyDB { + reservations: { + id: number; trip_id: number; day_id: number | null; type: string | null; title: string | null; + location: string | null; reservation_time: string | null; reservation_end_time: string | null; + status: string | null; notes: string | null; + }; +} + +/** + * `public-api.service.ts::buildAccommodations` (Task 5's `// Task 3` + * pickup) — `SELECT p.name, p.address, p.lat, p.lng, ds.date AS start_date, + * de.date AS end_date, a.check_in, a.check_out, a.notes FROM + * day_accommodations a LEFT JOIN places p ON p.id = a.place_id LEFT JOIN + * days ds ON ds.id = a.start_day_id LEFT JOIN days de ON de.id = + * a.end_day_id WHERE a.trip_id = ? ORDER BY ds.date ASC`. + */ +export interface PublicApiAccommodationRow { + name: string | null; + address: string | null; + lat: number | null; + lng: number | null; + start_date: string | null; + end_date: string | null; + check_in: string | null; + check_out: string | null; + notes: string | null; +} + +interface PublicApiAccommodationKyselyDB { + day_accommodations: { id: number; trip_id: number; place_id: number | null; start_day_id: number | null; end_day_id: number | null; check_in: string | null; check_out: string | null; notes: string | null }; + places: { id: number; name: string | null; address: string | null; lat: number | null; lng: number | null }; + days: { id: number; date: string | null }; +} + +/** + * `public-api.service.ts::buildUnplannedPlaces` (Task 5's `// Task 3` + * pickup — "the whole statement stays raw rather than splitting the + * places/day_assignments reads from the one 3d-table predicate", the + * method's own comment) — `SELECT p.name, p.address, p.lat, p.lng, + * p.place_time, p.end_time, p.duration_minutes, p.notes, p.transport_mode, + * c.name AS category FROM places p LEFT JOIN categories c ON c.id = + * p.category_id WHERE p.trip_id = ? AND NOT EXISTS (SELECT 1 FROM + * day_assignments da WHERE da.place_id = p.id) AND NOT EXISTS (SELECT 1 + * FROM day_accommodations a WHERE a.place_id = p.id) ORDER BY + * p.created_at ASC, p.id ASC`. + */ +export interface PublicApiUnplannedPlaceRow { + name: string; + address: string | null; + lat: number | null; + lng: number | null; + place_time: string | null; + end_time: string | null; + duration_minutes: number | null; + notes: string | null; + transport_mode: string | null; + category: string | null; +} + +interface PublicApiUnplannedPlaceKyselyDB { + places: { + id: number; trip_id: number; name: string; address: string | null; lat: number | null; lng: number | null; + place_time: string | null; end_time: string | null; duration_minutes: number | null; notes: string | null; + transport_mode: string | null; category_id: number | null; created_at: string | null; + }; + categories: { id: number; name: string }; + day_assignments: { id: number; place_id: number }; + day_accommodations: { id: number; place_id: number | null }; +} + +/** + * Plan 3h Task 4 (`AirportsService.backfillFlightEndpoints`, AIR1) — + * additive. The narrow `reservations`/`reservation_endpoints` shape the + * `NOT EXISTS` guard needs. + */ +interface FlightsMissingEndpointsKyselyDB { + reservations: { id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null; type: string | null }; + reservation_endpoints: { id: number; reservation_id: number }; +} + +export class ReservationsRepository extends TrekRepository { + private joinedQuery() { + return this.kysely() + .selectFrom('reservations as r') + .leftJoin('days as d', 'd.id', 'r.day_id') + .leftJoin('places as p', 'p.id', 'r.place_id') + .leftJoin('day_accommodations as ap', 'ap.id', 'r.accommodation_id') + .leftJoin('places as acc_p', 'acc_p.id', 'ap.place_id') + .selectAll('r') + .select([ + 'd.day_number as day_number', + 'p.name as place_name', + 'ap.place_id as accommodation_place_id', + 'acc_p.name as accommodation_name', + 'ap.start_day_id as accommodation_start_day_id', + 'ap.end_day_id as accommodation_end_day_id', + ]); + } + + /** + * RS18 (`ReservationsService.list`) — the joined projection for a whole + * trip, `ORDER BY r.reservation_time ASC, r.created_at ASC`. + */ + async listForTrip(trip_id: number | string): Promise { + const rows = await this.joinedQuery() + .where('r.trip_id', '=', trip_id) + .orderBy('r.reservation_time', 'asc') + .orderBy('r.created_at', 'asc') + .execute(); + return rows as ReservationJoinRow[]; + } + + /** + * RR1 (`ReservationsReadService.getReservationWithJoins`) — the SAME + * projection for one reservation, no `ORDER BY` (a single row). + */ + async findWithJoins(id: number | string): Promise { + const row = await this.joinedQuery().where('r.id', '=', id).executeTakeFirst(); + return row as ReservationJoinRow | undefined; + } + + /** + * RS35 (`ReservationsService.getReservation`) — `SELECT * FROM + * reservations WHERE id = ? AND trip_id = ?`, the trip-scoping guard every + * write path re-reads through. `id`/`trip_id: number` (rule 21 — the + * SERVICE parses both ONCE via `toRowId` before calling this, and every + * later write in the same method reuses that same parsed value, never a + * second, independent conversion). + */ + async findInTrip(id: number, trip_id: number): Promise { + return this.qb('r') + .select(['r.*']) + .where({ id, trip: trip_id }) + .execute('get', false); + } + + /** RS45 (`ReservationsService.remove`'s pre-image read) — `SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?`. */ + async findHeaderInTrip(id: number, trip_id: number): Promise<{ id: number; title: string; type: string | null; accommodation_id: string | null } | undefined> { + return this.qb('r') + .select(['r.id', 'r.title', 'r.type', 'r.accommodation_id']) + .where({ id, trip: trip_id }) + .execute<{ id: number; title: string; type: string | null; accommodation_id: string | null } | undefined>('get', false); + } + + /** + * RS12 (`ReservationsService.resyncReservationDays`) — `SELECT id, + * reservation_time, reservation_end_time, day_id, end_day_id FROM + * reservations WHERE trip_id = ? AND (type != 'hotel' OR accommodation_id + * IS NULL) AND reservation_time IS NOT NULL`. + */ + async listResyncCandidates(trip_id: number): Promise<{ + id: number; reservation_time: string | null; reservation_end_time: string | null; + day_id: number | null; end_day_id: number | null; + }[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('r') + .select(['r.id', 'r.reservation_time', 'r.reservation_end_time', columnRef(platform, 'r.day_id').as('day_id'), columnRef(platform, 'r.end_day_id').as('end_day_id')]) + .where({ + trip: trip_id, + $or: [{ type: { $ne: 'hotel' } }, { accommodation_id: null }], + reservation_time: { $ne: null }, + }) + .execute<{ id: number; reservation_time: string | null; reservation_end_time: string | null; day_id: number | null; end_day_id: number | null }[]>('all', false); + } + + /** RS13 (`resyncReservationDays`'s per-row write) — `UPDATE reservations SET day_id = ?, end_day_id = ? WHERE id = ?`. */ + async setDays(id: number, day_id: number | null, end_day_id: number | null): Promise { + await this.nativeUpdate({ id }, { day: day_id, endDay: end_day_id }); + } + + /** + * RS28 (`ReservationsService.createInTx`) — `INSERT INTO reservations + * (trip_id, day_id, end_day_id, place_id, assignment_id, title, + * reservation_time, reservation_end_time, location, confirmation_number, + * notes, url, status, type, accommodation_id, metadata, needs_review) + * VALUES (?×17)`. `sync_enabled`/`ingest_state`/`day_plan_position` + * deliberately NOT named, so the column's own DB-level `DEFAULT` applies + * (`sync_enabled INTEGER DEFAULT 1`, `Migration20200101021800_…`; + * `ingest_state` `.default('live')`) — the caller has already resolved + * every value (the `|| null` coercions, `resolvedOrNull`, the numeric + * `accommodation_id` stringified for the TEXT column, R2). + */ + async insertReservation(input: { + trip_id: number; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + title: string; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + url: string | null; + status: string; + type: string; + accommodation_id: string | null; + metadata: string | null; + needs_review: number; + }): Promise { + return await this.insert({ + trip: input.trip_id, + day: input.day_id, + endDay: input.end_day_id, + place: input.place_id, + assignment: input.assignment_id, + title: input.title, + reservation_time: input.reservation_time, + reservation_end_time: input.reservation_end_time, + location: input.location, + confirmation_number: input.confirmation_number, + notes: input.notes, + url: input.url, + status: input.status, + type: input.type, + accommodation_id: input.accommodation_id, + metadata: input.metadata, + needs_review: input.needs_review, + }); + } + + /** + * RS41 (`ReservationsService.updateInTx`) — the 16-column `UPDATE + * reservations SET … WHERE id = ?`. The `PlacesRepository.updatePlace`/PL11 + * precedent: the four legacy `COALESCE(?, col)` keep-if-null columns + * (`title`, `status`, `type`, `needs_review`) are resolved to their FINAL + * value by the SERVICE first (it already holds the pre-image via the + * `current` parameter every caller passes), so this writes exactly what it + * is handed — no SQL-side COALESCE. `id: number` (rule 21 — the SAME + * `toRowId`-parsed value `findInTrip`'s gate read used). + */ + async updateReservation(id: number, write: { + title: string; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + url: string | null; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + status: string; + type: string; + accommodation_id: string | null; + metadata: string | null; + needs_review: number; + }): Promise { + await this.nativeUpdate({ id }, { + title: write.title, + reservation_time: write.reservation_time, + reservation_end_time: write.reservation_end_time, + location: write.location, + confirmation_number: write.confirmation_number, + notes: write.notes, + url: write.url, + day: write.day_id, + endDay: write.end_day_id, + place: write.place_id, + assignment: write.assignment_id, + status: write.status, + type: write.type, + accommodation_id: write.accommodation_id, + metadata: write.metadata, + needs_review: write.needs_review, + }); + } + + /** + * RS33 (`updatePositions`'s legacy/global branch) — `UPDATE reservations + * SET day_plan_position = ? WHERE id = ? AND trip_id = ?`. `position: + * number | null`, never `undefined` (R8 — the SERVICE maps an absent + * `day_plan_position` to `null` explicitly before calling, so + * `nativeUpdate` always WRITES the column instead of silently skipping it). + */ + async setDayPlanPosition(id: number, trip_id: number, position: number | null): Promise { + await this.nativeUpdate({ id, trip: trip_id }, { day_plan_position: position }); + } + + /** RS50 (`remove`) — `DELETE FROM reservations WHERE id = ?`, unscoped (the caller already proved trip access via `findHeaderInTrip`). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** DY14 (`DaysService.restampReservationDates`) — `SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?`. */ + async listForRestamp(trip_id: number): Promise<{ + id: number; day_id: number | null; end_day_id: number | null; + reservation_time: string | null; reservation_end_time: string | null; + }[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('r') + .select(['r.id', columnRef(platform, 'r.day_id').as('day_id'), columnRef(platform, 'r.end_day_id').as('end_day_id'), 'r.reservation_time', 'r.reservation_end_time']) + .where({ trip: trip_id }) + .execute<{ id: number; day_id: number | null; end_day_id: number | null; reservation_time: string | null; reservation_end_time: string | null }[]>('all', false); + } + + /** DY15 — `UPDATE reservations SET reservation_time = ? WHERE id = ?`. */ + async setReservationTime(id: number, reservation_time: string): Promise { + await this.nativeUpdate({ id }, { reservation_time }); + } + + /** DY16 — `UPDATE reservations SET reservation_end_time = ? WHERE id = ?`. */ + async setReservationEndTime(id: number, reservation_end_time: string): Promise { + await this.nativeUpdate({ id }, { reservation_end_time }); + } + + /** + * DY23 (`DaysService.resyncAccommodationDays`) — the named-param `UPDATE + * reservations SET day_id = :dayId, reservation_time = CASE WHEN + * reservation_time IS NULL THEN :date ELSE :date || SUBSTR(reservation_time, + * 11) END WHERE accommodation_id = :accId AND type = 'hotel'`. Kysely (the + * `CASE WHEN … ELSE … || SUBSTR(...)` shape has no typed-filter + * equivalent — the T6 tier per the inventory's own ruling), through + * `concatKysely`/`substringKysely` (`sql-functions.ts`'s Kysely-expression + * twins of `concat`/`substring` — the MikroORM `RawQueryFragment` forms + * render as a literal `?` token inside a Kysely statement and throw at + * execution, Task 0 review H1). `accommodation_id` (TEXT) compared against + * `accId` (a bound NUMBER, exactly as the legacy statement bound it — R2, + * never stringified here, so the SAME SQLite TEXT-affinity conversion the + * legacy statement triggered still applies, including its own miss on a + * `"14.0"`-shaped row — parity, not a fix). + */ + async restampLinkedReservation(accommodation_id: number, day_id: number, date: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.kysely() + .updateTable('reservations') + .set((eb) => ({ + day_id, + reservation_time: eb + .case() + .when('reservation_time', 'is', null) + .then(eb.val(date)) + .else(concatKysely(platform, eb, { value: date }, { expression: substringKysely(platform, eb, 'reservation_time', 11) })) + .end(), + })) + .where('accommodation_id', '=', accommodation_id) + .where('type', '=', 'hotel') + .execute(); + } + + /** + * RS22's dispatch (`ReservationsService.referencesOutsideTrip`'s + * `assignment_id` branch) — `SELECT d.trip_id FROM day_assignments da JOIN + * days d ON da.day_id = d.id WHERE da.id = ?`. Reached through + * `DayAssignmentsRepository`'s own relation join (`da.day` — the same + * cross-repository-read shape `TripMembersRepository.rosterUserIds` + * already uses for a table this repository does not own), never a new + * method on that file + * (`DayAssignmentsRepository`/`DaysRepository` are Task 1's + * additive-method territory per the file-ownership split). + */ + async getAssignmentTripId(assignment_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const dayAssignments = this.getEntityManager().getRepository(DayAssignments); + const row = await dayAssignments + .qb('da') + .join('da.day', 'd') + .select([columnRef(platform, 'd.trip_id').as('trip_id')]) + .where({ id: assignment_id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + /** + * RS11 (`resolveDayIdFromTime`'s nearest-day fallback) — `SELECT id FROM + * days WHERE trip_id = ? ORDER BY ABS(JULIANDAY(date) - JULIANDAY(?)) ASC, + * date ASC LIMIT 1`. `dayDistance`, through `DaysRepository`'s own QB (the + * same cross-repository-read shape as `getAssignmentTripId` above — + * `DaysRepository` stays Task 1's additive-method territory; no new + * method is added there). + */ + async findNearestDayId(trip_id: number, iso_date: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const days = this.getEntityManager().getRepository(Days); + const row = await days + .qb('d') + .select(['d.id']) + .where({ trip: trip_id }) + .orderBy([{ [dayDistance(platform, 'd.date', iso_date)]: 'asc' }, { date: 'asc' }]) + .limit(1) + .execute<{ id: number } | undefined>('get', false); + return row?.id; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 3 (`AccommodationsService`) — additive, per this task's own + // file-ownership rule ("additive methods on Days/Places/DayAssignments/ + // Reservations repositories where a read belongs there"). + // --------------------------------------------------------------------------- + + /** + * AC33 (`AccommodationsService.createAccommodation`) — `INSERT INTO + * reservations (trip_id, day_id, title, reservation_time, location, + * confirmation_number, notes, status, type, accommodation_id, metadata) + * VALUES (?, ?, ?, ?, ?, ?, ?, 'confirmed', 'hotel', ?, ?)` — the + * auto-created partner hotel booking. `status`/`type` literals, `location` + * always `null` (matching the legacy statement exactly — it never binds a + * value there). `accommodation_id: string` — R2: the caller stringifies + * the new stay's numeric id (`String(newId)`) the same way `insertReservation`'s + * own `accommodation_id` caller already does, so SQLite's TEXT-affinity + * storage is identical to the legacy raw bind of the bare number. + */ + async insertHotelPartner(input: { + trip_id: number | string; + day_id: number; + title: string; + reservation_time: string | null; + confirmation_number: string | null; + notes: string | null; + accommodation_id: string; + metadata: string | null; + }): Promise { + return await this.insert({ + trip: input.trip_id, + day: input.day_id, + title: input.title, + reservation_time: input.reservation_time, + location: null, + confirmation_number: input.confirmation_number, + notes: input.notes, + status: 'confirmed', + type: 'hotel', + accommodation_id: input.accommodation_id, + metadata: input.metadata, + }); + } + + /** + * AC37 (`AccommodationsService.updateAccommodation`) — `SELECT id, + * metadata FROM reservations WHERE accommodation_id = ?`, every linked + * booking (no unique constraint on the column — more than one booking can + * point at the same stay). Bound as a plain `number`, matching the + * legacy's own `Number(id)` bind byte-for-byte — see + * {@link ReservationsByAccommodationKyselyDB}'s docstring (M3, reverted + * from the `castIntegerKysely` widening). + */ + async listIdMetadataByStay(accommodation_id: number): Promise<{ id: number; metadata: string | null }[]> { + const rows = await this.kysely() + .selectFrom('reservations') + .select(['id', 'metadata']) + .where('accommodation_id', '=', accommodation_id) + .execute(); + return rows as { id: number; metadata: string | null }[]; + } + + /** + * AC38 (`AccommodationsService.updateAccommodation`) — `UPDATE + * reservations SET metadata = ?, confirmation_number = COALESCE(?, + * confirmation_number) WHERE id = ?`. Runs AFTER `updateAccommodation`'s + * own transaction commits (§18.6 — R5 class, flagged not fixed). + * `coalesceOverride` for the value-side COALESCE — the bound `?` FIRST, + * the column the fallback, so the new value wins + * (`DayAccommodationsRepository.patchConfirmation`'s precedent). + */ + async setMetadataAndConfirmation(id: number, metadata: string, confirmation: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb() + .update({ + metadata, + confirmation_number: coalesceOverride(platform, confirmation, 'confirmation_number'), + }) + .where({ id }) + .execute('run'); + } + + /** + * AC40 (`AccommodationsService.deleteAccommodation`) — `SELECT id FROM + * reservations WHERE accommodation_id = ?`, ALL linked bookings. Same + * REAL-bound compare as {@link listIdMetadataByStay} (M3). + */ + async listIdsByStay(accommodation_id: number): Promise<{ id: number }[]> { + const rows = await this.kysely() + .selectFrom('reservations') + .select(['id']) + .where('accommodation_id', '=', accommodation_id) + .execute(); + return rows as { id: number }[]; + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 6 (`trips.service.ts::copy`) — moved here from + // `ReservationEndpoints.repository.ts` in the Task 7 whole-plan review's + // fix wave (item 8/D4, "one repository per table"): Task 4 owned this file + // for the Task 6 tree window, so TP58/TP59 (the reservations copy + // read/write) landed on `ReservationEndpointsRepository` instead at the + // time — a pure move now that the window is closed, same statements, + // same parity proof (`Reservations.repository.test.ts`). Endpoints/ + // travelers/day-positions are never copied with a reservation (R7, + // `copy` never copied them — kept as-is on their own repositories). + // --------------------------------------------------------------------------- + + /** + * TP58 (`trips.service.ts::copy`'s reservations read) — `SELECT * FROM + * reservations WHERE trip_id = ?`, no `ORDER BY` (matching the legacy + * statement; the copy loop's own id-remap doesn't depend on read order). + * `ReservationAllColumnsRow` (RS35's own `SELECT *` shape) rather than a + * second, differently-named copy of the same fields. + */ + async listAllForTrip(trip_id: number): Promise { + return await this.kysely<{ reservations: ReservationAllColumnsRow }>() + .selectFrom('reservations') + .selectAll() + .where('trip_id', '=', trip_id) + .execute(); + } + + /** + * TP59 (`trips.service.ts::copy`'s reservation INSERT) — `INSERT INTO + * reservations (trip_id, day_id, end_day_id, place_id, assignment_id, + * accommodation_id, title, reservation_time, reservation_end_time, + * location, confirmation_number, notes, url, status, type, metadata, + * day_plan_position, needs_review, ingest_state) VALUES (?×19)`. The + * `external_*`/`sync_enabled`/`created_at` columns are deliberately + * omitted (the legacy statement's own column list never named them — a + * copy must not inherit the source's external sync identity). + * + * `this.insert(...)`, not `this.getEntityManager().insert(Reservations, + * …)`: this repository IS templated on `Reservations` (unlike + * `ReservationEndpointsRepository`, where this method used to live and + * needed the escape hatch to reach a table it wasn't templated on) — the + * ordinary `TrekRepository.insert` is the right call here. + * + * `accommodation_id` arrives ALREADY formatted as the legacy `'.0'` + * TEXT shape (`row-id.ts`'s `legacyBoundIntegerText`, the program's + * Plan 3d Task 3 `"14.0"` finding): a plain JS number written here would + * be inlined as a SQL literal (rule 22) and stored as `'14'`, not the + * shape the legacy raw-bound statement produced. + */ + async insertReservationCopy(input: { + trip_id: number; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + accommodation_id: string | null; + title: string; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + url: string | null; + status: string | null; + type: string | null; + metadata: string | null; + day_plan_position: number | null; + needs_review: number; + ingest_state: string; + }): Promise { + return await this.insert({ + trip: input.trip_id, + day: input.day_id, + endDay: input.end_day_id, + place: input.place_id, + assignment: input.assignment_id, + accommodation_id: input.accommodation_id, + title: input.title, + reservation_time: input.reservation_time, + reservation_end_time: input.reservation_end_time, + location: input.location, + confirmation_number: input.confirmation_number, + notes: input.notes, + url: input.url, + status: input.status, + type: input.type, + metadata: input.metadata, + day_plan_position: input.day_plan_position, + needs_review: input.needs_review, + ingest_state: input.ingest_state, + }); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 4 — additive (see the file-level comment above the interfaces + // this section's methods return). + // --------------------------------------------------------------------------- + + /** CL2 — see {@link CalendarReservationRow}'s docstring. */ + async listForCalendar(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('reservations as r') + .leftJoin('places as pl', 'pl.id', 'r.place_id') + .leftJoin('day_accommodations as a', 'a.id', 'r.accommodation_id') + .leftJoin('days as sd', 'sd.id', 'a.start_day_id') + .leftJoin('days as ed', 'ed.id', 'a.end_day_id') + .leftJoin('days as rd', 'rd.id', 'r.day_id') + .leftJoin('days as red', 'red.id', 'r.end_day_id') + .selectAll('r') + .select((eb) => [ + 'pl.lat as place_lat', + 'pl.lng as place_lng', + 'sd.date as stay_start_date', + 'ed.date as stay_end_date', + 'a.check_in as stay_check_in', + 'a.check_out as stay_check_out', + eb + .selectFrom('reservations as r2') + .select((eb2) => eb2.fn.min('r2.id').as('c')) + .whereRef('r2.accommodation_id', '=', 'a.id') + .as('stay_first_reservation_id'), + 'rd.date as day_date', + 'red.date as end_day_date', + ]) + .where('r.trip_id', '=', trip_id) + .where((eb) => publicReservationExpr(eb, 'r.ingest_state')) + .execute(); + return rows as CalendarReservationRow[]; + } + + /** CL4 — see {@link CalendarStopRow}'s docstring. */ + async listCalendarStops(day_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_assignments as da') + .innerJoin('places as p', 'p.id', 'da.place_id') + .selectAll('da') + .select((eb) => [ + 'p.name as place_name', + 'p.address as place_address', + 'p.lat as place_lat', + 'p.lng as place_lng', + eb.fn.coalesce('da.assignment_time', 'p.place_time').as('effective_time'), + eb.fn.coalesce('da.assignment_end_time', 'p.end_time').as('effective_end_time'), + ]) + .where('da.day_id', '=', day_id) + .where('da.accommodation_id', 'is', null) + .orderBy('da.order_index', 'asc') + .orderBy('da.created_at', 'asc') + .execute(); + return rows as CalendarStopRow[]; + } + + /** CL7 — see {@link CalendarStayRow}'s docstring. */ + async listPublicStaysForCalendar(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .where('a.trip_id', '=', trip_id) + .where((eb) => publicStayExists(eb)) + .leftJoin('days as sd', 'sd.id', 'a.start_day_id') + .leftJoin('days as ed', 'ed.id', 'a.end_day_id') + .leftJoin('places as p', 'p.id', 'a.place_id') + .select((eb) => [ + 'a.id', + 'a.check_in', + 'a.check_in_end', + 'a.check_out', + 'sd.date as start_date', + 'ed.date as end_date', + 'p.name as place_name', + 'p.address as place_address', + 'p.lat as place_lat', + 'p.lng as place_lng', + eb + .selectFrom('reservations as r') + .select('r.title') + .whereRef('r.accommodation_id', '=', 'a.id') + .where((eb2) => publicReservationExpr(eb2, 'r.ingest_state')) + .orderBy('r.id', 'asc') + .limit(1) + .as('reservation_title'), + ]) + .orderBy('a.id', 'asc') + .execute(); + return rows as CalendarStayRow[]; + } + + /** RS20 — see {@link UpcomingReservationRow}'s docstring. */ + async listUpcomingForUser(user_id: number, today: string, now_hhmm: string, limit: number): Promise { + const platform = this.getEntityManager().getPlatform(); + + // The `COALESCE(place name, nameless-stay's-linked-booking title, trip + // title)` title is identical across the check-in and check-out arms + // (both name the STAY, not the moment); it is spelled out in each rather + // than factored into a shared helper, matching `Trips.repository.ts`'s + // `activeTrip` precedent for its own repeated `CASE WHEN` — a helper + // spanning two differently-joined query builders would need the same + // kind of hand-written cross-callback type `_shared/reservation-visibility + // .ts`'s own module docstring explains doesn't hold up. + const rows = await this.kysely() + .with('visible_trips', (qb) => + qb + .selectFrom('trips as t') + .leftJoin('trip_members as tm', (join) => join.onRef('tm.trip_id', '=', 't.id').on('tm.user_id', '=', user_id)) + .select(['t.id', 't.title', 't.cover_image']) + .where((eb) => + eb.and([eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', 'is not', null)]), eb('t.is_archived', '=', 0)]), + ), + ) + .with('entries', (qb) => { + const bookings = qb + .selectFrom('reservations as r') + .innerJoin('visible_trips as tr', 'tr.id', 'r.trip_id') + .leftJoin('days as d', 'd.id', 'r.day_id') + .leftJoin('places as p', 'p.id', 'r.place_id') + .select((eb) => [ + 'r.id as id', + 'r.trip_id as trip_id', + 'r.title as title', + 'r.type as type', + 'r.status as status', + 'r.location as location', + 'r.reservation_time as reservation_time', + 'r.confirmation_number as confirmation_number', + 'tr.title as trip_title', + 'tr.cover_image as trip_cover', + 'd.date as day_date', + 'p.name as place_name', + 'p.image_url as place_image', + eb + .case() + .when(startsWithIsoDateKysely(platform, eb, 'r.reservation_time')) + .then(substringKysely(platform, eb, 'r.reservation_time', 1, 10)) + .else(eb.ref('d.date')) + .end() + .as('at_date'), + eb + .case() + .when(startsWithIsoDateKysely(platform, eb, 'r.reservation_time')) + .then(substringKysely(platform, eb, 'r.reservation_time', 12)) + .else(eb.ref('r.reservation_time')) + .end() + .as('at_time'), + ]) + .where('r.status', '!=', 'cancelled') + .where((eb) => eb(eb.fn.coalesce(eb.ref('r.type'), eb.val('')), '!=', 'hotel')) + .$castTo(); + + const checkins = qb + .selectFrom('day_accommodations as a') + .innerJoin('visible_trips as tr', 'tr.id', 'a.trip_id') + .innerJoin('days as d', 'd.id', 'a.start_day_id') + .leftJoin('places as p', 'p.id', 'a.place_id') + .select((eb) => [ + 'a.id as id', + 'a.trip_id as trip_id', + eb + .fn.coalesce( + eb.ref('p.name'), + eb + .selectFrom('reservations as res') + .select('res.title') + .where((eb2) => eb2(castIntegerKysely(platform, eb2, 'res.accommodation_id'), '=', eb2.ref('a.id'))) + .where('res.status', '!=', 'cancelled') + .orderBy('res.id', 'asc') + .limit(1), + eb.ref('tr.title'), + ) + .as('title'), + eb.val('checkin').as('type'), + eb.val('confirmed').as('status'), + eb.val(null).as('location'), + eb + .case() + .when('a.check_in', 'is not', null) + .then(concatKysely(platform, eb, { column: 'd.date' }, { value: 'T' }, { column: 'a.check_in' })) + .end() + .as('reservation_time'), + 'a.confirmation as confirmation_number', + 'tr.title as trip_title', + 'tr.cover_image as trip_cover', + 'd.date as day_date', + 'p.name as place_name', + 'p.image_url as place_image', + 'd.date as at_date', + 'a.check_in as at_time', + ]) + .$castTo(); + + const checkouts = qb + .selectFrom('day_accommodations as a') + .innerJoin('visible_trips as tr', 'tr.id', 'a.trip_id') + .innerJoin('days as d', 'd.id', 'a.end_day_id') + .leftJoin('places as p', 'p.id', 'a.place_id') + .select((eb) => [ + 'a.id as id', + 'a.trip_id as trip_id', + eb + .fn.coalesce( + eb.ref('p.name'), + eb + .selectFrom('reservations as res') + .select('res.title') + .where((eb2) => eb2(castIntegerKysely(platform, eb2, 'res.accommodation_id'), '=', eb2.ref('a.id'))) + .where('res.status', '!=', 'cancelled') + .orderBy('res.id', 'asc') + .limit(1), + eb.ref('tr.title'), + ) + .as('title'), + eb.val('checkout').as('type'), + eb.val('confirmed').as('status'), + eb.val(null).as('location'), + eb + .case() + .when('a.check_out', 'is not', null) + .then(concatKysely(platform, eb, { column: 'd.date' }, { value: 'T' }, { column: 'a.check_out' })) + .end() + .as('reservation_time'), + 'a.confirmation as confirmation_number', + 'tr.title as trip_title', + 'tr.cover_image as trip_cover', + 'd.date as day_date', + 'p.name as place_name', + 'p.image_url as place_image', + 'd.date as at_date', + 'a.check_out as at_time', + ]) + .$castTo(); + + return bookings.unionAll(checkins).unionAll(checkouts); + }) + .selectFrom('entries') + .select(['id', 'trip_id', 'title', 'type', 'status', 'location', 'reservation_time', 'confirmation_number', 'trip_title', 'trip_cover', 'day_date', 'place_name', 'place_image']) + .where('at_date', 'is not', null) + .where((eb) => + eb.or([ + eb('at_date', '>', today), + eb.and([eb('at_date', '=', today), eb(eb.fn.coalesce(eb.ref('at_time'), eb.val('23:59')), '>=', now_hhmm)]), + ]), + ) + .orderBy('at_date', 'asc') + .orderBy((eb) => eb.fn.coalesce(eb.ref('at_time'), eb.val('00:00')), 'asc') + .orderBy('id', 'asc') + .limit(limit) + .execute(); + return rows as UpcomingReservationRow[]; + } + + /** `share.service.ts:251` — see {@link ShareEndpointRow}'s docstring. */ + async listEndpointsForShare(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('reservation_endpoints as e') + .innerJoin('reservations as r', 'r.id', 'e.reservation_id') + .select(['e.reservation_id', 'e.role', 'e.sequence', 'e.name', 'e.code', 'e.lat', 'e.lng', 'e.timezone', 'e.local_date', 'e.local_time']) + .where('r.trip_id', '=', trip_id) + .orderBy('e.reservation_id', 'asc') + .orderBy('e.sequence', 'asc') + .execute(); + return rows as ShareEndpointRow[]; + } + + /** `share.service.ts:368` — see {@link ShareDayPositionRow}'s docstring. */ + async listDayPositionsForShare(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('reservation_day_positions as rdp') + .innerJoin('reservations as r', 'r.id', 'rdp.reservation_id') + .select(['rdp.reservation_id', 'rdp.day_id', 'rdp.position']) + .where('r.trip_id', '=', trip_id) + .execute(); + return rows as ShareDayPositionRow[]; + } + + /** `share.service.ts:387` — see {@link SharePublicReservationRow}'s docstring. */ + async listPublicForShare(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('reservations as r') + .select([ + 'r.id', 'r.trip_id', 'r.day_id', 'r.end_day_id', 'r.place_id', 'r.accommodation_id', + 'r.title', 'r.type', 'r.status', 'r.location', 'r.reservation_time', 'r.reservation_end_time', + 'r.notes', 'r.url', 'r.metadata', 'r.created_at', + ]) + .where('r.trip_id', '=', trip_id) + .where((eb) => publicReservationExpr(eb, 'r.ingest_state')) + .orderBy('r.reservation_time', 'asc') + .execute(); + return rows as SharePublicReservationRow[]; + } + + /** `share.service.ts:401` — see {@link SharePublicAccommodationRow}'s docstring. */ + async listPublicAccommodationsForShare(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .where('a.trip_id', '=', trip_id) + .where((eb) => publicStayExists(eb)) + .innerJoin('places as p', 'p.id', 'a.place_id') + .select([ + 'a.id', 'a.trip_id', 'a.place_id', 'a.start_day_id', 'a.end_day_id', + 'a.check_in', 'a.check_in_end', 'a.check_out', 'a.notes', + 'p.name as place_name', 'p.address as place_address', 'p.lat as place_lat', 'p.lng as place_lng', + ]) + .execute(); + return rows as SharePublicAccommodationRow[]; + } + + /** + * `share.service.ts`'s travel-only read (SH19, #1712) — `SELECT DISTINCT + * a.place_id FROM day_accommodations a WHERE a.trip_id = ? AND `: + * the places the trip sleeps at, through the same public-stay predicate as + * {@link listPublicAccommodationsForShare}. + */ + async listPublicStayPlaceIdsForShare(trip_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .where('a.trip_id', '=', trip_id) + .where((eb) => publicStayExists(eb)) + .select('a.place_id') + .distinct() + .execute(); + return rows.map((r) => r.place_id as number); + } + + /** `public-api.service.ts::reservationsByDay` (Task 5's `// Task 2` pickup) — see {@link PublicApiScheduledReservationRow}'s docstring. */ + async listScheduledForPublicApi(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('reservations') + .select(['day_id', 'type', 'title', 'location', 'reservation_time', 'reservation_end_time', 'status', 'notes']) + .where('trip_id', '=', trip_id) + .where('day_id', 'is not', null) + .orderBy('day_id', 'asc') + .orderBy('reservation_time', 'asc') + .execute(); + return rows as PublicApiScheduledReservationRow[]; + } + + /** `public-api.service.ts::buildUnscheduledReservations` (Task 5's `// Task 2` pickup) — see {@link PublicApiUnscheduledReservationRow}'s docstring. */ + async listUnscheduledForPublicApi(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('reservations') + .select(['type', 'title', 'location', 'reservation_time', 'reservation_end_time', 'status', 'notes']) + .where('trip_id', '=', trip_id) + .where('day_id', 'is', null) + .orderBy('reservation_time', 'asc') + .orderBy('id', 'asc') + .execute(); + return rows as PublicApiUnscheduledReservationRow[]; + } + + /** `public-api.service.ts::buildAccommodations` (Task 5's `// Task 3` pickup) — see {@link PublicApiAccommodationRow}'s docstring. */ + async listAccommodationsForPublicApi(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('day_accommodations as a') + .leftJoin('places as p', 'p.id', 'a.place_id') + .leftJoin('days as ds', 'ds.id', 'a.start_day_id') + .leftJoin('days as de', 'de.id', 'a.end_day_id') + .select(['p.name', 'p.address', 'p.lat', 'p.lng', 'ds.date as start_date', 'de.date as end_date', 'a.check_in', 'a.check_out', 'a.notes']) + .where('a.trip_id', '=', trip_id) + .orderBy('ds.date', 'asc') + .execute(); + return rows as PublicApiAccommodationRow[]; + } + + /** `public-api.service.ts::buildUnplannedPlaces` (Task 5's `// Task 3` pickup) — see {@link PublicApiUnplannedPlaceRow}'s docstring. */ + async listUnplannedPlacesForPublicApi(trip_id: number): Promise { + const rows = await this.kysely() + .selectFrom('places as p') + .leftJoin('categories as c', 'c.id', 'p.category_id') + .select((eb) => [ + 'p.name', 'p.address', 'p.lat', 'p.lng', 'p.place_time', 'p.end_time', + 'p.duration_minutes', 'p.notes', 'p.transport_mode', 'c.name as category', + ]) + .where('p.trip_id', '=', trip_id) + .where((eb) => eb.not(eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id')))) + .where((eb) => eb.not(eb.exists(eb.selectFrom('day_accommodations as a').select('a.id').whereRef('a.place_id', '=', 'p.id')))) + .orderBy('p.created_at', 'asc') + .orderBy('p.id', 'asc') + .execute(); + return rows as PublicApiUnplannedPlaceRow[]; + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 1 (`FilesService.findForeignLinkTarget`, R12) — additive, + // append-only per that task's own file-ownership rule. + // --------------------------------------------------------------------------- + + /** + * FL1 (`FilesService.findForeignLinkTarget`'s reservation branch) — + * `SELECT 1 FROM reservations WHERE id = ? AND trip_id = ?`, re-expressed + * as "what trip does this row belong to" (R12: one method per target + * table, no dynamic identifier dispatch — the RS21/RS23/RS25 D4 precedent) + * so the caller compares the returned `trip_id` against its own, already + * `toRowId`-parsed value instead of binding two ids into the query itself. + * `trip_id` is a `persist(false)` mirror of the `trip` relation — + * `columnRef`, not a bare select (the program-wide trap). + */ + async findTripId(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('r') + .select([columnRef(platform, 'r.trip_id').as('trip_id')]) + .where({ id }) + .execute<{ trip_id: number } | undefined>('get', false); + return row?.trip_id; + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 2 (budget) — additive, append-only per that task's own + // file-ownership rule: BG54/85, BG55/86, BG56/87 — the mirrored-price + // read/write/re-select `BudgetService.clearReservationPrice`/ + // `syncReservationPrice` run against a reservation. Kysely, matching + // `findTripId` above's reasoning: `trip_id` is a `persist(false)` mirror. + // --------------------------------------------------------------------------- + + /** BG54/BG85 — `SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?`. */ + async getIdAndMetadata(id: number | string, trip_id: number | string): Promise<{ id: number; metadata: string | null } | undefined> { + return await this.kysely<{ reservations: { id: number; trip_id: number; metadata: string | null } }>() + .selectFrom('reservations') + .select(['id', 'metadata']) + .where('id', '=', id as number) + .where('trip_id', '=', trip_id as number) + .executeTakeFirst(); + } + + /** BG55/BG86 — `UPDATE reservations SET metadata = ? WHERE id = ?`. */ + async setMetadata(id: number | string, metadata: string): Promise { + await this.kysely<{ reservations: { id: number; metadata: string } }>() + .updateTable('reservations') + .set({ metadata }) + .where('id', '=', id as number) + .execute(); + } + + /** BG56/BG87 — `SELECT * FROM reservations WHERE id = ?`, the mirrored-price re-select for the `reservation:updated` broadcast. */ + async getFull(id: number | string): Promise { + return await this.kysely<{ reservations: ReservationAllColumnsRow }>() + .selectFrom('reservations') + .selectAll() + .where('id', '=', id as number) + .executeTakeFirst(); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 4 (AirTrail's bookkeeping, `nest/integrations/airtrail*.ts` — + // owns zero tables of its own, R7) + airports' flight-endpoint backfill + // (`nest/airports/airports.service.ts`) — additive, per this task's own + // file-ownership rule ("additive methods on Reservations/ReservationEndpoints/ + // Days/Places/Users repositories"). `trip_id` is a `persist(false)` FK + // mirror — every trip-scoped read below filters by the `trip` ref property + // (the `findInTrip`/`listResyncCandidates` precedent above), never a raw + // column name. + // --------------------------------------------------------------------------- + + /** ATI1 (`airtrail-import.service.ts#importAirtrailFlights`, dedup: already-linked ids) — `SELECT external_id, metadata FROM reservations WHERE trip_id = ? AND external_source = 'airtrail'`. */ + async listAirtrailLinkedForTrip(trip_id: number): Promise<{ external_id: string | null; metadata: string | null }[]> { + return this.qb('r') + .select(['r.external_id', 'r.metadata']) + .where({ trip: trip_id, external_source: 'airtrail' }) + .execute<{ external_id: string | null; metadata: string | null }[]>('all', false); + } + + /** ATI2 (`airtrail-import.service.ts#importAirtrailFlights`, existing-flight signature dedup) — `SELECT r.id, r.reservation_time, r.metadata FROM reservations r WHERE r.trip_id = ? AND r.type = 'flight'`. */ + async listFlightReservationsForTrip(trip_id: number): Promise<{ id: number; reservation_time: string | null; metadata: string | null }[]> { + return this.qb('r') + .select(['r.id', 'r.reservation_time', 'r.metadata']) + .where({ trip: trip_id, type: 'flight' }) + .execute<{ id: number; reservation_time: string | null; metadata: string | null }[]>('all', false); + } + + /** + * ATI5 (`airtrail-import.service.ts#importAirtrailFlights`, multi-leg + * branch) — `UPDATE reservations SET external_source = 'airtrail', + * external_id = ?, external_owner_user_id = ?, sync_enabled = 0, + * external_synced_at = ? WHERE id = ?`. **CORRECTNESS GAP, pre-existing, + * report don't fix** (inventory §3b): neither this nor {@link + * linkAirtrailSingleFlight} is transactional with the preceding + * `ReservationsService.create` call — a failure between the two leaves an + * untracked reservation. Preserved exactly, not "fixed" with an added + * `uow.transactional` wrap. + */ + async linkAirtrailMultiLeg(id: number, external_id: string, external_owner_user_id: number, external_synced_at: string): Promise { + await this.nativeUpdate({ id }, { external_source: 'airtrail', external_id, external_owner_user_id, sync_enabled: 0, external_synced_at }); + } + + /** + * ATI6 (`airtrail-import.service.ts#importAirtrailFlights`, single-flight + * branch) — `UPDATE reservations SET external_source = 'airtrail', + * external_id = ?, external_owner_user_id = ?, sync_enabled = 1, + * external_hash = ?, external_synced_at = ? WHERE id = ?`. Same + * pre-existing tx gap as {@link linkAirtrailMultiLeg} — report don't fix. + */ + async linkAirtrailSingleFlight(id: number, external_id: string, external_owner_user_id: number, external_hash: string, external_synced_at: string): Promise { + await this.nativeUpdate({ id }, { external_source: 'airtrail', external_id, external_owner_user_id, sync_enabled: 1, external_hash, external_synced_at }); + } + + /** ATL2 (`airtrail-link.service.ts#detach`) — `UPDATE reservations SET sync_enabled = 0 WHERE id = ?`. */ + async setAirtrailSyncDisabled(id: number): Promise { + await this.nativeUpdate({ id }, { sync_enabled: 0 }); + } + + /** + * ATL4 (`airtrail-link.service.ts#pushReservationToAirtrail`) — `SELECT + * id, trip_id, external_id, external_owner_user_id, sync_enabled FROM + * reservations WHERE id = ? AND external_source = 'airtrail'`. `trip_id` + * via `columnRef` (the `listResyncCandidates` precedent above) — the + * mirror column, not a bare select. + */ + async findAirtrailLinked(id: number): Promise<{ id: number; trip_id: number; external_id: string | null; external_owner_user_id: number | null; sync_enabled: number | null } | undefined> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('r') + .select(['r.id', columnRef(platform, 'r.trip_id').as('trip_id'), 'r.external_id', 'r.external_owner_user_id', 'r.sync_enabled']) + .where({ id, external_source: 'airtrail' }) + .execute<{ id: number; trip_id: number; external_id: string | null; external_owner_user_id: number | null; sync_enabled: number | null } | undefined>('get', false); + } + + /** + * ATL5 (`airtrail-link.service.ts#pushReservationToAirtrail`, self-write + * suppression) / ATS2 (`airtrail-sync.service.ts#syncOwner`, byte-identical + * 2nd call site) — `UPDATE reservations SET external_hash = ?, + * external_synced_at = ? WHERE id = ?`. One method, two call sites (D4). + */ + async setAirtrailSyncStamp(id: number, external_hash: string, external_synced_at: string): Promise { + await this.nativeUpdate({ id }, { external_hash, external_synced_at }); + } + + /** + * ATS1 (`airtrail-sync.service.ts#syncOwner`) — `SELECT id, trip_id, + * external_id, external_hash FROM reservations WHERE external_source = + * 'airtrail' AND sync_enabled = 1 AND external_owner_user_id = ?`. + */ + async listAirtrailSyncCandidatesForOwner(owner_user_id: number): Promise<{ id: number; trip_id: number; external_id: string | null; external_hash: string | null }[]> { + const platform = this.getEntityManager().getPlatform(); + return this.qb('r') + .select(['r.id', columnRef(platform, 'r.trip_id').as('trip_id'), 'r.external_id', 'r.external_hash']) + .where({ external_source: 'airtrail', sync_enabled: 1, external_owner_user_id: owner_user_id }) + .execute<{ id: number; trip_id: number; external_id: string | null; external_hash: string | null }[]>('all', false); + } + + /** + * ATS3 (`airtrail-sync.service.ts#runAirtrailSync`) — `SELECT DISTINCT + * external_owner_user_id AS uid FROM reservations WHERE external_source = + * 'airtrail' AND sync_enabled = 1 AND external_owner_user_id IS NOT NULL`. + * **NON-HTTP ENTRYPOINT SOURCE** — the cron poll's owner list. + */ + async listAirtrailSyncOwners(): Promise { + const rows = await this.qb('r') + .select(['r.external_owner_user_id']) + .distinct() + .where({ external_source: 'airtrail', sync_enabled: 1, external_owner_user_id: { $ne: null } }) + .execute<{ external_owner_user_id: number }[]>('all', false); + return rows.map((row) => row.external_owner_user_id); + } + + /** + * AIR1 (`airports.service.ts#backfillFlightEndpoints`) — `SELECT r.id, + * r.metadata, r.reservation_time, r.reservation_end_time FROM reservations + * r WHERE r.type = 'flight' AND NOT EXISTS (SELECT 1 FROM + * reservation_endpoints e WHERE e.reservation_id = r.id)`. **NON-HTTP + * ENTRYPOINT SOURCE** — the boot-sweep backfill; idempotent via this NOT + * EXISTS guard, same shape as `Reservations.repository.ts`'s own + * `listUnplannedPlacesForPublicApi` NOT EXISTS precedent (`eb.not(eb.exists(...))`). + */ + async listFlightsMissingEndpoints(): Promise<{ id: number; metadata: string | null; reservation_time: string | null; reservation_end_time: string | null }[]> { + const rows = await this.kysely() + .selectFrom('reservations as r') + .select(['r.id', 'r.metadata', 'r.reservation_time', 'r.reservation_end_time']) + .where('r.type', '=', 'flight') + .where((eb) => eb.not(eb.exists(eb.selectFrom('reservation_endpoints as e').select('e.id').whereRef('e.reservation_id', '=', 'r.id')))) + .execute(); + return rows; + } + + /** AIR3 (`airports.service.ts#backfillFlightEndpoints`, looped) — `UPDATE reservations SET needs_review = 1 WHERE id = ?`. */ + async markNeedsReview(id: number): Promise { + await this.nativeUpdate({ id }, { needs_review: 1 }); + } + + /** RPL4/RPL5's shared select list, the columns of `RoadtripCarrierRow`. */ + private roadtripCarrierQuery() { + const platform = this.getEntityManager().getPlatform(); + return this.qb('r') + .select([ + 'r.id', + 'r.type', + 'r.title', + columnRef(platform, 'r.day_id').as('day_id'), + columnRef(platform, 'r.end_day_id').as('end_day_id'), + 'r.reservation_time', + 'r.reservation_end_time', + 'r.metadata', + 'r.day_plan_position', + ]); + } + + /** + * RPL4 (`RoadtripPlanService.carriers`) — `SELECT id, type, title, day_id, + * end_day_id, reservation_time, reservation_end_time, metadata, + * day_plan_position FROM reservations WHERE trip_id = ? AND type IN + * ('flight', 'train', 'ferry', 'cruise', 'bus', 'car') AND day_id IS NOT + * NULL`: the bookings that seam the drive, and the hire cars whose desks + * stand on it. + */ + async listRoadtripCarriers(trip_id: number): Promise { + return this.roadtripCarrierQuery() + .where({ trip: trip_id, type: { $in: ['flight', 'train', 'ferry', 'cruise', 'bus', 'car'] }, day: { $ne: null } }) + .execute('all', false); + } + + /** + * RPL5 (`RoadtripPlanService.undatedRides`) — `SELECT id, type, title, + * day_id, end_day_id, reservation_time, reservation_end_time, metadata, + * day_plan_position FROM reservations WHERE trip_id = ? AND day_id IS + * NULL`. Which of them count as rides is `undatedRides` (shared) alone. + */ + async listRoadtripUndated(trip_id: number): Promise { + return this.roadtripCarrierQuery() + .where({ trip: trip_id, day: null }) + .execute('all', false); + } +} + +/** + * RPL4/RPL5's row: a booking the traveller rides, with what the road trip's + * seam needs of it (the days and clocks at both ends, where the day plan + * seats it). The terminals and day positions are joined on in the service. + */ +export interface RoadtripCarrierRow { + id: number; + type: string; + title: string; + day_id: number | null; + end_day_id: number | null; + reservation_time: string | null; + reservation_end_time: string | null; + metadata: string | null; + day_plan_position: number | null; +} + +/** `SELECT *` — every scalar column of `Reservations`, RS35's shape. */ +export interface ReservationAllColumnsRow { + id: number; + trip_id: number; + day_id: number | null; + end_day_id: number | null; + place_id: number | null; + assignment_id: number | null; + title: string; + accommodation_id: string | null; + reservation_time: string | null; + reservation_end_time: string | null; + location: string | null; + confirmation_number: string | null; + notes: string | null; + status: string | null; + type: string | null; + created_at: string | null; + metadata: string | null; + day_plan_position: number | null; + needs_review: number; + external_source: string | null; + external_id: string | null; + external_owner_user_id: number | null; + external_synced_at: string | null; + sync_enabled: number | null; + external_hash: string | null; + url: string | null; + ingest_state: string; +} diff --git a/server/src/db/repositories/RoadtripDayBoundaries.repository.ts b/server/src/db/repositories/RoadtripDayBoundaries.repository.ts new file mode 100644 index 000000000..81741644c --- /dev/null +++ b/server/src/db/repositories/RoadtripDayBoundaries.repository.ts @@ -0,0 +1,101 @@ +import type { RoadtripDayBoundary } from '@trek/shared'; +import type { RoadtripDayBoundaries } from '../entities/RoadtripDayBoundaries.entity'; +import { columnRef } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `roadtrip_day_boundaries` — where a manual day ending sits (a stop, or a + * fraction along the driving leg between two consecutive stops), overriding + * the automatic day-ending rule daily travel times uses. + */ +export class RoadtripDayBoundariesRepository extends TrekRepository { + /** + * RB1 — `SELECT day_number, from_assignment_id, to_assignment_id, fraction + * FROM roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number`. + * + * `columnRef` for `from_assignment_id`/`to_assignment_id` — both are + * `persist(false)` mirrors of the `fromAssignment`/`toAssignment` + * relations, and selecting either bare (`'b.from_assignment_id'`) drops it + * from the result silently, EVEN with no active join on that relation + * (verified directly here, not assumed from `PlaceRatingsRepository + * .listForPlaces`'s narrower, join-only framing of the same trap). + */ + async listForTrip(trip_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('b') + .select(['b.day_number', columnRef(platform, 'b.from_assignment_id'), columnRef(platform, 'b.to_assignment_id'), 'b.fraction']) + .where({ trip: trip_id }) + .orderBy({ day_number: 'asc' }) + .execute('all', false); + } + + /** + * RB3 — `INSERT INTO roadtrip_day_boundaries (trip_id, day_number, + * from_assignment_id, to_assignment_id, fraction) VALUES (?×5) ON CONFLICT + * (trip_id, day_number) DO UPDATE SET from_assignment_id = + * excluded.from_assignment_id, to_assignment_id = excluded.to_assignment_id, + * fraction = excluded.fraction`. + * + * `onConflictFields: ['trip', 'day_number']` — the composite PK the + * migration and the entity's own `[PrimaryKeyProp]?: ['trip', + * 'day_number']` both declare; `onConflictMergeFields` names the three + * relation/scalar properties the legacy SET list names (`fromAssignment`/ + * `toAssignment`, not their `persist(false)` `_id` mirrors — + * `OauthConsentsRepository.upsertGrant`'s precedent), so a conflict never + * touches `trip`/`day_number` themselves (the key) or any future column + * this entity gains. + */ + async upsertBoundary(trip_id: number, boundary: RoadtripDayBoundary): Promise { + await this.upsert( + { + trip: trip_id, + day_number: boundary.day_number, + fromAssignment: boundary.from_assignment_id, + toAssignment: boundary.to_assignment_id, + fraction: boundary.fraction, + }, + { onConflictFields: ['trip', 'day_number'], onConflictAction: 'merge', onConflictMergeFields: ['fromAssignment', 'toAssignment', 'fraction'] }, + ); + } + + /** RB4 — `DELETE FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number = ?`, silent on a miss. */ + async deleteForDay(trip_id: number, day_number: number): Promise { + await this.nativeDelete({ trip: trip_id, day_number }); + } + + /** + * RB5 (`DaysService.shiftBoundariesBack`, the dated append) — `SELECT + * day_number FROM roadtrip_day_boundaries WHERE trip_id = ? AND day_number + * >= ? ORDER BY day_number DESC`. + */ + async listDayNumbersFrom(trip_id: number, from_number: number): Promise { + const rows = await this.qb('b') + .select(['b.day_number']) + .where({ trip: trip_id, day_number: { $gte: from_number } }) + .orderBy({ day_number: 'desc' }) + .execute<{ day_number: number }[]>('all', false); + return rows.map((r) => r.day_number); + } + + /** + * RB6 (`DayRemovalService.shiftBoundaries`) — `SELECT day_number FROM + * roadtrip_day_boundaries WHERE trip_id = ? ORDER BY day_number`. + */ + async listDayNumbers(trip_id: number): Promise { + const rows = await this.qb('b') + .select(['b.day_number']) + .where({ trip: trip_id }) + .orderBy({ day_number: 'asc' }) + .execute<{ day_number: number }[]>('all', false); + return rows.map((r) => r.day_number); + } + + /** + * RB7 (`DaysService.shiftBoundariesBack`, `DayRemovalService.shiftBoundaries`) + * — `UPDATE roadtrip_day_boundaries SET day_number = ? WHERE trip_id = ? AND + * day_number = ?`. + */ + async moveDayNumber(trip_id: number, from_number: number, to_number: number): Promise { + await this.nativeUpdate({ trip: trip_id, day_number: from_number }, { day_number: to_number }); + } +} diff --git a/server/src/db/repositories/RoadtripDayTracks.repository.ts b/server/src/db/repositories/RoadtripDayTracks.repository.ts new file mode 100644 index 000000000..cf62573a2 --- /dev/null +++ b/server/src/db/repositories/RoadtripDayTracks.repository.ts @@ -0,0 +1,71 @@ +import type { RoadtripDayTrack } from '@trek/shared'; +import type { RoadtripDayTracks } from '../entities/RoadtripDayTracks.entity'; +import { columnRef } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `roadtrip_day_tracks` — which recorded track (a `places` row with route + * geometry) a day's drive was fitted to, and how far the fitted route still + * strayed from it. + */ +export class RoadtripDayTracksRepository extends TrekRepository { + /** RT7 — `DELETE FROM roadtrip_day_tracks WHERE day_id = ?` (`createMany`'s explicit `track: null` clear). */ + async deleteForDay(day_id: number): Promise { + await this.nativeDelete({ day: day_id }); + } + + /** + * RT8 — `INSERT INTO roadtrip_day_tracks (day_id, place_id, stray_km) + * VALUES (?, ?, ?) ON CONFLICT(day_id) DO UPDATE SET place_id = + * excluded.place_id, stray_km = excluded.stray_km`. + * + * `onConflictFields: ['day']` — the RELATION property, not the `day_id` + * scalar mirror (`persist(false)`, not a real column MikroORM's upsert + * builder can target) — the same naming `PlaceRatingsRepository + * .upsertRating`/`TripMembersRepository`'s own composite-key upserts use + * for a relation-backed conflict target. `RoadtripDayTracks.day` is this + * table's PRIMARY KEY (a nullable one-to-one, per Task 0's `RULE12` + * finding — ruled NOT a generator defect, matching the `VacayUserSettings` + * precedent), so the single-element list targets the table's actual PK, + * `roadtrip_day_tracks(day_id)`. Proven, not assumed: `RoadtripDayTracks + * .repository.test.ts` (`UPSERTTRACKREPO-*`) upserts the SAME `day_id` + * twice with different `place_id`/`stray_km` and asserts exactly one row + * survives with the SECOND call's values — if `onConflictFields: ['day']` + * resolved to anything other than the physical `day_id` column, SQLite's + * own `ON CONFLICT` target-matching would either throw (no matching + * unique index) or let the second call's row collide on the real PK and + * raise `UNIQUE constraint failed`, so the test is a genuine proof, not a + * behavioural coincidence. `created_at` is named in neither + * `onConflictMergeFields` NOR the insert payload beyond its own default, + * so a conflict never refreshes it (pinned — the legacy statement's SET + * list never named it either). + */ + async upsertTrack(day_id: number, place_id: number, stray_km: number | null): Promise { + await this.upsert( + { day: day_id, place: place_id, stray_km }, + { onConflictFields: ['day'], onConflictAction: 'merge', onConflictMergeFields: ['place', 'stray_km'] }, + ); + } + + /** + * RT12 — `SELECT t.day_id, t.place_id, t.stray_km FROM roadtrip_day_tracks + * t JOIN days d ON d.id = t.day_id WHERE d.trip_id = ? ORDER BY t.day_id`. + * + * `columnRef` for both `day_id` (actively joined via `t.day`) and + * `place_id` (not joined here, but selected the same defensive way for + * consistency with every other persist(false) mirror in this cluster — + * `PlaceRatingsRepository.listForPlaces`'s docstring is the verified trap + * this sidesteps). Ordered by `d.id` (`= t.day_id` by the join condition) + * rather than the mirror column itself, the same reason + * `RoadtripViasRepository.listForTrip` orders by `d.id`. + */ + async listForTrip(trip_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('t') + .join('t.day', 'd') + .select([columnRef(platform, 't.day_id'), columnRef(platform, 't.place_id'), 't.stray_km']) + .where({ 'd.trip': trip_id }) + .orderBy({ 'd.id': 'asc' }) + .execute('all', false); + } +} diff --git a/server/src/db/repositories/RoadtripPreferences.repository.ts b/server/src/db/repositories/RoadtripPreferences.repository.ts new file mode 100644 index 000000000..66224967a --- /dev/null +++ b/server/src/db/repositories/RoadtripPreferences.repository.ts @@ -0,0 +1,35 @@ +import type { RoadtripPreferences } from '../entities/RoadtripPreferences.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** RPF1's raw row — the JSON parse (with its raw-string fallback) stays in the service. */ +export interface RoadtripPreferenceRow { + key: string; + value: string; +} + +/** + * `roadtrip_preferences` — a trip-scoped key/value store, one row per + * preference key, values JSON-encoded in the service. + */ +export class RoadtripPreferencesRepository extends TrekRepository { + /** RPF1 — `SELECT key, value FROM roadtrip_preferences WHERE trip_id = ?` (no ORDER BY, matching the legacy statement). */ + async listForTrip(trip_id: number): Promise { + return await this.qb('r') + .select(['r.key', 'r.value']) + .where({ trip: trip_id }) + .execute('all', false); + } + + /** + * RPF3 — `INSERT INTO roadtrip_preferences (trip_id, key, value) VALUES + * (?, ?, ?) ON CONFLICT(trip_id, key) DO UPDATE SET value = excluded.value`. + * + * `onConflictFields: ['trip', 'key']` — the composite PK + * (`[PrimaryKeyProp]?: ['trip', 'key']`); `onConflictMergeFields: + * ['value']` matches the legacy SET list exactly (never touches `trip`/ + * `key`, the key itself). + */ + async upsertValue(trip_id: number, key: string, value: string): Promise { + await this.upsert({ trip: trip_id, key, value }, { onConflictFields: ['trip', 'key'], onConflictAction: 'merge', onConflictMergeFields: ['value'] }); + } +} diff --git a/server/src/db/repositories/RoadtripVias.repository.ts b/server/src/db/repositories/RoadtripVias.repository.ts new file mode 100644 index 000000000..0049ffb13 --- /dev/null +++ b/server/src/db/repositories/RoadtripVias.repository.ts @@ -0,0 +1,175 @@ +import type { RoadtripVia } from '@trek/shared'; +import type { AnchoredVia } from '@trek/shared/roadtrip'; +import type { RoadtripVias } from '../entities/RoadtripVias.entity'; +import { columnRef, maxOf } from '../dialect/sql-functions'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +const _roadtripViaRowKeys: AssertRowKeys = true; + +/** RT18/AC13 — the OLD anchor a re-anchor reads before any write. */ +export interface RoadtripViaAnchorRow { + id: number; + after_order_index: number; + sequence: number; +} + +/** RT21 — the same day's vias without `sequence` (the renumber pass reads it separately). */ +export interface RoadtripViaLegRow { + id: number; + after_order_index: number; +} + +/** + * `roadtrip_vias` — the points a day's drive is routed through without stopping. + * + * `RT2` (`listForDay`) is the ONE copy of that projection: `AccommodationsService` + * (AC16, Task 3) and `AssignmentsService` (AS23, `listDayVias`) both call it now, + * retiring the "deliberate duplicate" the legacy `AssignmentsService` docstring + * used to carry. + */ +export class RoadtripViasRepository extends TrekRepository { + /** RT2/AC16/AS23 — `SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE day_id = ? ORDER BY after_order_index, sequence, id`. */ + async listForDay(day_id: number): Promise { + const vias = await this.find({ day: day_id }, { orderBy: { after_order_index: 'asc', sequence: 'asc', id: 'asc' } }); + return vias.map((v) => toRow(v) as RoadtripVia); + } + + /** + * RT3 — `SELECT v.id, v.day_id, v.after_order_index, v.sequence, v.lat, v.lng, + * v.created_at FROM roadtrip_vias v JOIN days d ON d.id = v.day_id WHERE + * d.trip_id = ? ORDER BY v.day_id, v.after_order_index, v.sequence, v.id`. + * + * Ordered by `d.id` rather than the joined table's own `v.day_id` (always the + * same value, by the join condition): `v.day_id` is a `persist(false)` mirror + * of the `day` relation and selecting or ordering by it directly, alongside an + * active join on that same relation, is the exact trap `PlaceRatingsRepository + * .listForPlaces`'s docstring documents (the column silently aliases off the + * JOINED entity instead) — `columnRef` below sidesteps it for the SELECT list + * the same way `AssignmentWithPlaceRow`'s `category_id` does; ordering by + * `d.id` sidesteps it for the ORDER BY without needing a third mechanism. + */ + async listForTrip(trip_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.qb('v') + .join('v.day', 'd') + .select(['v.id', columnRef(platform, 'v.day_id'), 'v.after_order_index', 'v.sequence', 'v.lat', 'v.lng', 'v.created_at']) + .where({ 'd.trip': trip_id }) + .orderBy({ 'd.id': 'asc', 'v.after_order_index': 'asc', 'v.sequence': 'asc', 'v.id': 'asc' }) + .execute('all', false); + } + + /** + * RT4/RT10 — `SELECT COALESCE(MAX(sequence) + 1, 0) AS next FROM + * roadtrip_vias WHERE day_id = ? AND after_order_index = ?`, read as a plain + * `MAX()` (`maxOf`) folded to the same result in JS: no rows (or an + * all-`NULL` `sequence`, impossible here since the column is `NOT NULL + * DEFAULT 0`) reads back `null`, and `(null ?? -1) + 1 === 0`, matching + * `COALESCE(MAX(sequence) + 1, 0)`'s own null branch; a real max `m` reads + * back `m`, and `(m ?? -1) + 1 === m + 1`, matching `MAX(sequence) + 1`. + */ + async nextSequence(day_id: number, after_order_index: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('v') + .select([maxOf(platform, 'v.sequence', 'max_seq')]) + .where({ day: day_id, after_order_index }) + .execute<{ max_seq: number | null } | undefined>('get', false); + return (row?.max_seq ?? -1) + 1; + } + + /** RT5/RT11/TP43 — `INSERT INTO roadtrip_vias (day_id, after_order_index, sequence, lat, lng) VALUES (?×5)`, returning the new id. */ + async insertVia(input: { day_id: number; after_order_index: number; sequence: number; lat: number; lng: number }): Promise { + return await this.insert({ + day: input.day_id, + after_order_index: input.after_order_index, + sequence: input.sequence, + lat: input.lat, + lng: input.lng, + }); + } + + /** RT9 — `DELETE FROM roadtrip_vias WHERE day_id = ? AND after_order_index = ?` (one leg's own vias, `replace_legs`). */ + async deleteLeg(day_id: number, after_order_index: number): Promise { + await this.nativeDelete({ day: day_id, after_order_index }); + } + + /** RT14 — `SELECT id FROM roadtrip_vias WHERE id = ? AND day_id = ?`, the scoping check `move`'s two un-transacted UPDATEs (R7) rely on. */ + async existsInDay(id: number, day_id: number): Promise { + const row = await this.qb('v') + .select(['v.id']) + .where({ id, day: day_id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * RT15 — `UPDATE roadtrip_vias SET lat = ?, lng = ? WHERE id = ?`. No + * `day_id` scoping, matching the legacy statement exactly (R7 — an + * un-transacted check-then-act behind {@link existsInDay}; flagged, not + * fixed). + */ + async moveCoordinates(id: number, lat: number, lng: number): Promise { + await this.nativeUpdate({ id }, { lat, lng }); + } + + /** RT16 — `UPDATE roadtrip_vias SET lat = ?, lng = ?, after_order_index = ? WHERE id = ?`. Same R7 scoping note as {@link moveCoordinates}. */ + async moveCoordinatesAndAnchor(id: number, lat: number, lng: number, after_order_index: number): Promise { + await this.nativeUpdate({ id }, { lat, lng, after_order_index }); + } + + /** RT18/AC13 — `SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?` (no ORDER BY — matches the legacy statement). */ + async listAnchors(day_id: number): Promise { + return await this.qb('v') + .select(['v.id', 'v.after_order_index', 'v.sequence']) + .where({ day: day_id }) + .execute('all', false); + } + + /** RT21 — `SELECT id, after_order_index FROM roadtrip_vias WHERE day_id = ?` (no ORDER BY — the caller's own JS sort is total, §18's RT21 note). */ + async listLegs(day_id: number): Promise { + return await this.qb('v') + .select(['v.id', 'v.after_order_index']) + .where({ day: day_id }) + .execute('all', false); + } + + /** RT19/AS21 — `DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?`, silent on a miss (`reanchor`'s per-id remove). */ + async deleteInDay(id: number, day_id: number): Promise { + await this.nativeDelete({ id, day: day_id }); + } + + /** RT23 — the same statement as {@link deleteInDay}, but the caller (`remove`) needs the row count to answer 404 on a miss. */ + async deleteInDayCounted(id: number, day_id: number): Promise { + return await this.nativeDelete({ id, day: day_id }); + } + + /** RT20/AC15/AS22 — `UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?`, silent on a miss. */ + async setAnchor(id: number, day_id: number, after_order_index: number): Promise { + await this.nativeUpdate({ id, day: day_id }, { after_order_index }); + } + + /** RT22 — `UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?`, every row unconditionally (unlike AC17). */ + async setSequence(id: number, day_id: number, sequence: number): Promise { + await this.nativeUpdate({ id, day: day_id }, { sequence }); + } + + /** RT24 — `SELECT id, day_id, after_order_index, sequence, lat, lng, created_at FROM roadtrip_vias WHERE id = ?`. */ + async findById(id: number): Promise { + const via = await this.findOne({ id }); + return via ? (toRow(via) as RoadtripVia) : null; + } + + /** + * AS20 (`AssignmentsService.reanchorVias`) — `SELECT id, after_order_index, + * lat, lng FROM roadtrip_vias WHERE day_id = ?`, the `AnchoredVia` shape + * `reanchorByStopOrder` (`@trek/shared/roadtrip`) reads — a different + * projection from {@link listAnchors} (which carries `sequence`, not + * `lat`/`lng`), so it stays its own method rather than reusing one. + */ + async listForReanchor(day_id: number): Promise { + return await this.qb('v') + .select(['v.id', 'v.after_order_index', 'v.lat', 'v.lng']) + .where({ day: day_id }) + .execute('all', false); + } +} diff --git a/server/src/db/repositories/RouteUsageDaily.repository.ts b/server/src/db/repositories/RouteUsageDaily.repository.ts new file mode 100644 index 000000000..bd9c92537 --- /dev/null +++ b/server/src/db/repositories/RouteUsageDaily.repository.ts @@ -0,0 +1,135 @@ +import type { RouteUsageDaily } from '../entities/RouteUsageDaily.entity'; +import { nowDateOffset } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A `route_usage_daily` row exactly as `SELECT *` reads it (RU3's shape, + * snake_case). Named `RouteUsageDailyRow`, not `RouteUsageDayRow` — + * `@trek/shared` already exports a `RouteUsageDayRow` (the camelCase API + * shape `RouteUsageService.rows()` maps this into), and `route-usage.service.ts` + * imports both; a same-named repository export would collide. + */ +export interface RouteUsageDailyRow { + day: string; + profile: string; + surface: string; + self_hosted: number; + requests: number; + waypoints: number; + km: number; + failed: number; +} + +interface RouteUsageDailyKyselyDB { + route_usage_daily: { + day: string; + profile: string; + surface: string; + self_hosted: number; + requests: number; + waypoints: number; + km: number; + failed: number; + }; +} + +/** + * `route_usage_daily` — a composite-PK `(day, profile, surface, self_hosted)` + * table (`RouteUsageDaily.entity.ts`'s `[PrimaryKeyProp]`, matching R4). + * Kysely throughout: RU2's `ON CONFLICT ... DO UPDATE SET count = count + + * excluded.count` shape (an ADDITIVE upsert) is genuinely beyond `em.upsert` + * (`onConflictAction: 'merge'` always OVERWRITES — see {@link record}'s own + * docstring), and once RU2 needs the escape hatch, the rest of the class + * stays there too rather than mixing QB and Kysely for the same table. + */ +export class RouteUsageDailyRepository extends TrekRepository { + /** + * RU2 (`RouteUsageService.record`, inside the caller's own `uow.transactional`, + * once per batch entry) — `INSERT INTO route_usage_daily (day, profile, + * surface, self_hosted, requests, waypoints, km, failed) VALUES + * (date('now'), ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(day, profile, surface, + * self_hosted) DO UPDATE SET requests = requests + excluded.requests, + * waypoints = waypoints + excluded.waypoints, km = km + excluded.km, + * failed = failed + excluded.failed`. + * + * **R4**: an ADDITIVE upsert, not a blind overwrite — `em.upsert`'s + * `onConflictAction: 'merge'` (the shape `PlaceDetailsCacheRepository + * .upsertEntry` already uses successfully for a composite-key upsert, R8) + * has no way to express `col = col + excluded.col`: merge always REPLACES + * the value with + * the new one, it cannot reference the pre-existing row's own value in the + * SET expression. Kysely's `onConflict(...).doUpdateSet(...)`, each column + * an `eb(col, '+', eb.ref('excluded.col'))` binary-operator expression + * (the `DocumentSyncItemsRepository`-precedent `eb.ref('excluded.x')` shape, + * widened here from a blind `eb.ref(...)` re-assignment to a real `+`), + * is the only shape that renders `col = col + excluded.col`. + * + * `day` is a plain bound parameter, not `date('now')` spelled into the + * statement: the caller resolves "today" ONCE per batch (`todayUtc()`, + * the 3f/`TripsRepository.lastStartedTrip` precedent for a BARE + * `date('now')` with no offset — Task 0's report) rather than this + * repository re-deriving a SQLite date function inline for every entry. + * The legacy statement re-evaluated `date('now')` on every loop + * iteration; a batch's entries import in the same tick, so this cannot + * observably diverge except across a real UTC-midnight boundary mid-loop, + * which the legacy code was equally exposed to (both resolve "today" once + * per entry's own execution instant — the JS resolution just does it a + * few microseconds earlier, at the loop's start rather than inside each + * statement). + */ + async record(entry: { + day: string; + profile: string; + surface: string; + self_hosted: number; + requests: number; + waypoints: number; + km: number; + failed: number; + }): Promise { + await this.kysely() + .insertInto('route_usage_daily') + .values(entry) + .onConflict((oc) => + oc.columns(['day', 'profile', 'surface', 'self_hosted']).doUpdateSet({ + requests: (eb) => eb('requests', '+', eb.ref('excluded.requests')), + waypoints: (eb) => eb('waypoints', '+', eb.ref('excluded.waypoints')), + km: (eb) => eb('km', '+', eb.ref('excluded.km')), + failed: (eb) => eb('failed', '+', eb.ref('excluded.failed')), + }), + ) + .execute(); + } + + /** RU3 (`RouteUsageService.rows`) — `SELECT * FROM route_usage_daily ORDER BY day DESC, profile, surface`. */ + async rows(): Promise { + const result = await this.kysely() + .selectFrom('route_usage_daily') + .selectAll() + .orderBy('day', 'desc') + .orderBy('profile', 'asc') + .orderBy('surface', 'asc') + .execute(); + return result; + } + + /** + * RU4 (`RouteUsageService.purgeExpired`) — `DELETE FROM route_usage_daily + * WHERE day < date('now', ?)`, `?` bound to `` `-${RETENTION_DAYS} days` ``. + * `nowDateOffset` (Task 0's groundwork helper): `RETENTION_DAYS` + * (`route-usage.service.ts`) is a compile-time module constant, so the + * legacy statement's bound-modifier form and a literal day-count offset + * are the same shape at this call site. `PlaceShadowPicksRepository + * .expireStale`'s `nativeDelete({ created_at: { $lt: nowMinusDays(...) } + * })` is the closest working precedent for the filter shape. + */ + async purgeExpired(retentionDays: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.nativeDelete({ day: { $lt: nowDateOffset(platform, -retentionDays) } }); + } + + /** RU5 (`RouteUsageService.clear`) — `DELETE FROM route_usage_daily`, unscoped (the admin's "start over"). */ + async clear(): Promise { + return this.nativeDelete({}); + } +} diff --git a/server/src/db/repositories/SchemaVersion.repository.ts b/server/src/db/repositories/SchemaVersion.repository.ts new file mode 100644 index 000000000..7523911d3 --- /dev/null +++ b/server/src/db/repositories/SchemaVersion.repository.ts @@ -0,0 +1,4 @@ +import type { SchemaVersion } from '../entities/SchemaVersion.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class SchemaVersionRepository extends TrekRepository {} diff --git a/server/src/db/repositories/SchoolHolidayCountries.repository.ts b/server/src/db/repositories/SchoolHolidayCountries.repository.ts new file mode 100644 index 000000000..3aec11875 --- /dev/null +++ b/server/src/db/repositories/SchoolHolidayCountries.repository.ts @@ -0,0 +1,69 @@ +import type { SchoolHolidayCountries } from '../entities/SchoolHolidayCountries.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `school_holiday_countries` row as the API emits it (SH1/SH3 — `SchoolHolidayCountryRequest`'s exact shape, `@trek/shared`). */ +export interface SchoolHolidayCountryRow { + code: string; + name: string; +} + +const _schoolHolidayCountryRowKeys: AssertRowKeys = true; + +/** `school_holiday_countries`'s single-table read/write shape (Plan 3f Task 2, SH1/SH3/SH4/SH6). */ +export class SchoolHolidayCountriesRepository extends TrekRepository { + /** SH1 — `SELECT code, name FROM school_holiday_countries ORDER BY name, code`. */ + async list(): Promise { + const countries = await this.find({}, { orderBy: { name: 'asc', code: 'asc' } }); + return countries.map((country) => toRow(country) as SchoolHolidayCountryRow); + } + + /** + * SH3 — `SELECT code, name FROM school_holiday_countries WHERE code = ?`. + * Named `findByCode`, not `find` (the base `EntityRepository.find` name is + * already taken — every other repository in this program follows the same + * `findById`/`findByCode`-style rename, e.g. `CategoriesRepository + * .findById`). + */ + async findByCode(code: string): Promise { + const country = await this.findOne({ code }); + return country ? (toRow(country) as SchoolHolidayCountryRow) : null; + } + + /** + * SH4 — `INSERT OR IGNORE INTO school_holiday_countries (code, name) + * VALUES (?, ?)`. `code` is the table's own PRIMARY KEY (no separate + * `uniques` constraint to name), so a plain `ON CONFLICT DO NOTHING` with + * no column list (the `BudgetCategoryOrderRepository.insertIgnore` + * precedent) covers it. Returns whether a row was actually inserted + * (`result.changes > 0` in the legacy code) — `numInsertedOrUpdatedRows` + * is Kysely's dialect-general affected-row count, 0 when the conflict + * target (an existing `code`) already existed and the insert was + * ignored — the `TripPhotosRepository.insertIgnore` precedent for + * reporting this back to the caller as a boolean instead of swallowing it. + */ + async insertIgnore(country: SchoolHolidayCountryRow): Promise { + const result = await this.kysely() + .insertInto('school_holiday_countries') + .values({ code: country.code, name: country.name }) + .onConflict((oc) => oc.doNothing()) + .executeTakeFirst(); + return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n; + } + + /** + * SH6 — `DELETE FROM school_holiday_countries WHERE code = ?`. The legacy + * caller (`SchoolHolidaysService.deleteCountry`) always pre-checks + * existence (SH3, via `country()`) and the absence of any referencing + * region (SH5) inside the same transaction before calling this, so the + * affected-row count is never consulted — matching that, this returns + * `void`, not a count. + */ + async remove(code: string): Promise { + await this.nativeDelete({ code }); + } +} + +interface SchoolHolidayCountriesKyselyDB { + school_holiday_countries: { code: string; name: string }; +} diff --git a/server/src/db/repositories/SchoolHolidayPeriods.repository.ts b/server/src/db/repositories/SchoolHolidayPeriods.repository.ts new file mode 100644 index 000000000..0fe2b9838 --- /dev/null +++ b/server/src/db/repositories/SchoolHolidayPeriods.repository.ts @@ -0,0 +1,62 @@ +import type { SchoolHolidayPeriod } from '@trek/shared'; +import type { SchoolHolidayPeriods } from '../entities/SchoolHolidayPeriods.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `school_holiday_periods`'s single-table read/write shape (Plan 3f Task 2, + * SH8/SH10/SH11/SH15). No `AssertRowKeys` pairing here (unlike this + * program's usual `toRow(entity) as Row` shape): the legacy SH8 statement + * itself renames `start_date`/`end_date` to `startDate`/`endDate` via SQL + * aliasing (the API's own `SchoolHolidayPeriod` shape, `@trek/shared`), so + * the equivalent here is a plain field-renaming `.map()` after a narrowed + * `find` (the `McpTokensRepository.listByUserAndKind` precedent for a + * `fields`-narrowed-then-mapped projection), not a row interface whose keys + * are expected to match the entity's column names 1:1. + */ +export class SchoolHolidayPeriodsRepository extends TrekRepository { + /** + * SH8 — `SELECT name, start_date AS startDate, end_date AS endDate FROM + * school_holiday_periods WHERE region_id = ? ORDER BY start_date, + * end_date, name`. `id`/`region_id` are not selected — the legacy + * projection never returns them — so `fields` narrows to exactly the + * three legacy columns; none of the three is a `persist(false)` shadow + * scalar (that trap only applies to `region_id`, not selected here), so + * narrowing is safe. + */ + async listForRegion(region_id: number): Promise { + const periods = await this.find( + { region: region_id }, + { fields: ['name', 'start_date', 'end_date'], orderBy: { start_date: 'asc', end_date: 'asc', name: 'asc' } }, + ); + return periods.map((period) => ({ name: period.name, startDate: period.start_date, endDate: period.end_date })); + } + + /** + * SH10/SH15 — `DELETE FROM school_holiday_periods WHERE region_id = ?`, + * the replace-all half of `writePeriods` (called from both `createRegion` + * and `updateRegion`, plus `deleteRegion`'s own cleanup) and always + * called from inside the surrounding `uow.transactional` block, same as + * the legacy code. + */ + async deleteForRegion(region_id: number): Promise { + await this.nativeDelete({ region: region_id }); + } + + /** + * SH11 — `INSERT INTO school_holiday_periods (region_id, name, + * start_date, end_date) VALUES (?, ?, ?, ?)`, the legacy `.prepare()` + * -then-loop shape (the ONE such shape in this whole plan) — one batched + * `insertMany` call here instead of N round trips, same end state (every + * period row written), called only from inside the surrounding + * transaction (never on its own). Named `insertPeriods`, not `insertMany` + * (the base `EntityRepository.insertMany` name is already taken). Empty + * `holidays` is a legitimate legacy no-op (the loop over an empty array + * writes nothing) — guarded here rather than handed to `insertMany`, + * matching every other batch-write method in this program + * (`AssignmentParticipantsRepository.insertIgnore`'s precedent). + */ + async insertPeriods(region_id: number, holidays: SchoolHolidayPeriod[]): Promise { + if (holidays.length === 0) return; + await this.insertMany(holidays.map((holiday) => ({ region: region_id, name: holiday.name, start_date: holiday.startDate, end_date: holiday.endDate }))); + } +} diff --git a/server/src/db/repositories/SchoolHolidayRegions.repository.ts b/server/src/db/repositories/SchoolHolidayRegions.repository.ts new file mode 100644 index 000000000..3d4233eb0 --- /dev/null +++ b/server/src/db/repositories/SchoolHolidayRegions.repository.ts @@ -0,0 +1,140 @@ +import { collateNoCase, columnIncrementedBy } from '../dialect/sql-functions'; +import type { SchoolHolidayRegions } from '../entities/SchoolHolidayRegions.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A `school_holiday_regions` row as the API emits it, MINUS the synthesized + * `country || '-MANUAL-' || id AS code` column (SH2/SH7) — computed in + * `SchoolHolidaysService` from `country`/`id` instead of in SQL: it is pure, + * derived data with no WHERE clause anywhere ever filtering on it (unlike + * `VacayHolidayCalendars.region`, which stores this same string as a plain + * value to MATCH against), so a JS template string after the read is exactly + * as correct as the legacy SQL concatenation and needs no `concat()` dialect + * helper. + */ +export interface SchoolHolidayRegionRow { + id: number; + country: string; + name: string; + revision: number; +} + +const _schoolHolidayRegionRowKeys: AssertRowKeys = true; + +/** + * `school_holiday_regions`'s single-table read/write shape (Plan 3f Task 2, + * SH2/SH5/SH7/SH9/SH12/SH13/SH16). `country` is a `persist(false)` shadow + * scalar of the real relation (`countryRef`, `.manyToOne(SchoolHolidayCountries) + * .ref().joinColumn('country')`) — §14 of the plan's inventory flagged this + * column as possibly FK-less; reading the entity directly (not assumed) shows + * it IS FK-backed, just under the inverted naming this table's entity + * generator produced (the relation carries the `Ref` suffix, the bare column + * name is the plain-scalar shadow — the opposite of `SchoolHolidayPeriods + * .region`/`.region_id`). A plain, unrestricted `find`/`findOne` (no `fields` + * narrowing) hydrates `country` from the physical column exactly like any + * other persist(false) shadow twin in this program (`TripPhotosRepository + * .listTripIdsSharedForPhoto`'s docstring names the same trap for a narrowed + * read) — every read below reads the whole row, so this never bites here. + * Writes go through `countryRef` (the relation property), never `country` + * itself (`persist(false)` silently drops a direct write), matching every + * other shadow-FK write in this program (`DayNotesRepository.create`'s + * `day: input.day_id` precedent). + */ +export class SchoolHolidayRegionsRepository extends TrekRepository { + /** SH2 — `SELECT *, country || '-MANUAL-' || id AS code FROM school_holiday_regions ORDER BY name, id` (code column computed by the service, see this file's own docstring). */ + async list(): Promise { + const regions = await this.find({}, { orderBy: { name: 'asc', id: 'asc' } }); + return regions.map((region) => toRow(region) as SchoolHolidayRegionRow); + } + + /** + * SH7 — same base text as SH2, single row, `WHERE id = ?`, no `ORDER BY`. + * Named `findById`, not `find` (the base `EntityRepository.find` name is + * already taken). + */ + async findById(id: number): Promise { + const region = await this.findOne({ id }); + return region ? (toRow(region) as SchoolHolidayRegionRow) : null; + } + + /** + * SH5 — `SELECT id FROM school_holiday_regions WHERE country = ? LIMIT 1` + * (`deleteCountry`'s "no region still references this country" guard). + * Also the additive consumer Plan 3f Task 5 (`vacay.service.ts + * #validateManualRegion`, VC45) calls, per the plan's own cross-domain + * sequencing note — this repository lands first, so Task 5 gets a + * populated method instead of leaving its own read raw a beat longer. + */ + async existsForCountry(country: string): Promise { + const row = await this.findOne({ country }, { fields: ['id'] }); + return row !== null; + } + + /** + * SH9 (`checkName`'s duplicate-name guard) — `SELECT id FROM + * school_holiday_regions WHERE country = ? AND name = ? COLLATE NOCASE + * AND id != ?`. Uses `collateNoCase` (Plan 3f Task 0, R9) as the filter + * KEY, the same `{ [lower(platform, col)]: value }` shape + * `Users.repository.ts#findIdByUsernameCI` uses for its own + * case-insensitive collision check — NOT `lower()`: the two helpers are + * proven to agree on every input tested (Task 0's report, SQLF-061) but + * are textually different SQL constructs matching two textually different + * legacy statements, and the brief for this task is explicit that + * `collateNoCase` is the one to use here, not a substitution. + * `collateNoCase` renders the COLLATE on the column side (`name COLLATE + * NOCASE = ?`) where the legacy statement spells it on the value side + * (`name = ? COLLATE NOCASE`) — row-identical (SQLF-060), not just + * assumed, per Task 0's own concern note for this task. + */ + async findIdByNameCI(country: string, name: string, excludeId: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { country, [collateNoCase(platform, 'name')]: name, id: { $ne: excludeId } }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + /** + * SH12 — `INSERT INTO school_holiday_regions (country, name) VALUES (?, + * ?)`. `revision` is omitted from the write, same as the legacy column + * list — the entity's own default (`p.integer().default(1)`, verified + * against `Migration20200101033600_create_school_holiday_countries.ts`'s + * `revision INTEGER NOT NULL DEFAULT 1`) applies, matching the legacy + * column default exactly. Named `insertRegion`, not `insert` (the base + * `EntityRepository.insert` name is already taken). + */ + async insertRegion(country: string, name: string): Promise { + return this.insert({ countryRef: country, name }); + } + + /** + * SH13 (`updateRegion`) — `UPDATE school_holiday_regions SET name = ?, + * revision = revision + 1 WHERE id = ? AND revision = ?`. A genuine + * optimistic-concurrency ("lost update") guard, not a cosmetic detail: + * this method returns the raw affected-row count (0 or 1) and does NOT + * throw or interpret it itself — `nativeUpdate`'s own return value IS + * that count, so nothing here can silently swallow a 0-row result the + * way an ORM `nativeUpdate` wrapped in a helper that only returns the + * updated entity would. `SchoolHolidaysService.updateRegion` is the one + * that turns a 0 into `ConflictException`. `columnIncrementedBy` + * (`InviteTokensRepository.incrementUsedCount`'s precedent) for the + * sibling-column `revision + 1` — never a hand-spelled `raw()` call here. + */ + async updateWithRevision(id: number, expectedRevision: number, name: string): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.nativeUpdate({ id, revision: expectedRevision }, { name, revision: columnIncrementedBy(platform, 'revision', 1) }); + } + + /** + * SH16 — `DELETE FROM school_holiday_regions WHERE id = ?`. The caller + * (`deleteRegion`) already re-read the region and checked its revision + * inside the same transaction before calling this, so — same as + * `SchoolHolidayCountriesRepository.remove` — the affected-row count is + * never consulted. + */ + async remove(id: number): Promise { + await this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/Settings.repository.ts b/server/src/db/repositories/Settings.repository.ts new file mode 100644 index 000000000..0a65440a5 --- /dev/null +++ b/server/src/db/repositories/Settings.repository.ts @@ -0,0 +1,92 @@ +import type { Settings } from '../entities/Settings.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `settings` row (the per-user key/value table) as the API emits it. */ +export interface SettingRow { + id: number; + user_id: number; + key: string; + value: string | null; +} + +const _settingRowKeys: AssertRowKeys = true; + +export class SettingsRepository extends TrekRepository { + /** + * `SELECT key, value FROM settings WHERE user_id = ?` + * + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — `_shared/trek-repository.ts`) — a "rows out" + * read, converted via `toRow` and discarded. + */ + async getForUser(userId: number): Promise { + const rows = await this.find({ user: userId }); + return rows.map((row) => toRow(row) as SettingRow); + } + + /** + * `SELECT value FROM settings WHERE user_id = ? AND key = ?` + * + * `disableIdentityMap: true`, applied by the base class's default (Plan 3b + * interlude B — supersedes Plan 3a's I1 — see `Users.repository.ts`'s + * class-level docstring and + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): this + * filter is `(user, key)` — `Settings`'s PK is the surrogate `id`, not + * this composite — so it was never PK-only and always re-queried even + * before this ruling; `disableIdentityMap: true` is applied uniformly + * regardless, matching every other read in this repository. + * `SETTINGSREPO-012`/`013` pin the always-fresh-read property directly (a + * raw UPDATE/DELETE on the same row, then `getOne` in the same request, + * sees it) rather than assume it. + */ + async getOne(userId: number, key: string): Promise { + const row = await this.findOne({ user: userId, key }); + return row ? (toRow(row) as SettingRow) : null; + } + + /** + * `INSERT INTO settings (user_id, key, value) VALUES (?, ?, ?) + * ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value` + * + * The composite conflict target is the relation field (`user`), never its + * `persist(false)` scalar twin (`user_id`) — same rule as every other + * `create`/`upsert` call in this migration (D4's defaults rule): the + * relation is the one column MikroORM actually writes, the twin is filled + * by a subsequent read. `Settings`'s own `uniques: [{ properties: ['user', + * 'key'] }]` (`Settings.entity.ts`, generated off the schema's inline + * `UNIQUE(user_id, key)` by the `fix(db)` commit that precedes this one) + * names that same relation field — `Settings.repository.test.ts` asserts + * both the schema-level unique index and the entity metadata. + * + * `onConflictFields` is passed explicitly here for CLARITY at the call + * site, not because `em.upsert` needs it to find the conflict target: the + * `uniques` entry above is fidelity to the schema first, but as a + * consequence it is also consumable — `em.upsert` infers the same + * `(user, key)` target from it alone when `onConflictFields` is omitted + * (`getWhereCondition` in `@mikro-orm/core/utils/upsert-utils.js` matches a + * `uniques` entry's `properties` against the payload's own property-named + * keys). `SETTINGSREPO-014` proves that inference directly, independent of + * this method's explicit option. + * + * Only `value` is ever updated on conflict, matching the legacy statement's + * `DO UPDATE SET value = excluded.value` exactly (no other column, no + * `updated_at` — the `settings` table has none). + */ + async upsertForUser(userId: number, key: string, value: string): Promise { + await this.upsert({ user: userId, key, value }, { onConflictFields: ['user', 'key'], onConflictAction: 'merge' }); + } + + /** + * Plan 3j Task 2 — `plugin-runtime.service.ts#uninstall`'s `deleteData` branch + * (PR39): `` DELETE FROM settings WHERE key LIKE 'plugin:${id}:%' ``. No `user` + * filter — this deletes a plugin's settings across EVERY user, unlike every + * other method on this repository, which is why it takes a bare key-prefix + * `$like` pattern rather than the `(user, key)` shape the rest of the class + * uses. `prefix` is caller-built (`` `plugin:${id}:` ``) and bound as a plain + * parameter — never interpolated into SQL text. + */ + async deleteByKeyPrefix(prefix: string): Promise { + return await this.nativeDelete({ key: { $like: `${prefix}%` } }); + } +} diff --git a/server/src/db/repositories/ShareTokens.repository.ts b/server/src/db/repositories/ShareTokens.repository.ts new file mode 100644 index 000000000..e2134a388 --- /dev/null +++ b/server/src/db/repositories/ShareTokens.repository.ts @@ -0,0 +1,170 @@ +import type { ShareTokens } from '../entities/ShareTokens.entity'; +import { currentTimestampKysely } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `share_tokens` — public trip-share links (Plan 3h Task 6). `trip_id` and + * `created_by` are `persist(false)` shadow mirrors of the `trip`/`createdByRef` + * relations (`ShareTokens.entity.ts`), so every READ here goes through + * `this.db_()` (Kysely), never a bare `qb().select(['trip_id', ...])` — the + * `CollectionPlacesRepository`/`DayAssignmentsRepository.assignmentWithPlaceSelect` + * class-docstring trap applies here too. WRITES go through the MikroORM + * entity API (`insert`/`nativeUpdate`/`nativeDelete`), using the entity's own + * relation property names (`trip`/`createdByRef`) for the two FK columns — + * the `DocumentConnectionsRepository.insertConnection` precedent. + */ +export interface ShareTokenRow { + id: number; + trip_id: number; + token: string; + created_by: number; + share_map: number | null; + share_bookings: number | null; + share_packing: number | null; + share_budget: number | null; + share_collab: number | null; + created_at: string | null; + expires_at: string | null; + share_travel_only: number; + share_hide_images: number; +} + +interface ShareTokensKyselyDB { + share_tokens: ShareTokenRow; +} + +export class ShareTokensRepository extends TrekRepository { + private db_() { + return this.kysely(); + } + + /** + * SH1 (`createOrUpdate`, TX) — `SELECT token FROM share_tokens WHERE + * trip_id = ?`. + */ + async findTokenByTrip(trip_id: number | string): Promise<{ token: string } | undefined> { + return await this.db_().selectFrom('share_tokens').select('token').where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** + * SH2 (`createOrUpdate`, TX, existing row) — `UPDATE share_tokens SET + * share_map=?, share_bookings=?, share_packing=?, share_budget=?, + * share_collab=?, share_travel_only=?, share_hide_images=?, expires_at=? + * WHERE trip_id=?`. + */ + async updateFlagsByTrip(trip_id: number | string, data: { + share_map: number; + share_bookings: number; + share_packing: number; + share_budget: number; + share_collab: number; + share_travel_only: number; + share_hide_images: number; + expires_at: string; + }): Promise { + await this.nativeUpdate({ trip: trip_id as number }, data); + } + + /** + * SH3 (`createOrUpdate`, TX, new row) — `INSERT INTO share_tokens + * (trip_id, token, created_by, share_map, share_bookings, share_packing, + * share_budget, share_collab, share_travel_only, share_hide_images, + * expires_at) VALUES (...)`. `token` is + * generated by the caller (`crypto.randomBytes(24)`), passed straight + * through. + */ + async insertNew(data: { + trip_id: number | string; + token: string; + created_by: number; + share_map: number; + share_bookings: number; + share_packing: number; + share_budget: number; + share_collab: number; + share_travel_only: number; + share_hide_images: number; + expires_at: string; + }): Promise { + await this.insert({ + trip: data.trip_id as number, + token: data.token, + createdByRef: data.created_by, + share_map: data.share_map, + share_bookings: data.share_bookings, + share_packing: data.share_packing, + share_budget: data.share_budget, + share_collab: data.share_collab, + share_travel_only: data.share_travel_only, + share_hide_images: data.share_hide_images, + expires_at: data.expires_at, + }); + } + + /** SH4 (`get`) — `SELECT * FROM share_tokens WHERE trip_id = ?`. */ + async findRawByTrip(trip_id: number | string): Promise { + return await this.db_().selectFrom('share_tokens').selectAll().where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** + * SH5 (`remove`) — `DELETE FROM share_tokens WHERE trip_id = ?`. + * **DISTINCT from `deleteByCreator` (UC6)** — a different predicate on the + * same table, never conflated into one method with an optional parameter. + */ + async deleteByTrip(trip_id: number | string): Promise { + await this.nativeDelete({ trip: trip_id as number }); + } + + /** + * The `token = ?` and `(expires_at IS NULL OR expires_at > )` + * predicate SH6, SH17 and `platform.routes.ts`'s R1-converted pre-init + * `servePhoto` fallback ALL share — built exactly once here, never + * re-derived at any of the three call sites (R5). `token` is compared + * with a plain `=` — no `LIKE`, no case-folding, ever (R5's own + * "the opposite of rule 18" framing). `currentTimestampKysely` (not a + * hand-written `datetime('now')` literal): SQLite's bare `CURRENT_TIMESTAMP` + * keyword renders in the SAME `YYYY-MM-DD HH:MM:SS` UTC text `datetime('now')` + * does, so reusing the existing helper is byte-equivalent to the legacy + * predicate, not an approximation of it. + */ + private validTokenQuery(token: string) { + const platform = this.getEntityManager().getPlatform(); + return this.db_() + .selectFrom('share_tokens') + .where('token', '=', token) + .where((eb) => eb.or([eb('expires_at', 'is', null), eb('expires_at', '>', currentTimestampKysely(platform))])); + } + + /** SH6 (`getSharedTripData`) — the full row, valid-token predicate above. */ + async findValidByToken(token: string): Promise { + return await this.validTokenQuery(token).selectAll().executeTakeFirst(); + } + + /** + * SH17 (`getSharedPlacePhotoKey`) — `trip_id, share_map, + * share_hide_images` only, SAME valid-token predicate as SH6/R1. + */ + async findTripAndShareMapByToken(token: string): Promise<{ trip_id: number; share_map: number | null; share_hide_images: number } | undefined> { + return await this.validTokenQuery(token).select(['trip_id', 'share_map', 'share_hide_images']).executeTakeFirst(); + } + + /** + * R1 (`platform.routes.ts`'s pre-init `servePhoto` share-token fallback, + * SV-PLATFORM1) — `trip_id` only, the SAME valid-token predicate SH6/SH17 + * use, built once here and reused, not re-derived at the pre-init call + * site. + */ + async findTripIdByToken(token: string): Promise<{ trip_id: number } | undefined> { + return await this.validTokenQuery(token).select(['trip_id']).executeTakeFirst(); + } + + /** + * UC6 (`user-cleanup.service.ts`'s GDPR erasure) — `DELETE FROM + * share_tokens WHERE created_by = ?`. **DISTINCT from `deleteByTrip` + * (SH5)** — deletes every link a user CREATED, across every trip, + * regardless of who owns those trips, not the links on one trip. + */ + async deleteByCreator(userId: number): Promise { + await this.nativeDelete({ createdByRef: userId }); + } +} diff --git a/server/src/db/repositories/Tags.repository.ts b/server/src/db/repositories/Tags.repository.ts new file mode 100644 index 000000000..8b6f5094e --- /dev/null +++ b/server/src/db/repositories/Tags.repository.ts @@ -0,0 +1,275 @@ +import type { Tags } from '../entities/Tags.entity'; +import { findOwnedByUser, listForOwner } from './_shared/owned-lookup'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A `tags` row as the API emits it. */ +export interface TagRow { + id: number; + user_id: number; + name: string; + color: string | null; + created_at: string | null; +} + +const _tagRowKeys: AssertRowKeys = true; + +/** + * One tag attached to one place, from `QueryHelpersService.loadTagsByPlaceIds` + * (QH1). `user_id` is present only on the non-compact projection — `listForPlaces` + * selects a narrower column set when `compact` is set, so a compact row never + * carries the key at all (matching the legacy's destructuring, which drops it + * rather than nulling it). + */ +export interface TagForPlaceRow { + place_id: number; + id: number; + user_id?: number; + name: string; + color: string | null; + created_at: string | null; +} + +/** + * `place_tags` (`place_id`, `tag_id`, `PRIMARY KEY (place_id, tag_id)`, + * `Migration20200101000000_baseline_schema.ts:195-199` — verified against + * the migration, not assumed) as `Places.entity.ts`'s own `place_tags` + * many-to-many relation declares it — a PLAIN pivot table + * (`p.manyToMany(Tags).pivotTable('place_tags')...`) with no owning entity of + * its own (unlike `PackingItems`' `packing_item_contributors`, which has a + * `pivotEntity`). Neither `em.upsert` nor `qb()` can target a table with no + * entity mapping — both are bound to exactly one mapped entity — so + * `TrekRepository.kysely()` (D3's sanctioned next escape hatch) is the only + * API that reaches it. + */ +interface PlaceTagsKyselyDB { + place_tags: { place_id: number; tag_id: number }; +} + +/** + * `place_tags` joined to `places` for TP46 (`trips.service.ts::copy`'s + * place-tags read) — `trip_id` widened to `number | string` (D4's T5 escape + * hatch, `TripsRepository.findAccessible`'s precedent) so the raw-bind seam + * matches `TripsService.copy`'s own `sourceTripId: string | number` + * parameter. + */ +interface PlaceTagsForTripKyselyDB { + place_tags: { place_id: number; tag_id: number }; + places: { id: number; trip_id: number | string }; +} + +export class TagsRepository extends TrekRepository { + /** + * `SELECT * FROM tags WHERE user_id = ? ORDER BY name ASC`, via the shared + * `listForOwner` helper (`tags.user_id` is `NOT NULL`, a strictly-owned + * entity — see `_shared/owned-lookup.ts`). + */ + async listByUser(userId: number): Promise { + const tags = await listForOwner(this, 'user', userId, 'name'); + return tags.map((tag) => toRow(tag) as TagRow); + } + + /** + * `SELECT * FROM tags WHERE id = ? AND user_id = ?`, via the shared + * `findOwnedByUser` helper — an ownership check (RPC/MCP writes re-verify + * with this before mutating), not a re-select following this repository's + * own write (`createTag`/`patch` refresh/return their own entity + * directly). + * + * `disableIdentityMap: true` by the base class's default (`_shared/ + * trek-repository.ts`, Plan 3b interlude B), per `findOwnedByUser`'s own + * docstring — this is a "rows out" read, converted via `toRow` and + * discarded. + */ + async findByIdAndUser(id: number, userId: number): Promise { + const tag = await findOwnedByUser(this, id, 'user', userId); + return tag ? (toRow(tag) as TagRow) : null; + } + + /** + * The column set of the legacy INSERT: + * `INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)`. The + * `#10b981` defaulting stays in `TagsService` (D4's defaults rule) — this + * writes exactly what it is given. + * + * `this.insert` (Plan 3b interlude B, finishing F2's sweep), never + * `create()` + `persist().flush()`, same reasoning as + * `CategoriesRepository.createCategory`: `flush()` commits the *whole* + * unit of work of the request's `EntityManager`, not just this row. + * Followed by a read-back (`findOne({ id })`, `disableIdentityMap: true` + * by the base class's default): the returned row carries the generated + * `id` and the `defaultRaw` `created_at` the same way a legacy + * INSERT-then-reselect pair did. + */ + async createTag(input: { user_id: number; name: string; color: string }): Promise { + const id = await this.insert({ + user: input.user_id, + name: input.name, + color: input.color, + }); + const inserted = await this.findOne({ id }); + if (!inserted) { + throw new Error('createTag: read-back after insert found no row'); + } + return toRow(inserted) as TagRow; + } + + /** + * `UPDATE tags SET name = COALESCE(?, name), color = COALESCE(?, color) + * WHERE id = ?`, re-selected — no `user_id` filter, matching the legacy + * statement exactly. `nativeUpdate` only writes the keys present on + * `changes` (the entity-API equivalent of `COALESCE` against a bind + * parameter), so `TagsService` omits a field entirely, rather than + * passing it as `undefined`, to leave it untouched — reproducing the + * legacy `|| null` coalesce-away-empty-string behaviour. Returns `null` + * when no row matches `id`, mirroring the legacy re-select's `undefined`. + * + * **Converted from `findOne({ refresh: true })` + `assign` + `flush()` + * to `nativeUpdate` + a typed partial (Plan 3b interlude B, per + * `task-1-rereview.md`'s ruling on `CategoriesRepository.patch`, applied + * here identically): "convert them, don't complete the sweep on the + * read."** `nativeUpdate` writes the given columns directly, with no ORM + * change-set diff against an in-memory snapshot — that removes the whole + * class of staleness `refresh: true` existed to guard against (Task 3 + * review, Important 1: an untouched column reading stale, a patch + * silently dropped because it happened to match a stale in-memory value, + * and a patch after `remove()` fabricating a row), not just the one B1 + * shape the rest of the program's `disableIdentityMap: true` ruling + * closes. When `changes` is non-empty, `nativeUpdate`'s own affected-row + * count IS the existence check (0 ⇒ `null`, no separate read); when + * `changes` is empty, a plain existence read (`disableIdentityMap: true` + * by the base class's default, like every other read in this repository + * now) stands in for it. Either way the return value is a fresh read, not + * the entity `assign` used to mutate in place. + */ + async patch(id: number, changes: { name?: string; color?: string }): Promise { + if (Object.keys(changes).length > 0) { + const affected = await this.nativeUpdate({ id }, changes); + if (affected === 0) return null; + } else { + const existing = await this.findOne({ id }, { fields: ['id'] }); + if (!existing) return null; + } + const tag = await this.findOne({ id }); + return tag ? (toRow(tag) as TagRow) : null; + } + + /** `DELETE FROM tags WHERE id = ?`. Returns the affected row count. */ + async remove(id: number): Promise { + return this.nativeDelete({ id }); + } + + /** + * `SELECT t.*, pt.place_id FROM tags t JOIN place_tags pt ON t.id = pt.tag_id + * WHERE pt.place_id IN (${…})` (`query-helpers.service.ts:41-45`, QH1) — + * ONE method, TWO projections (Plan 3c Task 1 ruling): `compact` selects + * `{id, name, color, created_at, place_id}`; the non-compact path selects + * every `tags` column except the join key (`{id, user_id, name, color, + * created_at, place_id}`), exactly the legacy's `t.*` minus `place_id`. + * + * Joined through `t.place_tags_inverse` (the inverse side of `Places.place_tags`, + * a many-to-many over the `place_tags` pivot table): selecting the joined + * place's own `id` as `place_id` is the same value the legacy's `pt.place_id` + * read off the pivot row directly (the pivot's `place_id` column IS the + * places row it points at), so this is not an approximation. + * + * Empty `placeIds` short-circuits before any query, matching the legacy + * service method. + */ + async listForPlaces(placeIds: number[], options?: { compact?: boolean }): Promise { + if (placeIds.length === 0) return []; + if (options?.compact) { + return this.qb('t') + .join('t.place_tags_inverse', 'p') + .select(['t.id', 't.name', 't.color', 't.created_at', 'p.id as place_id']) + .where({ 'p.id': { $in: placeIds } }) + .execute('all', false); + } + return this.qb('t') + .join('t.place_tags_inverse', 'p') + .select(['t.id', 't.user', 't.name', 't.color', 't.created_at', 'p.id as place_id']) + .where({ 'p.id': { $in: placeIds } }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 4 (`PlacesService`) — additive methods. + // --------------------------------------------------------------------------- + + /** + * PL2 (`PlacesService.tagsOnTrip`) — `` SELECT id, user_id FROM tags WHERE + * id IN (${…}) ``, the ownership half of tag scoping: the caller + * (`tagsOnTrip`) intersects these rows' `user_id` against the trip's + * roster, and an id belonging to nobody on the roster drops silently. + * `$in` for the dynamic list (rule 17b). + */ + async findByIds(ids: number[]): Promise<{ id: number; user_id: number }[]> { + if (ids.length === 0) return []; + // `t.user` (unaliased, not separately joined) resolves to the physical + // `user_id` column and comes back keyed as `user_id` on the raw driver + // row — `TripsRepository.findAccessible`'s `.select(['t.id', 't.user', + // 't.currency'])` establishes the same shape. + return this.qb('t') + .select(['t.id', 't.user']) + .where({ id: { $in: ids } }) + .execute<{ id: number; user_id: number }[]>('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 4 (`PlacesService.create`/`applyUpdate`, PL5/PL12/PL13) — + // `place_tags` writes. `PlaceTagsKyselyDB` above documents why Kysely. + // --------------------------------------------------------------------------- + + /** + * PL5/PL13 — `INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES + * (?, ?)`, the legacy statement run once per tag id. One batched Kysely + * insert with `onConflict((oc) => oc.columns(['place_id', 'tag_id']) + * .doNothing())` on the pivot's real composite `PRIMARY KEY (place_id, + * tag_id)` — SQLite's `ON CONFLICT (...) DO NOTHING` (supported since + * SQLite 3.24, well within better-sqlite3's bundled version) is the + * upsert-API-less equivalent of `INSERT OR IGNORE` for a table with no + * entity mapping. Same end state as the legacy per-row loop (every + * genuinely new pair inserted, every duplicate silently skipped); the + * caller (`PlacesService.tagsOnTrip`) always hands an already + * roster-scoped, deduplicated tag id list — the empty-array guard here is + * defensive, matching every other batch-write method in this program. + */ + async insertIgnore(place_id: number, tag_ids: number[]): Promise { + if (tag_ids.length === 0) return; + await this.kysely() + .insertInto('place_tags') + .values(tag_ids.map((tag_id) => ({ place_id, tag_id }))) + .onConflict((oc) => oc.columns(['place_id', 'tag_id']).doNothing()) + .execute(); + } + + /** PL12 — `DELETE FROM place_tags WHERE place_id = ?` (the replace-all half of an update). */ + async deleteForPlace(place_id: number): Promise { + await this.kysely() + .deleteFrom('place_tags') + .where('place_id', '=', place_id) + .execute(); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP46 (`trips.service.ts::copy`'s place-tags read) — `SELECT pt.* FROM + * place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ?`, + * every `place_tags` pair for every place in a trip. Kysely (the pivot has + * no entity mapping of its own, `PlaceTagsKyselyDB`'s own docstring); TP47 + * (the `INSERT OR IGNORE` write half) reuses {@link insertIgnore} above, + * called once per row with a single-element `tag_ids` array — the same + * shape the legacy per-row loop wrote in. + */ + async listPlaceTagsForTrip(trip_id: number | string): Promise<{ place_id: number; tag_id: number }[]> { + return await this.kysely() + .selectFrom('place_tags as pt') + .innerJoin('places as p', 'p.id', 'pt.place_id') + .select(['pt.place_id', 'pt.tag_id']) + .where('p.trip_id', '=', trip_id) + .execute(); + } +} diff --git a/server/src/db/repositories/TodoCategoryAssignees.repository.ts b/server/src/db/repositories/TodoCategoryAssignees.repository.ts new file mode 100644 index 000000000..3dd941076 --- /dev/null +++ b/server/src/db/repositories/TodoCategoryAssignees.repository.ts @@ -0,0 +1,79 @@ +import type { TodoCategoryAssignees } from '../entities/TodoCategoryAssignees.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** TD11/TD14's joined projection — the assignee row plus the display fields the picker needs. */ +export interface TodoCategoryAssigneeRow { + user_id: number; + username: string; + avatar: string | null; +} + +interface TodoCategoryAssigneesKyselyDB { + todo_category_assignees: { id: number; trip_id: number; category_name: string; user_id: number }; + users: { id: number; username: string; avatar: string | null }; +} + +interface TodoCategoryAssigneesInsertKyselyDB { + todo_category_assignees: { trip_id: number | string; category_name: string; user_id: number }; +} + +/** + * `todo_category_assignees` — which trip members are pinned to a to-do + * category, driving the picker `TodoService.getCategoryAssignees`/ + * `updateCategoryAssignees` render. Kysely throughout: `trip_id`/`user_id` + * are `persist(false)` relation mirrors (the program-wide trap), and the + * `INSERT OR IGNORE` write has no single `em.upsert`-expressible conflict + * target worth reaching for over Kysely's `ON CONFLICT DO NOTHING` (the + * `BudgetItemMembersRepository.insertIgnore` precedent) — the entity's own + * three-column unique (`trip`, `category_name`, `user`) is the constraint + * SQLite's `INSERT OR IGNORE` already ignores a conflict against. + */ +export class TodoCategoryAssigneesRepository extends TrekRepository { + /** TD11 (`getCategoryAssignees`) — `SELECT tca.category_name, tca.user_id, u.username, u.avatar FROM todo_category_assignees tca JOIN users u ON tca.user_id = u.id WHERE tca.trip_id = ?`. */ + async listForTrip(trip_id: number | string): Promise<(TodoCategoryAssigneeRow & { category_name: string })[]> { + return await this.kysely() + .selectFrom('todo_category_assignees as tca') + .innerJoin('users as u', 'u.id', 'tca.user_id') + .select(['tca.category_name', 'tca.user_id', 'u.username', 'u.avatar']) + .where('tca.trip_id', '=', trip_id as number) + .execute(); + } + + /** TD12 (`updateCategoryAssignees`) — `DELETE FROM todo_category_assignees WHERE trip_id = ? AND category_name = ?` (replace-all before a re-insert). */ + async deleteForCategory(trip_id: number | string, category_name: string): Promise { + await this.kysely() + .deleteFrom('todo_category_assignees') + .where('trip_id', '=', trip_id as number) + .where('category_name', '=', category_name) + .execute(); + } + + /** + * TD13 (`updateCategoryAssignees`, looped, roster-filtered) — `INSERT OR + * IGNORE INTO todo_category_assignees (trip_id, category_name, user_id) + * VALUES (?, ?, ?)`. The service filters `userIds` against + * `DatabaseService.rosterUserIds` before calling this once per surviving + * id — dropped rather than rejected: a copied trip carries assignee ids + * across before its members exist, and a 400 would make the picker + * unusable there. Kept exactly (`TodoService.updateCategoryAssignees`'s + * own comment). + */ + async insertIgnore(trip_id: number | string, category_name: string, user_id: number): Promise { + await this.kysely() + .insertInto('todo_category_assignees') + .values({ trip_id: trip_id as number, category_name, user_id }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + /** TD14 (`updateCategoryAssignees`'s post-write re-select) — `SELECT tca.user_id, u.username, u.avatar FROM todo_category_assignees tca JOIN users u ON tca.user_id = u.id WHERE tca.trip_id = ? AND tca.category_name = ?`. */ + async listForCategory(trip_id: number | string, category_name: string): Promise { + return await this.kysely() + .selectFrom('todo_category_assignees as tca') + .innerJoin('users as u', 'u.id', 'tca.user_id') + .select(['tca.user_id', 'u.username', 'u.avatar']) + .where('tca.trip_id', '=', trip_id as number) + .where('tca.category_name', '=', category_name) + .execute(); + } +} diff --git a/server/src/db/repositories/TodoItems.repository.ts b/server/src/db/repositories/TodoItems.repository.ts new file mode 100644 index 000000000..3c6d023c8 --- /dev/null +++ b/server/src/db/repositories/TodoItems.repository.ts @@ -0,0 +1,277 @@ +import type { TodoItems } from '../entities/TodoItems.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; +import { presenceSet } from './_shared/presence-set'; +import { columnRef, currentTimestamp, nowMinusHours } from '../dialect/sql-functions'; + +/** A bare `todo_items` row — every scalar column, incl. the two `persist(false)` relation mirrors (`trip_id`, `assigned_user_id`). */ +export interface TodoItemRow { + id: number; + trip_id: number; + name: string; + checked: number | null; + category: string | null; + sort_order: number | null; + due_date: string | null; + description: string | null; + assigned_user_id: number | null; + priority: number | null; + created_at: string | null; + reminded_at: string | null; +} + +const _todoItemRowKeys: AssertRowKeys = true; + +interface TodoItemsKyselyDB { + todo_items: TodoItemRow; +} + +/** The insert-only shape for `insertItem` (TD3) — omits `id`/`created_at`/`reminded_at` (autoincrement / `DEFAULT CURRENT_TIMESTAMP` / never written here). */ +interface TodoItemsInsertKyselyDB { + todo_items: { + trip_id: number | string; name: string; checked: number; category: string | null; sort_order: number; + due_date: string | null; description: string | null; assigned_user_id: number | null; priority: number; + }; +} + +/** The insert-only shape for `insertCopy` (TP73) — a distinct column set from {@link TodoItemsRepository.insertItem}'s: no `assigned_user_id` parameter at all (always written `NULL`, never taken from the caller). */ +interface TodoItemsCopyInsertKyselyDB { + todo_items: { + trip_id: number | string; name: string; checked: number; category: string | null; sort_order: number | null; + due_date: string | null; description: string | null; assigned_user_id: null; priority: number | null; + }; +} + +/** `isoDate` + 1 calendar day, in UTC — turns {@link TodoItemsRepository.listDueForReminder}'s inclusive cutoff into an exclusive `$lt` bound (L2, task-7-review.md). */ +function dayAfter(isoDate: string): string { + const d = new Date(`${isoDate}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + 1); + return d.toISOString().slice(0, 10); +} + +/** + * `todo_items` — Kysely throughout: `trip_id`/`assigned_user_id` are + * `persist(false)` relation mirrors (the program-wide bare-`select`/`fields` + * drop trap — `TripFilesRepository`'s class docstring, restated at Task 2's + * `BudgetItemsRepository`/Task 6's TC-form), avoided the same way here by + * going through Kysely for every statement rather than the QueryBuilder. + */ +export class TodoItemsRepository extends TrekRepository { + /** TD1 (`listItems`) — `SELECT * FROM todo_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC`. */ + async listForTrip(trip_id: number | string): Promise { + return await this.kysely() + .selectFrom('todo_items') + .selectAll() + .where('trip_id', '=', trip_id as number) + .orderBy('sort_order', 'asc') + .orderBy('created_at', 'asc') + .execute(); + } + + /** TD2 (`createItem`) — `SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?`. */ + async maxSortOrder(trip_id: number | string): Promise { + const row = await this.kysely().selectFrom('todo_items').select((eb) => eb.fn.max('sort_order').as('max')).where('trip_id', '=', trip_id as number).executeTakeFirst(); + return row?.max ?? null; + } + + /** + * TD3 (`createItem`) — `INSERT INTO todo_items (trip_id, name, checked, + * category, sort_order, due_date, description, assigned_user_id, priority) + * VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?)`. Returns the new row's id (TD4's + * re-select key). Named `insertItem`, not `create`/`insert` — both collide + * with `TrekRepository`/`EntityRepository`'s own same-named methods (a + * different signature: an unpersisted-entity builder, not an INSERT). + */ + async insertItem(row: { + trip_id: number | string; name: string; category: string | null; sort_order: number; + due_date: string | null; description: string | null; assigned_user_id: number | null; priority: number; + }): Promise { + const result = await this.kysely() + .insertInto('todo_items') + .values({ ...row, checked: 0 }) + .executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + /** TD4/TD7 (`createItem`/`updateItem`'s post-write re-select) — `SELECT * FROM todo_items WHERE id = ?` (no trip filter — the caller already knows `id` is in-scope, having just written it). */ + async findById(id: number | string): Promise { + return await this.kysely().selectFrom('todo_items').selectAll().where('id', '=', id as number).executeTakeFirst(); + } + + /** + * TD5 (`updateItem`'s trip-scoping guard) — `SELECT * FROM todo_items + * WHERE id = ? AND trip_id = ?`. `id: number` (Plan 4 Task 8b, U6 — the + * program's own gate-level id parsing carry: narrowed from `number | + * string` now that `TodoController.update`/`.remove` parse `:id` once via + * `toRowId` and thread the number down; `trip_id` stays `number | string`, + * a separate, still-accepted carry — see `todo.service.ts`'s own note). + */ + async findInTrip(id: number, trip_id: number | string): Promise { + return await this.kysely().selectFrom('todo_items').selectAll().where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** TD8 (`deleteItem`'s trip-scoping guard) — `SELECT id FROM todo_items WHERE id = ? AND trip_id = ?`. `id: number`, same Plan 4 Task 8b narrowing as {@link findInTrip}. */ + async existsInTrip(id: number, trip_id: number | string): Promise<{ id: number } | undefined> { + return await this.kysely().selectFrom('todo_items').select('id').where('id', '=', id).where('trip_id', '=', trip_id as number).executeTakeFirst(); + } + + /** + * TD6 (`updateItem`) — the 7-column presence-sentinel `UPDATE` (R11's + * helper — {@link presenceSet}, copying Task 2's `BudgetItemsRepository + * .update` shape). `name`/`category` keep the legacy `COALESCE(?, col)` + * truthy-wins semantics (the caller passes `present = !!value`, matching + * `data.name || null` / `data.category || null`'s fall-through-on-falsy + * exactly); every other column is a true presence sentinel bound off the + * legacy `bodyKeys` array (`present = bodyKeys.includes('')`). + */ + async update(id: number, write: { + name?: readonly [present: boolean, value: string]; + checked?: readonly [present: boolean, value: number]; + category?: readonly [present: boolean, value: string | null]; + due_date?: readonly [present: boolean, value: string | null]; + description?: readonly [present: boolean, value: string | null]; + assigned_user_id?: readonly [present: boolean, value: number | null]; + priority?: readonly [present: boolean, value: number]; + }): Promise { + const data = presenceSet<{ + name: string; checked: number; category: string | null; due_date: string | null; + description: string | null; assigned_user_id: number | null; priority: number; + }>(write); + if (Object.keys(data).length === 0) return; + await this.kysely().updateTable('todo_items').set(data).where('id', '=', id).execute(); + } + + /** TD9 (`deleteItem`) — `DELETE FROM todo_items WHERE id = ?` (no trip scoping in the statement itself — relies on the caller's prior {@link existsInTrip} gate, matching legacy). `id: number`, same Plan 4 Task 8b narrowing as {@link findInTrip}. */ + async deleteById(id: number): Promise { + await this.kysely().deleteFrom('todo_items').where('id', '=', id).execute(); + } + + /** TD10 (`reorderItems`, looped) — `UPDATE todo_items SET sort_order = ? WHERE id = ? AND trip_id = ?`. */ + async setSortOrder(id: number | string, trip_id: number | string, sort_order: number): Promise { + await this.kysely().updateTable('todo_items').set({ sort_order }).where('id', '=', id as number).where('trip_id', '=', trip_id as number).execute(); + } + + /** TP72 (`TripsService.copy`) — `SELECT * FROM todo_items WHERE trip_id = ?`. */ + async listAllForTrip(trip_id: number | string): Promise { + return await this.kysely().selectFrom('todo_items').selectAll().where('trip_id', '=', trip_id as number).execute(); + } + + /** + * TP73 (`TripsService.copy`) — `INSERT INTO todo_items (trip_id, name, + * checked, category, sort_order, due_date, description, assigned_user_id, + * priority) VALUES (?, ?, 0, ?, ?, ?, ?, NULL, ?)`. `assigned_user_id` is + * hard-coded `NULL` here, never taken from the caller — the copy + * deliberately does not carry the old assignee across, since the new trip + * has no roster yet (`TripsService.copy`'s own comment, kept exactly). + */ + async insertCopy(row: { + trip_id: number | string; name: string; category: string | null; sort_order: number | null; + due_date: string | null; description: string | null; priority: number | null; + }): Promise { + const result = await this.kysely() + .insertInto('todo_items') + .values({ ...row, checked: 0, assigned_user_id: null }) + .executeTakeFirstOrThrow(); + return Number(result.insertId); + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 4 (`ReminderJobsService#todoTick`) — additive. `this.qb()`, + // not Kysely: RJ5's dedup window needs `nowMinusHours` (a raw MikroORM + // `RawQueryFragment` from `sql-functions.ts`, which composes with a + // QueryBuilder/native filter but not with Kysely — the Kysely `sql` tag is + // banned under `src/db/repositories/**`), and the join needs `columnRef` + // to read `trip_id`/`assigned_user_id` (both `persist(false)` mirrors of + // the real `trip`/`assignedUser` relations — a bare select silently drops + // them, per `RoadtripDayBoundariesRepository`'s documented trap). `ti.trip` + // is ALSO the join target here, so it is never itself selected (that + // aliases off the JOINED row's own PK instead of the FK scalar — + // `PlaceRatingsRepository.listForPlaces`'s documented trap); `t.user` is + // safe to select bare (its target, `users`, is not separately joined in + // this query), aliasing to the physical `user_id` column per + // `TripsRepository.findAccessible`'s precedent. + // --------------------------------------------------------------------------- + + /** + * RJ4 (`reminder-jobs.service.ts#todoTick`) — restructured per Task 0's R9 + * ruling. Legacy: `SELECT ti.id, ti.trip_id, ti.name, ti.due_date, + * ti.assigned_user_id, t.title AS trip_title, t.user_id AS trip_owner_id + * FROM todo_items ti JOIN trips t ON t.id = ti.trip_id WHERE ti.checked = 0 + * AND ti.due_date IS NOT NULL AND ti.due_date <> '' AND date(ti.due_date) + * <= date('now', '+' || ? || ' days') AND date(ti.due_date) >= date('now') + * AND (ti.reminded_at IS NULL OR ti.reminded_at <= datetime('now', '-20 + * hours'))`. + * + * The `date('now', '+' || ? || ' days')`/`date('now')` bounds are + * JS-computed by the caller (`todayDate`/`cutoffDate`, both `Date.UTC`- + * based `YYYY-MM-DD` text — matching SQLite's own UTC `date('now')`, per + * the same reasoning RJ3's restructuring documents) and bound directly + * against `ti.due_date`. `due_date` is an unconstrained `z.string()` on + * the wire, not always canonical `YYYY-MM-DD` text, so `date(ti.due_date)`'s + * normalization is NOT a no-op: a time-bearing due date on the cutoff day + * itself (`2026-07-14T09:00`) sorts lexically AFTER the bare cutoff date + * string, so a plain `$lte: cutoffDate` drops it a day early (L2, + * task-7-review.md). `date(ti.due_date) <= cutoffDate` is reproduced as an + * exclusive `$lt` against the day AFTER cutoff instead of re-adding the + * `date()` wrap (Kysely `sql` is banned under repositories). The + * `datetime('now', '-20 hours')` dedup bound stays genuinely in SQL, via + * `nowMinusHours` (Task 0's R9 helper) — RJ5's brief explicitly calls for + * it, not a JS equivalent. + */ + async listDueForReminder(todayDate: string, cutoffDate: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const rows = await this.qb('ti') + .join('ti.trip', 't') + .select(['ti.id', columnRef(platform, 'ti.trip_id'), 'ti.name', 'ti.due_date', columnRef(platform, 'ti.assigned_user_id'), 't.title', 't.user']) + .andWhere({ checked: 0 }) + .andWhere({ due_date: { $ne: null } }) + .andWhere({ due_date: { $ne: '' } }) + .andWhere({ due_date: { $gte: todayDate } }) + .andWhere({ due_date: { $lt: dayAfter(cutoffDate) } }) + .andWhere({ $or: [{ reminded_at: null }, { reminded_at: { $lte: nowMinusHours(platform, 20) } }] }) + .execute('all', false); + return rows.map((row) => ({ + id: row.id, + trip_id: row.trip_id, + name: row.name, + due_date: row.due_date, + assigned_user_id: row.assigned_user_id, + trip_title: row.title, + trip_owner_id: row.user_id, + })); + } + + /** + * RJ5 (`reminder-jobs.service.ts#todoTick`, looped) — `UPDATE todo_items + * SET reminded_at = CURRENT_TIMESTAMP WHERE id = ?`. Stays AFTER the + * notification send in call order (plan3f-inputs.md correction #8 — a + * documented, not-fixed-by-this-plan at-least-once ordering, unlike + * `notifications.service.ts#respond`'s deliberately claim-then-act shape). + */ + async markReminded(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { reminded_at: currentTimestamp(platform) }); + } +} + +/** {@link TodoItemsRepository.listDueForReminder}'s raw QB row — before the `trip_title`/`trip_owner_id` rename. */ +interface TodoReminderQueryRow { + id: number; + trip_id: number; + name: string; + due_date: string; + assigned_user_id: number | null; + title: string; + user_id: number; +} + +/** {@link TodoItemsRepository.listDueForReminder}'s output row. */ +export interface TodoReminderRow { + id: number; + trip_id: number; + name: string; + due_date: string; + assigned_user_id: number | null; + trip_title: string; + trip_owner_id: number; +} diff --git a/server/src/db/repositories/TourTypes.repository.ts b/server/src/db/repositories/TourTypes.repository.ts new file mode 100644 index 000000000..49c5c9314 --- /dev/null +++ b/server/src/db/repositories/TourTypes.repository.ts @@ -0,0 +1,9 @@ +import type { TourTypes } from '../entities/TourTypes.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `tour_types` — the controlled vocabulary `tours.tour_type` points at. Only + * `hike` exists, written by the migration that creates the table; nothing + * reads the catalogue yet, so this repository has no methods of its own. + */ +export class TourTypesRepository extends TrekRepository {} diff --git a/server/src/db/repositories/TourWaypoints.repository.ts b/server/src/db/repositories/TourWaypoints.repository.ts new file mode 100644 index 000000000..b5112ab44 --- /dev/null +++ b/server/src/db/repositories/TourWaypoints.repository.ts @@ -0,0 +1,70 @@ +import type { TourWaypoints } from '../entities/TourWaypoints.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** One saved routing control point of a tour, in route order. */ +export interface TourWaypointRow { + lat: number; + lng: number; + role: 'start' | 'via' | 'end'; + sequence: number; +} + +/** {@link TourWaypointRow} plus the tour it belongs to, as the trip copy reads it. */ +export interface TourWaypointWithPlaceRow extends TourWaypointRow { + place_id: number; +} + +interface TourWaypointsKyselyDB { + tour_waypoints: { id: number; place_id: number; lat: number; lng: number; role: TourWaypointRow['role']; sequence: number }; + places: { id: number; trip_id: number }; +} + +/** + * `tour_waypoints` — the start, via and end points the route editor saved + * for a tour, so reopening it restores the controls rather than only the + * derived line. Rows hang off `tours(place_id)` and go with the tour. + */ +export class TourWaypointsRepository extends TrekRepository { + private db() { + return this.kysely(); + } + + /** TW1 — `SELECT lat, lng, role, sequence FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */ + async listForPlace(place_id: number): Promise { + return await this.db() + .selectFrom('tour_waypoints') + .select(['lat', 'lng', 'role', 'sequence']) + .where('place_id', '=', place_id) + .orderBy('sequence', 'asc') + .execute(); + } + + /** TW2 — every waypoint of a trip's tours, for the trip copy to remap by place. */ + async listForTrip(trip_id: number): Promise { + return await this.db() + .selectFrom('tour_waypoints as tw') + .innerJoin('places as p', 'p.id', 'tw.place_id') + .select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence']) + .where('p.trip_id', '=', trip_id) + .orderBy('tw.place_id', 'asc') + .orderBy('tw.sequence', 'asc') + .execute(); + } + + /** + * TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence) + * VALUES (...)` for each point. The caller owns the transaction: a create + * writes the tour first, an edit clears the old points first. + */ + async insertForPlace(place_id: number, waypoints: readonly TourWaypointRow[]): Promise { + if (waypoints.length === 0) return; + await this.insertMany( + waypoints.map((w) => ({ place: place_id, lat: w.lat, lng: w.lng, role: w.role, sequence: w.sequence })), + ); + } + + /** TW4 — `DELETE FROM tour_waypoints WHERE place_id = ?`. */ + async deleteForPlace(place_id: number): Promise { + await this.nativeDelete({ place: place_id }); + } +} diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts new file mode 100644 index 000000000..7e3b34acf --- /dev/null +++ b/server/src/db/repositories/Tours.repository.ts @@ -0,0 +1,221 @@ +import type { AssertRowKeys } from './_shared/rows'; +import type { Tours } from '../entities/Tours.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A tour as the Tours list and detail read it: the `tours` facet joined with + * its owning place's name, plus two read-model flags (`planned`: the place + * sits on at least one day; `has_waypoints`: the route editor saved control + * points for it). SQLite hands both flags back as 0/1. + */ +export interface TourListRow { + place_id: number; + name: string; + tour_type: string; + distance: number | null; + elevation_gain: number | null; + elevation_loss: number | null; + duration: number | null; + difficulty: string | null; + wanderer_ref: string | null; + match_confidence: number | null; + tour_group_id: number | null; + max_hiking_difficulty: number; + planned: number; + has_waypoints: number; +} + +/** Every `tours` column, as the trip copy reads and re-inserts it. */ +export interface TourRow { + place_id: number; + tour_type: string; + distance: number | null; + elevation_gain: number | null; + elevation_loss: number | null; + duration: number | null; + difficulty: string | null; + wanderer_ref: string | null; + match_confidence: number | null; + tour_group_id: number | null; + created_at: string | null; + max_hiking_difficulty: number; +} +const _tourRowKeys: AssertRowKeys = true; + +/** What a create or a GPX import writes; the unset metadata columns stay NULL. */ +export interface TourInsert { + place_id: number; + tour_type: string; + distance: number | null; + elevation_gain: number | null; + elevation_loss: number | null; + duration: number | null; + match_confidence: number | null; + max_hiking_difficulty: number; +} + +/** What a route edit replaces on an existing tour. */ +export type TourUpdate = Omit; + +interface ToursKyselyDB { + tours: TourRow; + places: { id: number; trip_id: number; name: string }; + day_assignments: { id: number; day_id: number; place_id: number }; + tour_waypoints: { id: number; place_id: number }; +} + +/** + * `tours` — the facet that makes a place a tour (#2586). Keyed on the owning + * place, so it carries no trip of its own: every trip-scoped statement here + * joins `places` for the predicate. Deleting the place cascades the row and + * its `tour_waypoints`. + * + * Kysely throughout: the list projection needs two correlated `EXISTS` + * subqueries, and the trip-scoped update and the day check join tables the + * entity has no relation path to. + */ +export class ToursRepository extends TrekRepository { + private db() { + return this.kysely(); + } + + /** The shared SELECT of {@link listForTrip} and {@link findInTrip}. */ + private listQuery() { + return this.db() + .selectFrom('tours as t') + .innerJoin('places as p', 'p.id', 't.place_id') + .select((eb) => [ + 'p.id as place_id', + 'p.name as name', + 't.tour_type as tour_type', + 't.distance as distance', + 't.elevation_gain as elevation_gain', + 't.elevation_loss as elevation_loss', + 't.duration as duration', + 't.difficulty as difficulty', + 't.wanderer_ref as wanderer_ref', + 't.match_confidence as match_confidence', + 't.tour_group_id as tour_group_id', + 't.max_hiking_difficulty as max_hiking_difficulty', + eb.exists(eb.selectFrom('day_assignments as da').select('da.id').whereRef('da.place_id', '=', 'p.id')).as('planned'), + eb.exists(eb.selectFrom('tour_waypoints as tw').select('tw.id').whereRef('tw.place_id', '=', 'p.id')).as('has_waypoints'), + ]); + } + + /** + * TO1 — `SELECT p.id AS place_id, p.name, t.*, EXISTS(day_assignments) AS + * planned, EXISTS(tour_waypoints) AS has_waypoints FROM tours t JOIN places + * p ON p.id = t.place_id WHERE p.trip_id = ? ORDER BY t.created_at DESC`, + * newest first. The place id breaks ties inside one CURRENT_TIMESTAMP second. + */ + async listForTrip(trip_id: number): Promise { + const rows = await this.listQuery() + .where('p.trip_id', '=', trip_id) + .orderBy('t.created_at', 'desc') + .orderBy('t.place_id', 'desc') + .execute(); + return rows.map(toListRow); + } + + /** TO2 — {@link listForTrip}'s projection for one place, `undefined` unless it is a tour of that trip. */ + async findInTrip(trip_id: number, place_id: number): Promise { + const row = await this.listQuery().where('p.trip_id', '=', trip_id).where('p.id', '=', place_id).executeTakeFirst(); + return row ? toListRow(row) : undefined; + } + + /** TO3 — `SELECT 1 FROM tours WHERE place_id = ?`: whether the place is a tour. */ + async existsForPlace(place_id: number): Promise { + const row = await this.db().selectFrom('tours').select('place_id').where('place_id', '=', place_id).executeTakeFirst(); + return !!row; + } + + /** TO4 — `SELECT place_id FROM tours WHERE place_id IN (...)`: which of these places are tours. */ + async listTourPlaceIds(place_ids: number[]): Promise { + if (place_ids.length === 0) return []; + const rows = await this.db().selectFrom('tours').select('place_id').where('place_id', 'in', place_ids).execute(); + return rows.map((r) => r.place_id); + } + + /** + * TO5 — `SELECT 1 FROM day_assignments da JOIN tours t ON t.place_id = + * da.place_id WHERE da.day_id = ? AND da.place_id = ? [AND da.id != ?]`. + * + * A tour fills its day, so one day holds it at most once. Create and move + * both ask this inside the transaction that writes the assignment; move + * passes the assignment being moved so it does not collide with itself. + * False for an ordinary place, which may sit on a day any number of times. + */ + async isOnDay(day_id: number, place_id: number, except_assignment_id?: number): Promise { + let query = this.db() + .selectFrom('day_assignments as da') + .innerJoin('tours as t', 't.place_id', 'da.place_id') + .select('da.id') + .where('da.day_id', '=', day_id) + .where('da.place_id', '=', place_id); + if (except_assignment_id !== undefined) query = query.where('da.id', '!=', except_assignment_id); + return !!(await query.executeTakeFirst()); + } + + /** TO6 — every `tours` row of a trip's places, for the trip copy to remap. */ + async listRowsForTrip(trip_id: number): Promise { + return await this.db() + .selectFrom('tours as t') + .innerJoin('places as p', 'p.id', 't.place_id') + .selectAll('t') + .where('p.trip_id', '=', trip_id) + .orderBy('t.place_id', 'asc') + .execute(); + } + + /** TO7 — `INSERT INTO tours (place_id, tour_type, distance, ...) VALUES (...)`. */ + async insertTour(input: TourInsert): Promise { + await this.insert({ + place: input.place_id, + tourTypeRef: input.tour_type, + distance: input.distance, + elevation_gain: input.elevation_gain, + elevation_loss: input.elevation_loss, + duration: input.duration, + match_confidence: input.match_confidence, + max_hiking_difficulty: input.max_hiking_difficulty, + }); + } + + /** + * TO8 — a copied tour keeps every column, `created_at` included, so the + * copy lists its tours in the source's order. + */ + async insertCopy(place_id: number, row: Omit): Promise { + await this.db() + .insertInto('tours') + .values({ ...row, place_id }) + .execute(); + } + + /** + * TO9 — `UPDATE tours SET tour_type = ?, distance = ?, ... WHERE place_id = + * ? AND place_id IN (SELECT id FROM places WHERE trip_id = ?)`. Returns + * whether a row of that trip was updated. + */ + async updateInTrip(trip_id: number, place_id: number, input: TourUpdate): Promise { + const result = await this.db() + .updateTable('tours') + .set({ + tour_type: input.tour_type, + distance: input.distance, + elevation_gain: input.elevation_gain, + elevation_loss: input.elevation_loss, + duration: input.duration, + match_confidence: input.match_confidence, + max_hiking_difficulty: input.max_hiking_difficulty, + }) + .where('place_id', '=', place_id) + .where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id)) + .executeTakeFirst(); + return Number(result.numUpdatedRows ?? 0) > 0; + } +} + +function toListRow & { planned: unknown; has_waypoints: unknown }>(r: R): TourListRow { + return { ...r, planned: Number(r.planned), has_waypoints: Number(r.has_waypoints) }; +} diff --git a/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts b/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts new file mode 100644 index 000000000..6dfbb54e7 --- /dev/null +++ b/server/src/db/repositories/TrekPhotoCacheMeta.repository.ts @@ -0,0 +1,50 @@ +import type { TrekPhotoCacheMeta } from '../entities/TrekPhotoCacheMeta.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** TC1's row — `content_type`/`fetched_at`, the two columns `getFresh` needs. */ +export interface TrekPhotoCacheFreshness { + content_type: string; + fetched_at: number; +} + +export class TrekPhotoCacheMetaRepository extends TrekRepository { + /** TC1 — `SELECT content_type, fetched_at FROM trek_photo_cache_meta WHERE cache_key = ?`. */ + async findFreshness(cache_key: string): Promise { + const row = await this.findOne({ cache_key }, { fields: ['content_type', 'fetched_at'] }); + return row ? { content_type: row.content_type, fetched_at: row.fetched_at } : null; + } + + /** + * TC2/TC3/TC6 — `DELETE FROM trek_photo_cache_meta WHERE cache_key = ?`, + * the same statement text at three call sites (the TTL-expired branch, the + * object-missing branch, and `sweepExpired`'s stale pass) — one method (D4). + */ + async deleteByCacheKey(cache_key: string): Promise { + return await this.nativeDelete({ cache_key }); + } + + /** + * TC4 (`put`) — `INSERT OR REPLACE INTO trek_photo_cache_meta (cache_key, + * content_type, fetched_at) VALUES (?, ?, ?)`. `cache_key` is the table's + * only unique/primary column (`NOT NULL` since Plan 4 Task 8a's + * cache_key-tightening migration — R5's Task 0 finding was that every + * writer already bound a non-null value, so this was a schema tightening, + * not a behaviour change), so it is the sole conflict target, same upsert + * shape as `AppSettingsRepository.setValue`. + */ + async upsertMeta(cache_key: string, content_type: string, fetched_at: number): Promise { + await this.upsert({ cache_key, content_type, fetched_at }, { onConflictFields: ['cache_key'], onConflictAction: 'merge' }); + } + + /** TC5 — `SELECT cache_key FROM trek_photo_cache_meta WHERE fetched_at < ?`, `sweepExpired`'s stale-row pass. */ + async listStale(cutoff: number): Promise { + const rows = await this.find({ fetched_at: { $lt: cutoff } }, { fields: ['cache_key'] }); + return rows.map((row) => row.cache_key).filter((key): key is string => key != null); + } + + /** TC7 — `SELECT 1 FROM trek_photo_cache_meta WHERE cache_key = ?`, `sweepExpired`'s pass-2 row-less-object guard. */ + async existsByCacheKey(cache_key: string): Promise { + const row = await this.findOne({ cache_key }, { fields: ['cache_key'] }); + return !!row; + } +} diff --git a/server/src/db/repositories/TrekPhotos.repository.ts b/server/src/db/repositories/TrekPhotos.repository.ts new file mode 100644 index 000000000..4d6b12e79 --- /dev/null +++ b/server/src/db/repositories/TrekPhotos.repository.ts @@ -0,0 +1,157 @@ +import type { TrekPhoto } from '../../types'; +import type { TrekPhotos } from '../entities/TrekPhotos.entity'; +import { coalesceParam } from '../dialect/sql-functions'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `SELECT * FROM trek_photos WHERE id = ?`'s row (PH6/MA4 — the same + * statement text, one method serving both `TrekPhotoRegistrationService + * .resolve` and `MemoriesAccessService.canAccessTrekPhoto`'s entry read). + * Reuses the pre-existing `TrekPhoto` type from `../../types` (the row shape + * every consumer already imports) rather than a second, parallel row + * interface — its key set matches the entity's scalar columns exactly + * (`owner` is the hidden relation, `owner_id` its persist(false) twin). + */ +const _trekPhotoRowKeys: AssertRowKeys = true; + +export class TrekPhotosRepository extends TrekRepository { + /** PH1's SELECT half — `getOrCreate`'s existing-row lookup. */ + async findByProviderAsset(provider: string, asset_id: string, owner_id: number): Promise<{ id: number } | null> { + const row = await this.findOne({ provider, asset_id, owner: owner_id }, { fields: ['id'] }); + return row ? { id: row.id } : null; + } + + /** PH2 — conditional passphrase re-encrypt on an already-registered remote photo. */ + async setPassphrase(id: number, passphrase: string): Promise { + await this.nativeUpdate({ id }, { passphrase }); + } + + /** PH3 — `INSERT INTO trek_photos (provider, asset_id, owner_id, passphrase, media_type)`. */ + async insertRemote(data: { + provider: string; + asset_id: string; + owner_id: number; + passphrase: string | null; + media_type: string; + }): Promise { + return await this.insert({ + provider: data.provider, + asset_id: data.asset_id, + owner: data.owner_id, + passphrase: data.passphrase, + media_type: data.media_type, + }); + } + + /** PH4's SELECT half — `getOrCreateLocal`'s existing-row lookup. */ + async findLocalByPath(file_path: string): Promise<{ id: number } | null> { + const row = await this.findOne({ provider: 'local', file_path }, { fields: ['id'] }); + return row ? { id: row.id } : null; + } + + /** PH5 — `INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, media_type, duration_ms)`, always `provider = 'local'`. */ + async insertLocal(data: { + file_path: string; + thumbnail_path: string | null; + width: number | null; + height: number | null; + media_type: string; + duration_ms: number | null; + }): Promise { + return await this.insert({ + provider: 'local', + file_path: data.file_path, + thumbnail_path: data.thumbnail_path, + width: data.width, + height: data.height, + media_type: data.media_type, + duration_ms: data.duration_ms, + }); + } + + /** + * PH6 (`TrekPhotoRegistrationService.resolve`) / MA4 (`MemoriesAccessService + * .canAccessTrekPhoto`'s entry read) — byte-identical `SELECT * FROM + * trek_photos WHERE id = ?`, one method for both call sites (D4). + */ + async findById(id: number): Promise { + const row = await this.findOne({ id }); + return row ? (toRow(row) as TrekPhoto) : null; + } + + /** PH7 — retarget a local photo onto a provider once it has been uploaded there. */ + async retarget(id: number, provider: string, asset_id: string, owner_id: number): Promise { + await this.nativeUpdate({ id }, { provider, asset_id, owner: owner_id }); + } + + /** + * PH8 — stamp a generated local thumbnail. `COALESCE(width, ?)`/ + * `COALESCE(height, ?)` keep dimensions a re-generated thumbnail already + * knew (the caller-side guard for "don't blank what's known" stays on the + * service, same as legacy). + */ + async patchThumbnail(id: number, thumbnail_path: string, width: number, height: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { + thumbnail_path, + width: coalesceParam(platform, 'width', width), + height: coalesceParam(platform, 'height', height), + }); + } + + /** + * PH9 — `COALESCE`d capture metadata write. The service computes the final + * `taken_at`/`lat`/`lng` (the all-empty early return, the pair-or-neither + * coordinate guard) before calling this. + * + * Legacy SQL (#1587): + * `UPDATE trek_photos SET taken_at = COALESCE(taken_at, ?), lat = COALESCE(lat, ?), lng = COALESCE(lng, ?) + * WHERE id = ? AND ((taken_at IS NULL AND ? IS NOT NULL) OR (lat IS NULL AND ? IS NOT NULL) OR (lng IS NULL AND ? IS NOT NULL))`. + * + * Answers whether the row learned anything. The guard is what makes that + * answer mean something: SQLite counts a matched row as changed even when + * every COALESCE kept the old value, and the caller broadcasts a journey + * refresh on it. The `? IS NOT NULL` halves are known here, so the guard is + * built as an `$or` over just the columns that bring a value; with none of + * them there is nothing to learn and no statement is issued. + */ + async patchCaptureMetadata(id: number, taken_at: string | null, lat: number | null, lng: number | null): Promise { + const learns: Array<{ taken_at: null } | { lat: null } | { lng: null }> = []; + if (taken_at != null) learns.push({ taken_at: null }); + if (lat != null) learns.push({ lat: null }); + if (lng != null) learns.push({ lng: null }); + if (learns.length === 0) return false; + const platform = this.getEntityManager().getPlatform(); + const changed = await this.nativeUpdate({ id, $or: learns }, { + taken_at: coalesceParam(platform, 'taken_at', taken_at), + lat: coalesceParam(platform, 'lat', lat), + lng: coalesceParam(platform, 'lng', lng), + }); + return changed > 0; + } + + /** PH11 — `DELETE FROM trek_photos WHERE id = ? AND provider != 'local'`; local photos are never auto-reclaimed here. */ + async deleteNonLocal(id: number): Promise { + return await this.nativeDelete({ id, provider: { $ne: 'local' } }); + } + + /** + * TH1 — `SELECT file_path FROM trek_photos WHERE file_path LIKE 'journey/%'`. + * The caller (`ThumbnailService.sweepOrphanThumbs`) always passes the + * literal `'journey/'` prefix; `$like` binds the wildcard-appended value as + * a genuine parameter (no dialect helper needed — a plain `LIKE` with a + * hardcoded prefix, no injection surface). Rows with a NULL `file_path` + * never match `LIKE`, so every returned value is a real string. + */ + async listByFilePathPrefix(prefix: string): Promise { + const rows = await this.find({ file_path: { $like: `${prefix}%` } }, { fields: ['file_path'] }); + return rows.map((row) => row.file_path).filter((path): path is string => path != null); + } + + /** TH2 — `SELECT thumbnail_path FROM trek_photos WHERE thumbnail_path LIKE 'journey/thumbs/%'`, same shape as TH1 on the sibling column. */ + async listByThumbnailPathPrefix(prefix: string): Promise { + const rows = await this.find({ thumbnail_path: { $like: `${prefix}%` } }, { fields: ['thumbnail_path'] }); + return rows.map((row) => row.thumbnail_path).filter((path): path is string => path != null); + } +} diff --git a/server/src/db/repositories/TripAlbumLinks.repository.ts b/server/src/db/repositories/TripAlbumLinks.repository.ts new file mode 100644 index 000000000..1bb9d71b2 --- /dev/null +++ b/server/src/db/repositories/TripAlbumLinks.repository.ts @@ -0,0 +1,165 @@ +import type { TripAlbumLinks } from '../entities/TripAlbumLinks.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** MA7/MA8's shared row shape — album_id always needed, passphrase only by the sync path (MA8). */ +export interface TripAlbumLinkSyncRow { + album_id: string; + passphrase: string | null; +} + +/** + * `trip_album_links`'s single-table write shape (Plan 3e Task 7, + * UM7/UM10). `trip_id`/`id` are typed `number | string` — the legacy + * statements bound `tripId`/`linkId` raw (T5's raw-bind seam), including the + * primary-key `id` column in UM10's `DELETE ... WHERE id = ?`. + */ +interface TripAlbumLinksWriteKyselyDB { + trip_album_links: { + id: number | string; + trip_id: number | string; + user_id: number; + provider: string; + album_id: string; + album_name: string; + passphrase: string | null; + }; +} + +/** + * UM7's insert-only shape — `id`/`sync_enabled`/`last_synced_at`/`created_at` + * are autoincrement/defaulted and omitted from `.values()` (the legacy + * statement's own column list omits them too), so a SEPARATE interface from + * {@link TripAlbumLinksWriteKyselyDB} (the `FileLinksWriteKyselyDB` / + * `TripPhotosInsertKyselyDB` precedent) — that one declares `id` as + * required, which `.insertInto()` would then also require in `.values()`. + */ +interface TripAlbumLinksInsertKyselyDB { + trip_album_links: { + trip_id: number | string; + user_id: number; + provider: string; + album_id: string; + album_name: string; + passphrase: string | null; + }; +} + +/** UM3's joined projection (`UnifiedMemoriesService.listTripAlbumLinks`) — own Kysely shape, one purpose-shaped interface per statement. */ +interface TripAlbumLinksListKyselyDB { + trip_album_links: { + id: number; + trip_id: number | string; + user_id: number; + provider: string; + album_id: string; + album_name: string; + sync_enabled: number; + last_synced_at: string | null; + created_at: string | null; + }; + users: { id: number; username: string | null }; +} + +/** UM3's row shape — `tal.*, u.username`. */ +export interface TripAlbumLinkListRow { + id: number; + trip_id: number | string; + user_id: number; + provider: string; + album_id: string; + album_name: string; + sync_enabled: number; + last_synced_at: string | null; + created_at: string | null; + username: string | null; +} + +export class TripAlbumLinksRepository extends TrekRepository { + /** + * MA7 (`getAlbumIdFromLink`) / MA8 (`getAlbumLinkForSync`) — same + * user-scoped WHERE, different SELECT list in the legacy text + * (`album_id` alone vs. `album_id, passphrase`); one method serving both, + * per the inventory's own proposal, since MA7's caller simply ignores the + * extra `passphrase` column. `SELECT album_id, passphrase FROM + * trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?` — note + * this is USER-scoped, not just trip-scoped: a link belongs to the user + * who created it, not the whole trip. + */ + async findScoped(id: number, trip_id: number, user_id: number): Promise { + const row = await this.findOne({ id, trip: trip_id, user: user_id }, { fields: ['album_id', 'passphrase'] }); + return row ? { album_id: row.album_id, passphrase: row.passphrase ?? null } : null; + } + + /** + * MA9 — `UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP + * WHERE id = ?`. Deliberately unscoped by trip/user, matching the legacy + * statement: the caller (`syncImmichAlbum`/`syncSynologyAlbum`) has + * already resolved `linkId` through `findScoped` (MA7/MA8) earlier in the + * same request, so this write relies entirely on that prior resolution — + * not a defect to fix here (see the class docstring on `MemoriesAccessService`). + */ + async touchSyncTime(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { last_synced_at: currentTimestamp(platform) }); + } + + /** + * UM7 (`UnifiedMemoriesService.createTripAlbumLink`) — `INSERT OR IGNORE + * INTO trip_album_links (trip_id, user_id, provider, album_id, album_name, + * passphrase) VALUES (?, ?, ?, ?, ?, ?)`. `(trip, user, provider, + * album_id)` is the entity's real `uniques` constraint, so `ON CONFLICT + * (trip_id, user_id, provider, album_id) DO NOTHING` is the exact SQLite + * equivalent of the legacy `OR IGNORE`. Returns whether a row was actually + * inserted (legacy `result.changes === 0` drove the "already linked" 409). + */ + async insertIgnore(row: { trip_id: number | string; user_id: number; provider: string; album_id: string; album_name: string; passphrase: string | null }): Promise { + const result = await this.kysely() + .insertInto('trip_album_links') + .values({ trip_id: row.trip_id, user_id: row.user_id, provider: row.provider, album_id: row.album_id, album_name: row.album_name, passphrase: row.passphrase }) + .onConflict((oc) => oc.columns(['trip_id', 'user_id', 'provider', 'album_id']).doNothing()) + .executeTakeFirst(); + return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n; + } + + /** UM10 (`UnifiedMemoriesService.removeAlbumLink`, inside its transaction) — `DELETE FROM trip_album_links WHERE id = ? AND trip_id = ? AND user_id = ?`. */ + async deleteScoped(id: number | string, trip_id: number | string, user_id: number): Promise { + await this.kysely() + .deleteFrom('trip_album_links') + .where('id', '=', id) + .where('trip_id', '=', trip_id) + .where('user_id', '=', user_id) + .execute(); + } + + /** + * UM3 (`UnifiedMemoriesService.listTripAlbumLinks`) — `SELECT tal.id, + * tal.trip_id, tal.user_id, tal.provider, tal.album_id, tal.album_name, + * tal.sync_enabled, tal.last_synced_at, tal.created_at, u.username FROM + * trip_album_links tal JOIN users u WHERE tal.trip_id = ? AND tal.provider + * IN (dynamic) ORDER BY tal.created_at ASC`. + */ + async listForTrip(trip_id: number | string, enabled_providers: string[]): Promise { + if (enabled_providers.length === 0) return []; + const rows = await this.kysely() + .selectFrom('trip_album_links as tal') + .innerJoin('users as u', 'u.id', 'tal.user_id') + .select([ + 'tal.id as id', + 'tal.trip_id as trip_id', + 'tal.user_id as user_id', + 'tal.provider as provider', + 'tal.album_id as album_id', + 'tal.album_name as album_name', + 'tal.sync_enabled as sync_enabled', + 'tal.last_synced_at as last_synced_at', + 'tal.created_at as created_at', + 'u.username as username', + ]) + .where('tal.trip_id', '=', trip_id) + .where('tal.provider', 'in', enabled_providers) + .orderBy('tal.created_at', 'asc') + .execute(); + return rows; + } +} diff --git a/server/src/db/repositories/TripDocumentLinks.repository.ts b/server/src/db/repositories/TripDocumentLinks.repository.ts new file mode 100644 index 000000000..418f8ccbc --- /dev/null +++ b/server/src/db/repositories/TripDocumentLinks.repository.ts @@ -0,0 +1,257 @@ +import { coalesceParam, currentTimestamp, nowPlusSeconds } from '../dialect/sql-functions'; +import type { TripDocumentLinks } from '../entities/TripDocumentLinks.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** A bare `trip_document_links` row (every scalar column). */ +export interface TripDocumentLinkRow { + id: number; + trip_id: number; + connection_id: number; + provider_id: string; + remote_scope_key: string; + remote_root_id: string | null; + remote_root_path: string | null; + remote_label: string; + direction: string; + delete_policy: string; + conflict_policy: string; + sync_enabled: number; + webhook_token: string | null; + webhook_secret: string | null; + webhook_subscription_id: string | null; + remote_cursor: string | null; + last_sync_at: string | null; + last_sync_state: string; + last_sync_error: string | null; + failure_count: number; + next_attempt_at: string | null; + created_by: number | null; + created_at: string | null; + updated_at: string | null; +} + +const _linkRowKeys: AssertRowKeys = true; + +/** DSC17's dynamic SET list (`updateLink`) — only the fields present get written; `updated_at` is always stamped by {@link TripDocumentLinksRepository.updateFields} itself. */ +export interface TripDocumentLinkPatch { + direction?: string; + delete_policy?: string; + conflict_policy?: string; + sync_enabled?: number; + remote_label?: string; + /** The un-orphan reset (`sets.push("last_sync_state = 'never'")`) — set by the service, never inferred here. */ + last_sync_state?: string; +} + +/** + * `trip_document_links` — one trip's binding of a scope (folder/tag/space) at + * a `document_connections` row, both directions. + */ +export class TripDocumentLinksRepository extends TrekRepository { + /** + * DS1 (`DocSyncService.dueLinks`) — `SELECT * FROM trip_document_links + * WHERE sync_enabled=1 AND last_sync_state!='orphaned' AND + * failure_count { + if (enabledProviderIds.length === 0) return []; + const platform = this.getEntityManager().getPlatform(); + const rows = await this.qb('l') + .select(['l.*']) + .where({ + sync_enabled: 1, + last_sync_state: { $ne: 'orphaned' }, + failure_count: { $lt: circuitOpenAfter }, + $or: [{ next_attempt_at: null }, { next_attempt_at: { $lte: currentTimestamp(platform) } }], + provider_id: { $in: [...enabledProviderIds] }, + }) + .orderBy([ + { [coalesceParam(platform, 'next_attempt_at', '1970-01-01')]: 'asc' }, + { [coalesceParam(platform, 'last_sync_at', '1970-01-01')]: 'asc' }, + { id: 'asc' }, + ]) + .limit(limit) + .execute('all', false); + return rows; + } + + /** DSC12 (`listLinks`) — `SELECT * FROM trip_document_links WHERE trip_id = ? ORDER BY id`. */ + async listForTrip(tripId: number): Promise { + return await this.qb('l').select(['l.*']).where({ trip_id: tripId }).orderBy({ id: 'asc' }).execute('all', false); + } + + /** DSC13 (`getLink`) — `SELECT * FROM trip_document_links WHERE id = ?`. */ + async findById(id: number): Promise { + return await this.qb('l').select(['l.*']).where({ id }).execute('get', false); + } + + /** DSC14 (`getLinkByToken`) — `SELECT * FROM trip_document_links WHERE webhook_token = ?`. **PUBLIC/ANONYMOUS ENTRYPOINT** (the webhook controller's sole authentication check). */ + async findByToken(token: string): Promise { + return await this.qb('l').select(['l.*']).where({ webhook_token: token }).execute('get', false); + } + + /** DSC15 (`createLink`'s pre-check) — `SELECT * FROM trip_document_links WHERE trip_id = ? AND connection_id = ? AND remote_scope_key = ?`. */ + async findByTripConnectionScope(tripId: number, connectionId: number, scopeKey: string): Promise { + return await this.qb('l') + .select(['l.*']) + .where({ trip_id: tripId, connection_id: connectionId, remote_scope_key: scopeKey }) + .execute('get', false); + } + + /** + * DSC16 (`createLink`) — `INSERT INTO trip_document_links (trip_id, + * connection_id, provider_id, remote_scope_key, remote_root_id, + * remote_root_path, remote_label, direction, delete_policy, + * conflict_policy, sync_enabled, webhook_token, webhook_secret, + * created_by, next_attempt_at) VALUES (?×14, CURRENT_TIMESTAMP)`. + * `webhook_secret` arrives already encrypted (R7) — this method never + * calls `encryptSecrets` itself. `next_attempt_at` is seeded to "now" so a + * fresh binding sorts first under DS1's due-links ordering. + */ + async insertLink(data: { + trip_id: number; + connection_id: number; + provider_id: string; + remote_scope_key: string; + remote_root_id: string | null; + remote_root_path: string | null; + remote_label: string; + direction: string; + delete_policy: string; + conflict_policy: string; + sync_enabled: number; + webhook_token: string; + webhook_secret: string; + created_by: number; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + return await this.insert({ + trip: data.trip_id, + connection: data.connection_id, + provider_id: data.provider_id, + remote_scope_key: data.remote_scope_key, + remote_root_id: data.remote_root_id, + remote_root_path: data.remote_root_path, + remote_label: data.remote_label, + direction: data.direction, + delete_policy: data.delete_policy, + conflict_policy: data.conflict_policy, + sync_enabled: data.sync_enabled, + webhook_token: data.webhook_token, + webhook_secret: data.webhook_secret, + createdByRef: data.created_by, + next_attempt_at: currentTimestamp(platform), + }); + } + + /** + * DSC17 (`updateLink`) — the dynamic SET list, as a typed partial (see + * {@link TripDocumentLinkPatch}'s own docstring). `updated_at` is always + * stamped; the caller (`DocSyncConfigService.updateLink`) only calls this + * once it has confirmed at least one field of `patch` is present, matching + * the legacy `sets.length === 0` early return. + */ + async updateFields(id: number, patch: TripDocumentLinkPatch): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { ...patch, updated_at: currentTimestamp(platform) }); + } + + /** DSC19 (`deleteLink`, inside `uow.transactional`) — `DELETE FROM trip_document_links WHERE id = ?`. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** + * DSC21 (`markOrphanedLinks`) — `UPDATE trip_document_links SET + * last_sync_state='orphaned', sync_enabled=0, updated_at=CURRENT_TIMESTAMP + * WHERE sync_enabled=1 AND last_sync_state!='orphaned' AND connection_id + * IN ()`. The predicate itself is + * `DocumentConnectionsRepository.listOrphanedIds()` (DSC20/DSC21's shared + * repository predicate) — this method takes the already-resolved id list, + * matching {@link dueLinks}'s same "resolve the subquery's rows first, + * filter here" shape. An empty list short-circuits to zero rows changed, + * the same outcome `IN (SELECT ... WHERE )` would produce. + */ + async markOrphanedByConnectionIds(connectionIds: readonly number[]): Promise { + if (connectionIds.length === 0) return 0; + const platform = this.getEntityManager().getPlatform(); + return await this.nativeUpdate( + { sync_enabled: 1, last_sync_state: { $ne: 'orphaned' }, connection_id: { $in: [...connectionIds] } }, + { last_sync_state: 'orphaned', sync_enabled: 0, updated_at: currentTimestamp(platform) }, + ); + } + + /** + * DS25 (`DocSyncService.recordLinkSuccess`) — `UPDATE trip_document_links + * SET remote_cursor=?, last_sync_at=CURRENT_TIMESTAMP, last_sync_state=?, + * last_sync_error=?, failure_count=0, next_attempt_at=NULL, + * updated_at=CURRENT_TIMESTAMP WHERE id=?`. + */ + async recordSuccess(id: number, data: { remote_cursor: string | null; state: string; error_code: string | null }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + remote_cursor: data.remote_cursor, + last_sync_at: currentTimestamp(platform), + last_sync_state: data.state, + last_sync_error: data.error_code, + failure_count: 0, + next_attempt_at: null, + updated_at: currentTimestamp(platform), + }, + ); + } + + /** + * DS26 (`DocSyncService.recordLinkFailure`) — `UPDATE trip_document_links + * SET last_sync_at=CURRENT_TIMESTAMP, last_sync_state=?, last_sync_error=?, + * failure_count=?, next_attempt_at=datetime('now','+'||?||' seconds'), + * updated_at=CURRENT_TIMESTAMP WHERE id=?`. `nextAttemptSeconds` (the + * caller's own `backoffSeconds(...)` runtime value) is turned into the + * `datetime('now', ...)` fragment HERE, through Task 0's `nowPlusSeconds` + * helper — the service passes the plain second count, never a + * pre-rendered fragment, keeping every dialect-function call inside the + * repository layer. + */ + async recordFailure(id: number, data: { state: string; error_code: string; failure_count: number; next_attempt_seconds: number }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + last_sync_at: currentTimestamp(platform), + last_sync_state: data.state, + last_sync_error: data.error_code, + failure_count: data.failure_count, + next_attempt_at: nowPlusSeconds(platform, data.next_attempt_seconds), + updated_at: currentTimestamp(platform), + }, + ); + } + + /** DSCTRL3 (R2 — moved off the controller) — `UPDATE trip_document_links SET webhook_subscription_id = ? WHERE id = ?`, the webhook-registration follow-up. */ + async setWebhookSubscriptionId(id: number, subscriptionId: string): Promise { + await this.nativeUpdate({ id }, { webhook_subscription_id: subscriptionId }); + } +} diff --git a/server/src/db/repositories/TripFiles.repository.ts b/server/src/db/repositories/TripFiles.repository.ts new file mode 100644 index 000000000..11c8850b1 --- /dev/null +++ b/server/src/db/repositories/TripFiles.repository.ts @@ -0,0 +1,272 @@ +import { currentTimestamp } from '../dialect/sql-functions'; +import type { TripFiles } from '../entities/TripFiles.entity'; +import { type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A bare `trip_files` row — every scalar column of the entity, incl. the SIX + * `persist(false)` relation mirrors (`trip_id`, `place_id`, `reservation_id`, + * `note_id`, `uploaded_by`, `message_id` — the trap the class docstring + * below names). Kysely's `selectAll()` reads real DB columns directly, so it + * carries every one of these without the bare-QB-`.select([...])` drop a + * `columnRef` would otherwise be needed to avoid. + */ +export interface TripFileRow { + id: number; + trip_id: number; + place_id: number | null; + reservation_id: number | null; + filename: string; + original_name: string; + file_size: number | null; + mime_type: string | null; + description: string | null; + created_at: string | null; + note_id: number | null; + uploaded_by: number | null; + starred: number | null; + deleted_at: string | null; + message_id: number | null; +} + +const _tripFileRowKeys: AssertRowKeys = true; + +/** FILE_SELECT's joined projection (FL7/FL11/FL15/FL17/FL20). */ +export interface TripFileJoinRow extends TripFileRow { + reservation_title: string | null; + uploaded_by_name: string | null; + uploaded_by_avatar: string | null; +} + +interface TripFilesKyselyDB { + trip_files: TripFileRow; + reservations: { id: number; title: string }; + users: { id: number; username: string; avatar: string | null }; +} + +/** + * `trip_files` — trip attachments (uploads, expense receipts). Every read + * below goes through Kysely rather than the QueryBuilder: this entity has + * SIX `persist(false)` mirror columns (see {@link TripFileRow}'s docstring), + * and a bare QB `.select([...])` silently drops any of them it names without + * `columnRef` (the program-wide trap `RoadtripDayBoundariesRepository` + * documents) — Kysely's `selectAll()`/typed column list reads the real DB + * columns directly and has no such gap, so it is the simpler, uniformly-safe + * choice for a row this wide rather than six `columnRef` calls repeated + * across five read methods. + */ +export class TripFilesRepository extends TrekRepository { + /** FILE_SELECT (`:22-27`) — `trip_files f LEFT JOIN reservations r ON f.reservation_id = r.id LEFT JOIN users u ON f.uploaded_by = u.id`. */ + private joinedQuery() { + return this.kysely() + .selectFrom('trip_files as f') + .leftJoin('reservations as r', 'r.id', 'f.reservation_id') + .leftJoin('users as u', 'u.id', 'f.uploaded_by') + .selectAll('f') + .select(['r.title as reservation_title', 'u.username as uploaded_by_name', 'u.avatar as uploaded_by_avatar']); + } + + /** + * FL5 (`FilesService.getFileById`) — `SELECT * FROM trip_files WHERE id = ? + * AND trip_id = ?`, the trip-scoping guard reused by budget/collab reads + * (BG29/44, CB21) and by `permanentDeleteFile`/`softDeleteFile`/`remove`'s + * pre-image reads. + */ + async findInTrip(id: number, trip_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .selectAll() + .where('id', '=', id) + .where('trip_id', '=', trip_id) + .executeTakeFirst(); + } + + /** FL6 (`FilesService.getDeletedFile`) — same as {@link findInTrip} plus `AND deleted_at IS NOT NULL`, the trash-scoped variant. */ + async findDeletedInTrip(id: number, trip_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .selectAll() + .where('id', '=', id) + .where('trip_id', '=', trip_id) + .where('deleted_at', 'is not', null) + .executeTakeFirst(); + } + + /** + * FL7 (`FilesService.listFiles`) — `` FILE_SELECT WHERE f.trip_id = ? AND + * f.message_id IS NULL AND f.deleted_at IS (NOT) NULL ORDER BY f.starred + * DESC, f.created_at DESC ``. `showTrash` toggles the `deleted_at` arm + * (D4: two WHERE shapes, one method, matching the legacy ternary). + */ + async listForTrip(trip_id: number, showTrash: boolean): Promise { + let q = this.joinedQuery().where('f.trip_id', '=', trip_id).where('f.message_id', 'is', null); + q = showTrash ? q.where('f.deleted_at', 'is not', null) : q.where('f.deleted_at', 'is', null); + return await q.orderBy('f.starred', 'desc').orderBy('f.created_at', 'desc').execute(); + } + + /** + * FL11/FL15/FL17/FL20 — `` FILE_SELECT WHERE f.id = ? `` , the SAME + * re-select text run after `createFile`/`updateFile`/`toggleStarred`/ + * `restoreFile`. No `trip_id` filter (matching the legacy statement — the + * caller already knows `id` is in-scope by construction, having just + * written it). + */ + async findByIdWithJoins(id: number): Promise { + return await this.joinedQuery().where('f.id', '=', id).executeTakeFirst(); + } + + /** FL22 (`FilesService.emptyTrash`'s read half) — `SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL`. */ + async listTrashed(trip_id: number): Promise { + return await this.kysely() + .selectFrom('trip_files') + .selectAll() + .where('trip_id', '=', trip_id) + .where('deleted_at', 'is not', null) + .execute(); + } + + /** + * FL9 (`FilesService.createFile`) — `INSERT INTO trip_files (trip_id, + * place_id, reservation_id, filename, original_name, file_size, mime_type, + * description, uploaded_by) VALUES (?×9)`. Relation properties + * (`trip`/`place`/`reservation`/`uploadedByRef`), not their `persist(false)` + * `_id` mirrors — MikroORM accepts a raw scalar id for a `Ref`-typed + * property (`InviteTokensRepository.insertInvite`'s precedent). + * `uploadedByRef` is the entity's own name for the `uploaded_by` + * `joinColumn` relation (`TripFiles.entity.ts`). `trip_id: number | + * string` — the caller's own trip id, already vouched for by the + * guard/access check upstream, written (never compared) here; SQLite's + * own INTEGER-affinity conversion on INSERT normalises either shape the + * same way `DayAccommodationsRepository.insertStay`'s `trip_id` does, so + * no `toRowId` gate is needed on a write-only value (rule 21 governs the + * read-then-write seam, not every scalar a write ever carries). + */ + async insertFile(row: { + trip_id: number | string; + place_id: number | null; + reservation_id: number | null; + filename: string; + original_name: string; + file_size: number; + mime_type: string; + description: string | null; + uploaded_by: number; + }): Promise { + return await this.insert({ + trip: row.trip_id, + place: row.place_id, + reservation: row.reservation_id, + filename: row.filename, + original_name: row.original_name, + file_size: row.file_size, + mime_type: row.mime_type, + description: row.description, + uploadedByRef: row.uploaded_by, + }); + } + + /** + * FL12 (`FilesService.updateFile`) — `UPDATE trip_files SET description = + * ?, place_id = ?, reservation_id = ? WHERE id = ?`. The caller resolves + * the `!== undefined ? (x || null) : current.x` presence sentinel BEFORE + * calling this (matching the legacy inline ternaries) — this method just + * writes the three final values verbatim. + */ + async updateFile(id: number, write: { description: string | null; place_id: number | null; reservation_id: number | null }): Promise { + await this.nativeUpdate({ id }, { description: write.description, place: write.place_id, reservation: write.reservation_id }); + } + + /** FL16 (`FilesService.toggleStarred`) — `UPDATE trip_files SET starred = ? WHERE id = ?`. */ + async setStarred(id: number, starred: number): Promise { + await this.nativeUpdate({ id }, { starred }); + } + + /** FL18 (`FilesService.softDeleteFile`) — `UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?`. */ + async softDelete(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { deleted_at: currentTimestamp(platform) }); + } + + /** FL19 (`FilesService.restoreFile`) — `UPDATE trip_files SET deleted_at = NULL WHERE id = ?`. */ + async restore(id: number): Promise { + await this.nativeUpdate({ id }, { deleted_at: null }); + } + + /** FL21 (`FilesService.permanentDeleteFile`) — `DELETE FROM trip_files WHERE id = ?`, run after a successful (or idempotent-missing) storage delete. */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 5 (doc-sync) — cross-domain additive methods, flagged per the + // task brief (DS4/DS15/DS16/DS19/DS29/DS32/DS36 are cross-domain). DS15 + // reuses the existing {@link setStarred} above; these four are new. + // --------------------------------------------------------------------------- + + /** + * DS4/DS32 (`DocSyncService.applyAction` 'rename_local', `resolveConflict` + * keep='provider' rename) — `UPDATE trip_files SET original_name = ? + * WHERE id = ?`, the identical statement shape at both legacy call sites + * — one method, not two. + */ + async renameOriginalName(id: number, original_name: string): Promise { + await this.nativeUpdate({ id }, { original_name }); + } + + /** + * DS29 (`DocSyncService.resolveConflict`'s local-name read) — `SELECT + * original_name FROM trip_files WHERE id = ?`. Kysely, per this class's + * own docstring (a bare QB/`fields:` read risks silently dropping one of + * this entity's six `persist(false)` mirror columns — irrelevant to this + * one-column projection, but every other read in this file stays on + * Kysely for the same reason, so this one does too). + */ + async findOriginalName(id: number): Promise { + const row = await this.kysely().selectFrom('trip_files').select('original_name').where('id', '=', id).executeTakeFirst(); + return row?.original_name; + } + + /** + * DS19 (`DocSyncService.loadLocalDocuments`) — `SELECT f.id, + * f.original_name, f.file_size, f.mime_type, f.deleted_at FROM trip_files + * f WHERE f.trip_id = ? AND f.message_id IS NULL AND f.note_id IS NULL`. + * Excludes chat and note attachments — a binding mirrors the trip's + * PAPERWORK, not its conversation. + */ + async listSyncableForTrip(trip_id: number): Promise> { + return await this.kysely() + .selectFrom('trip_files') + .select(['id', 'original_name', 'file_size', 'mime_type', 'deleted_at']) + .where('trip_id', '=', trip_id) + .where('message_id', 'is', null) + .where('note_id', 'is', null) + .execute(); + } + + /** + * DS36 (`DocSyncService.status`'s holdings count) — `SELECT COUNT(*) AS n + * FROM trip_files WHERE trip_id = ? AND deleted_at IS NULL AND message_id + * IS NULL AND note_id IS NULL`. + */ + async countActiveDocuments(trip_id: number): Promise { + const row = await this.kysely() + .selectFrom('trip_files') + .select((eb) => eb.fn.countAll().as('n')) + .where('trip_id', '=', trip_id) + .where('deleted_at', 'is', null) + .where('message_id', 'is', null) + .where('note_id', 'is', null) + .executeTakeFirstOrThrow(); + return row.n; + } + + /** + * FL23 (`FilesService.emptyTrash`'s bulk delete) — `DELETE FROM trip_files + * WHERE id IN (dynamic)`, only for the ids whose storage delete already + * succeeded. `$in` for the dynamic list (rule 23) — no interpolated SQL. + */ + async deleteMany(ids: number[]): Promise { + if (ids.length === 0) return; + await this.nativeDelete({ id: { $in: ids } }); + } +} diff --git a/server/src/db/repositories/TripInviteTokens.repository.ts b/server/src/db/repositories/TripInviteTokens.repository.ts new file mode 100644 index 000000000..450721b63 --- /dev/null +++ b/server/src/db/repositories/TripInviteTokens.repository.ts @@ -0,0 +1,117 @@ +import type { TripInviteTokens } from '../entities/TripInviteTokens.entity'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `trip-invite.service.ts::get`'s projection. */ +export interface TripInviteInfoRow { + token: string; + expires_at: string | null; + created_at: string; +} + +/** `trip-invite.service.ts::resolve`'s joined projection. */ +export interface TripInviteResolvedRow { + trip_id: number; + title: string; + expires_at: string | null; +} + +interface TripInviteResolveKyselyDB { + trip_invite_tokens: { token: string; trip_id: number; expires_at: string | null }; + trips: { id: number; title: string }; +} + +/** + * `trip_invite_tokens` (Plan 4 Task 1). One row per trip (`uniques: [{ + * properties: ['trip'] }]`, `TripInviteTokens.entity.ts`) — every method + * here is scoped by `trip_id`, raw-bind (`number | string`, D4's T5 escape + * hatch): `TripInviteService`'s own methods take `tripId: string | number` + * and bind it unconverted, matching the legacy statements exactly. + */ +export class TripInviteTokensRepository extends TrekRepository { + /** `get()` — `SELECT token, expires_at, created_at FROM trip_invite_tokens WHERE trip_id = ?`. */ + async findInfoByTrip(trip_id: number | string): Promise { + return await this.qb('t') + .select(['t.token', 't.expires_at', 't.created_at']) + .where('t.trip_id = ?', [trip_id]) + .execute('get', false); + } + + /** + * `createOrRotate`'s probe, inside `uow.transactional` — `SELECT id FROM + * trip_invite_tokens WHERE trip_id = ?`, a bare existence check deciding + * the UPDATE-vs-INSERT branch below. + */ + async existsForTrip(trip_id: number | string): Promise { + const row = await this.qb('t') + .select(['t.id']) + .where('t.trip_id = ?', [trip_id]) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * `createOrRotate`'s UPDATE branch (rotate) — `UPDATE trip_invite_tokens + * SET token = ?, expires_at = ?, created_by = ?, created_at = + * CURRENT_TIMESTAMP WHERE trip_id = ?`. `created_by` writes through + * `createdByRef`, the real joined-column relation property — `created_by` + * itself is a `persist(false)` mirror (`TripInviteTokens.entity.ts`). + * `CURRENT_TIMESTAMP` through the shared dialect helper (rule 5), matching + * the column's own `defaultRaw` literal exactly. + */ + async updateForTrip(trip_id: number | string, input: { token: string; expires_at: string | null; created_by: number }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.qb('t') + .update({ token: input.token, expires_at: input.expires_at, createdByRef: input.created_by, created_at: currentTimestamp(platform) }) + .where('t.trip_id = ?', [trip_id]) + .execute('run'); + } + + /** + * `createOrRotate`'s INSERT branch (first link) — `INSERT INTO + * trip_invite_tokens (trip_id, token, created_by, expires_at) VALUES (?, + * ?, ?, ?)`. `created_at` is not in the legacy column set — left to the + * column's own `defaultRaw` (`CURRENT_TIMESTAMP`), same as + * `DayNotesRepository.createNote`'s documented shape for an unnamed + * `defaultRaw` column. `trip`, not the raw `trip_id`: `insert()`'s typed + * relation property wants the entity's own primary-key type — the caller + * (`TripInviteService`) only ever calls this once a trip-scoped route has + * already resolved a REAL numeric trip id through `TripAccessGuard`, so + * `Number(...)` here mirrors the same seam `DayNotesService.create`'s own + * `Number(dayId)`/`Number(tripId)` conversion documents. + */ + async insertForTrip(input: { trip_id: number | string; token: string; created_by: number; expires_at: string | null }): Promise { + await this.insert({ + trip: Number(input.trip_id), + token: input.token, + createdByRef: input.created_by, + expires_at: input.expires_at, + }); + } + + /** `remove()` — `DELETE FROM trip_invite_tokens WHERE trip_id = ?`. */ + async deleteByTrip(trip_id: number | string): Promise { + await this.qb('t') + .delete() + .where('t.trip_id = ?', [trip_id]) + .execute('run'); + } + + /** + * `resolve(token)` — `SELECT t.id AS trip_id, t.title AS title, + * ti.expires_at AS expires_at FROM trip_invite_tokens ti JOIN trips t ON + * ti.trip_id = t.id WHERE ti.token = ?`. Kysely (a cross-table join to + * `trips`, the same escape-hatch order every other cross-table read in + * this program uses) — expiry itself is checked in JS by the caller + * (`TripInviteService.resolve`'s own docstring), never in this query. + */ + async resolveTokenToTrip(token: string): Promise { + const row = await this.kysely() + .selectFrom('trip_invite_tokens as ti') + .innerJoin('trips as t', 't.id', 'ti.trip_id') + .select(['t.id as trip_id', 't.title as title', 'ti.expires_at as expires_at']) + .where('ti.token', '=', token) + .executeTakeFirst(); + return row as TripInviteResolvedRow | undefined; + } +} diff --git a/server/src/db/repositories/TripMembers.repository.ts b/server/src/db/repositories/TripMembers.repository.ts new file mode 100644 index 000000000..6e94289b1 --- /dev/null +++ b/server/src/db/repositories/TripMembers.repository.ts @@ -0,0 +1,315 @@ +import type { TripMembers } from '../entities/TripMembers.entity'; +import { Trips } from '../entities/Trips.entity'; +import { caseWhenEquals, coalesce } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * One row of `TripMembersService.listMembers` (`trip-members.service.ts:117-125`, + * TM2). `is_guest` stays the raw SQLite integer here — the `!!m.is_guest` + * boolean coercion is a service-layer default (D4), same split every other + * repository in this program keeps. + */ +export interface MemberWithUserAndInviterRow { + id: number; + username: string; + email: string; + avatar: string | null; + is_guest: number; + role: string; + added_at: string | null; + invited_by_username: string | null; +} + +export class TripMembersRepository extends TrekRepository { + /** + * The user ids a trip may refer to: its members plus the owner. Byte-for-byte + * the legacy `DatabaseService.rosterUserIds`'s statement in intent — + * `SELECT user_id FROM trip_members WHERE trip_id = ? UNION SELECT user_id + * FROM trips WHERE id = ?` — as two reads merged through a `Set` (which + * already dedupes, the same guarantee `UNION` gives) rather than one `UNION` + * statement: the two `SELECT`s read different tables with no FK between + * them, so a single-entity `QueryBuilder` (rooted on `TripMembers`) cannot + * express the second half without losing rows on a trip that has no + * members yet — an inner join from `trip_members` would produce zero rows + * for exactly the case (an owner-only trip) the legacy statement's second + * `SELECT` exists to cover. `trips` has no `TripMembersRepository` field to + * reach it through, so this repository asks the `EntityManager` for + * `TripsRepository` the same way `DatabaseService`/the guards resolve a + * repository outside their own entity — one `EntityManager`, two + * repositories, two independent reads. + */ + async rosterUserIds(trip_id: number | string): Promise> { + // Raw conditions (D4's T5 escape hatch), not `.where({ 'm.trip': trip_id })`: + // MikroORM's typed filter rejects a `string` against the relation's + // branded id type, and coercing to `Number(trip_id)` first would change + // the raw-bind id-shape parity `TripsRepository`'s docstring describes. + // `m.trip_id`/`t.id` are the physical column names. + const members = await this.qb('m') + .select(['m.user']) + .where('m.trip_id = ?', [trip_id]) + .execute<{ user_id: number }[]>('all', false); + + const trips = this.getEntityManager().getRepository(Trips); + const owner = await trips + .qb('t') + .select(['t.user']) + .where('t.id = ?', [trip_id]) + .execute<{ user_id: number } | undefined>('get', false); + + const ids = new Set(members.map((m) => m.user_id)); + if (owner) ids.add(owner.user_id); + return ids; + } + + /** + * `SELECT user_id FROM trip_members WHERE trip_id = ? ORDER BY added_at ASC` + * (`trip-membership.service.ts:29-31`, TB2) — the owner is excluded by + * construction (the table has no row for the owner), matching the legacy + * statement exactly. Raw-bind (D4's T5 escape hatch), the same + * `number | string` seam `TripsRepository.findAccessible` documents. + */ + async listUserIdsByTrip(trip_id: number | string): Promise { + const rows = await this.qb('m') + .select(['m.user']) + .where('m.trip_id = ?', [trip_id]) + .orderBy({ 'm.added_at': 'asc' }) + .execute<{ user_id: number }[]>('all', false); + return rows.map((r) => r.user_id); + } + + /** + * `SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?` + * (`trip-membership.service.ts:70`, TB5 — `joinTripAsMember`'s + * already-a-member check, one leg of a non-transactional check-then-act + * sequence, §18.4: unchanged by this conversion; also `trip-members + * .service.ts:151`, TM5 — `addMember`'s own "already has access" check, + * the SAME non-transactional shape against TM6's insert). `TripMembershipService + * .joinTripAsMember`'s `tripId` is always a real `number`; `TripMembersService + * .addMember`'s is still the route's unconverted `tripId` + * (`string | number`) — raw-bind (D4's T5 escape hatch, the same seam + * `TripsRepository.findAccessible` documents) covers both without forking + * the statement into two methods. + */ + async exists(trip_id: number | string, user_id: number): Promise { + const row = await this.qb('m') + .select(['m.id']) + .where('m.trip_id = ?', [trip_id]) + .andWhere({ user: user_id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * `INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)` + * (`trip-membership.service.ts:72`, TB6 — the third leg of the same + * non-transactional sequence as `exists` above; also `trip-members + * .service.ts:155`, TM6 — `addMember`'s own insert, and `:227`, TM17 — + * `createGuest`'s membership row for the new guest, inside its own + * transaction). `invitedBy` may be `null`. `trip_id: number`, not + * `number | string`: unlike `exists`'s WHERE-comparison shape, `insert`'s + * typed `EntityData` requires the relation's real `Primary` type. + * This is still exact parity for `TripMembersService`'s two still-string + * `tripId` callers (TM6/TM17): by the time either call happens, the + * route's `tripId` has already matched a real trip through one of the + * four raw-bind access primitives earlier in the SAME request (the + * controller's inline `canAccessTrip` check for `addMember`; + * `TripOwnerGuard`'s `isOwner`/`findAccessible` for `createGuest`'s + * owner-only route) — which only succeeds for a string SQLite's own + * affinity rules already recognise as numeric, and `Number(...)` of + * exactly that class of string always agrees with SQLite's own + * numeric-affinity conversion. The two call sites document this and pass + * `Number(tripId)`, not a widened raw bind. + */ + async addMember(trip_id: number, user_id: number, invitedBy: number | null): Promise { + await this.insert({ trip: trip_id, user: user_id, invited_by: invitedBy }); + } + + /** + * `SELECT u.id, COALESCE(u.display_name, u.username) AS username, u.email, + * u.avatar, u.is_guest, CASE WHEN u.id = ? THEN 'owner' ELSE 'member' END + * as role, m.added_at, COALESCE(ib.display_name, ib.username) as + * invited_by_username FROM trip_members m JOIN users u ON u.id = m.user_id + * LEFT JOIN users ib ON ib.id = m.invited_by WHERE m.trip_id = ? + * ORDER BY m.added_at ASC` (`trip-members.service.ts:117-125`, TM2) — + * the self-join on `users` (once as `u` for the member, once as `ib` for + * the inviter) via `TripMembers.entity.ts`'s `invitedByRef` relation, + * which exists precisely for this join. Every joined-alias column is + * selected with an explicit inline `as ` (the `PlaceRatingsRepository + * .listForPlaces`/`AssignmentParticipantsRepository.insertIgnore` + * precedent — rule 19's trap: selecting a joined alias's bare `.id` + * without an explicit alias risks MikroORM's own `__` prefix + * for a relation-property select, verified NOT to reproduce the legacy's + * bare `id`/`email`/`avatar`/`is_guest`/`added_at` keys otherwise), never + * left to MikroORM's own column-collision aliasing. + */ + async listWithUserAndInviter(trip_id: number | string, owner_id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + return this.qb('m') + .join('m.user', 'u') + .leftJoin('m.invitedByRef', 'ib') + .select([ + 'u.id as id', + coalesce(platform, 'u.display_name', 'u.username').as('username'), + 'u.email as email', + 'u.avatar as avatar', + 'u.is_guest as is_guest', + caseWhenEquals(platform, 'u.id', owner_id, 'owner', 'member').as('role'), + 'm.added_at as added_at', + coalesce(platform, 'ib.display_name', 'ib.username').as('invited_by_username'), + ]) + .where('m.trip_id = ?', [trip_id]) + .orderBy({ 'm.added_at': 'asc' }) + .execute('all', false); + } + + /** + * `SELECT u.id FROM users u JOIN trip_members m ON m.user_id = u.id + * WHERE u.id = ? AND m.trip_id = ? AND u.is_guest = 1` + * (`trip-members.service.ts:235-237`, TM18) — security-sensitive: the + * scoping guard that keeps guest mutations trip-local (#1362). Rooted on + * `TripMembers` (this repository's entity) rather than `Users`: the + * legacy statement's `users u JOIN trip_members m` becomes `trip_members m + * JOIN users u` here, the same join and the same result set, only rooted + * the other way because that is this repository's entity. + * + * Raw-bind on `user_id` (not `.andWhere({ user: user_id })`) plus a + * `Number.isFinite` short-circuit guard — the same reason `remove()` below + * has both: `renameGuest`/`deleteGuest`'s callers still do a bare + * `Number.parseInt(userId)` on the route's `:userId`, unvalidated — a + * non-numeric id parses to `NaN`. **Mechanism (Task 6 review's I1, + * captured by wrapping `better-sqlite3.prepare`, not assumed): MikroORM + * formats every query with ALL parameters inlined as escaped literals — + * the params array that reaches better-sqlite3 is always empty — so a + * non-finite `NaN`/`Infinity` renders as a bare, unquoted SQL token and + * SQLite fails at PREPARE time (`no such column: NaN`, a 500), before any + * statement runs. A raw `?` placeholder does not avoid this (a typed + * filter has the identical failure) because the inlining happens during + * formatting, not at the bind step.** The legacy statement bound it as a + * genuine better-sqlite3 parameter, matching zero rows (404 `'Guest not + * found'`, not a crash) — only skipping the query for a non-finite + * `user_id` reproduces that byte for byte. + */ + async isGuestOfTrip(trip_id: number | string, user_id: number): Promise { + if (!Number.isFinite(user_id)) return false; + const row = await this.qb('m') + .join('m.user', 'u') + .select(['u.id as id']) + .where('m.trip_id = ?', [trip_id]) + .andWhere('m.user_id = ?', [user_id]) + .andWhere({ 'u.is_guest': 1 }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * `INSERT OR IGNORE INTO trip_members (trip_id, user_id, invited_by) + * VALUES (?, ?, ?)` (`trip-members.service.ts:200`, TM15) — + * security-sensitive: `transferOwnership`'s third statement, giving the + * former owner membership back. `upsert` with `onConflictAction: 'ignore'` + * on the table's real unique key, named by entity property (`trip`/`user`, + * `AssignmentParticipantsRepository.insertIgnore`'s precedent for a + * relation-backed `onConflictFields` list) — `UNIQUE(trip_id, user_id)` + * per `TripMembersSchema`'s own `uniques` declaration, not assumed. + * `trip_id: number`, the same reasoning as `addMember`'s docstring above + * (an `upsert` is INSERT-shaped; the caller passes `Number(tripId)`, + * justified there — `transferOwnership`'s route is `TripOwnerGuard`-gated, + * so `tripId` has already matched a real trip through the raw-bind + * `isOwner`/`findAccessible` primitive earlier in the same request). + */ + async addIgnoringConflict(trip_id: number, user_id: number, invitedBy: number | null): Promise { + // `invitedByRef`, not `invited_by`: the latter is a `persist(false)` mirror + // column (like `trip_id`/`user_id`) that `upsert` — unlike `insert` above, + // per `addMember`'s test — silently drops when building its INSERT. + // `invitedByRef` is the real, joined-column-backed relation property. + await this.upsert( + { trip: trip_id, user: user_id, invitedByRef: invitedBy }, + { onConflictFields: ['trip', 'user'], onConflictAction: 'ignore' }, + ); + } + + /** + * `DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?` + * (`trip-members.service.ts:166`, TM8 `removeMember`; `:198`, TM14 + * `transferOwnership`'s "no longer a plain member" step — + * security-sensitive). `trip_id: number` (Plan 4 Task 8a, narrowed from + * `number | string` — its last raw-string caller, + * `TripMembersController.removeMember`, now parses its route param once + * itself before calling this), so `trip_id` is a typed `.where({ trip: + * trip_id })` filter below; `user_id` stays a raw `m.user_id = ?` + * placeholder — NOT the same seam, a different reason entirely: + * `TripMembersController.removeMember` still does a bare + * `Number.parseInt(userId)` on the route's `:userId`, unvalidated (rule + * 15's exact trap — a non-numeric id parses to `NaN`), and a typed filter + * cannot carry a `NaN` through this ORM layer intact (see below). + * + * **`Number.isFinite` guard, checked directly against a compiled boot, not + * assumed:** a typed `.andWhere({ user: user_id })` filter renders `NaN` as + * a literal, unquoted SQL token (`WHERE … AND user_id = NaN`), which + * SQLite's parser reads as a column reference and rejects at PREPARE time + * (`no such column: NaN`, a 500). Switching to a raw `m.user_id = ?` + * placeholder does NOT fix this (Task 6 review's I1, captured by wrapping + * `better-sqlite3.prepare`): MikroORM formats every query with ALL + * parameters inlined as escaped literals — the params array that reaches + * better-sqlite3 is always empty — so `NaN` renders as that same bare + * token regardless of whether the predicate was written as a typed filter + * or a raw `?` placeholder (reproduced directly: still `no such column: + * NaN` at prepare time, before any bind happens — the inlining happens + * during formatting, not at the bind step). The legacy statement bound + * `targetUserId` (`NaN`) straight into a real better-sqlite3 parameter, + * which silently matches zero rows and returns 200 `{ success: true }` — + * only skipping the query entirely for a non-finite `user_id` reproduces + * that, byte for byte, since neither the typed filter nor the raw + * placeholder route can pass `NaN` through the ORM layer intact. + */ + async remove(trip_id: number, user_id: number): Promise { + if (!Number.isFinite(user_id)) return; + await this.qb('m') + .delete() + .where({ trip: trip_id }) + .andWhere('m.user_id = ?', [user_id]) + .execute('run'); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 7 (`trips.rpc.ts::members`) — additive: no other repository + // is rooted at `trip_members` joined to `users`, and RP3's shape is + // deliberately NOT `listWithUserAndInviter`'s (TM2's COALESCE'd + // `username`), so it gets its own method here rather than a variant + // parameter on TM2's. + // --------------------------------------------------------------------------- + + /** + * RP3 (`trips.rpc.ts::members`) — `SELECT u.id, u.username, u.display_name, + * u.avatar FROM trip_members tm JOIN users u ON u.id = tm.user_id WHERE + * tm.trip_id = ?`. Emits raw `username` AND `display_name` as SEPARATE + * fields — deliberately NOT `listWithUserAndInviter`'s (TM2) COALESCE'd + * single `username` (the ruling: "do not harmonise" — a plugin reading + * `trips.members` and the REST/MCP roster are two different wire shapes on + * purpose, inventory §18.10). + */ + async listRawUsernameAndDisplayName(trip_id: number | string): Promise<{ id: number; username: string; display_name: string | null; avatar: string | null }[]> { + return await this.qb('m') + .join('m.user', 'u') + .select(['u.id', 'u.username', 'u.display_name', 'u.avatar']) + .where('m.trip_id = ?', [trip_id]) + .execute<{ id: number; username: string; display_name: string | null; avatar: string | null }[]>('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (UC4, `user-cleanup.service.ts::cleanupUserReferences`) — + // additive: the table's own Plan 3c ownership, mis-filed by an earlier + // ledger under Plan 3g/3j and left unconverted. + // --------------------------------------------------------------------------- + + /** + * UC4 — `UPDATE trip_members SET invited_by = NULL WHERE invited_by = ?` + * (GDPR erasure: severs every membership row's reference to a user being + * deleted, without deleting the membership itself). Filtered and set + * through `invitedByRef`, the real joined-column relation property — + * `invited_by` itself is the `persist(false)` mirror `addIgnoringConflict`'s + * docstring above already names. + */ + async clearInvitedBy(userId: number): Promise { + await this.nativeUpdate({ invitedByRef: userId }, { invitedByRef: null }); + } +} diff --git a/server/src/db/repositories/TripPhotos.repository.ts b/server/src/db/repositories/TripPhotos.repository.ts new file mode 100644 index 000000000..1392275b7 --- /dev/null +++ b/server/src/db/repositories/TripPhotos.repository.ts @@ -0,0 +1,212 @@ +import type { TripPhotos } from '../entities/TripPhotos.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * `trip_photos`'s single-table write/lookup shape (Plan 3e Task 7, + * UM4/UM5/UM6/UM8/UM9). `trip_id`/`album_link_id` are typed `number | + * string` — the legacy statements bound `tripId`/`albumLinkId` raw, straight + * from the route param, with no `Number()`/`toRowId` conversion (the same + * T5 raw-bind seam `TripsRepository.findForViewer`'s docstring documents), + * so this Kysely shape has to accept both to bind the identical value. + */ +interface TripPhotosKyselyDB { + trip_photos: { + id: number; + trip_id: number | string; + user_id: number; + photo_id: number; + shared: number; + album_link_id: number | string | null; + added_at: string | null; + }; +} + +/** + * UM4's insert-only shape — `id`/`added_at` are autoincrement/`DEFAULT + * CURRENT_TIMESTAMP` and omitted from `.values()` (the legacy statement's + * own column list omits them too), so Kysely's `InsertObject` needs a + * SEPARATE interface that doesn't declare them (the `FileLinksWriteKyselyDB` + * precedent) — {@link TripPhotosKyselyDB} declares every column, so passing + * it to `.insertInto()` would require `id`/`added_at` in every `.values()`. + */ +interface TripPhotosInsertKyselyDB { + trip_photos: { + trip_id: number | string; + user_id: number; + photo_id: number; + shared: number; + album_link_id: number | string | null; + }; +} + +/** + * UM2's joined projection (`UnifiedMemoriesService.listTripPhotos`) — three + * tables, so its own Kysely shape rather than folding onto + * {@link TripPhotosKyselyDB} (one purpose-shaped interface per statement, + * the `FileLinksRepository` precedent). + */ +interface TripPhotosListKyselyDB { + trip_photos: { trip_id: number | string; user_id: number; photo_id: number; shared: number; added_at: string | null }; + trek_photos: { id: number; asset_id: string | null; provider: string }; + users: { id: number; username: string | null; avatar: string | null }; +} + +/** UM2's row shape — `tp.photo_id, tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at, u.username, u.avatar`. */ +export interface TripPhotoListRow { + photo_id: number; + asset_id: string | null; + provider: string; + user_id: number; + shared: number; + added_at: string | null; + username: string | null; + avatar: string | null; +} + +export class TripPhotosRepository extends TrekRepository { + /** + * PH10's non-journey half — `SELECT 1 FROM trip_photos WHERE photo_id = ? + * LIMIT 1`, used by `TrekPhotoRegistrationService.deleteIfOrphan` alongside + * the `journey_photos` half of the same orphan check, converted onto + * {@link JourneyPhotosRepository.existsForPhoto} (Plan 3g Task 4). + */ + async existsForPhoto(photo_id: number): Promise { + const row = await this.findOne({ photo: photo_id }, { fields: ['id'] }); + return !!row; + } + + /** + * MA3 — the regular-trip branch of `MemoriesAccessService.canAccessUserPhoto`: + * is this provider asset shared by this exact user into this exact trip? + * `SELECT 1 FROM trip_photos tp JOIN trek_photos tkp ON tkp.id = tp.photo_id + * WHERE tp.user_id = ? AND tkp.asset_id = ? AND tkp.provider = ? AND + * tp.trip_id = ? AND tp.shared = 1 LIMIT 1`. + */ + async existsSharedForUser(trip_id: number, user_id: number, asset_id: string, provider: string): Promise { + const row = await this.qb('tp') + .select(['tp.id']) + .join('tp.photo', 'tkp') + .where({ 'tp.user': user_id, 'tp.trip': trip_id, 'tp.shared': 1, 'tkp.asset_id': asset_id, 'tkp.provider': provider }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * MA5's source data — every trip a photo is shared into (`shared = 1`), + * for `MemoriesAccessService.canAccessTrekPhoto` to check each one against + * `TripsRepository.findAccessible` (R4: reuse the existing accessibility + * builder rather than hand-translating the legacy `EXISTS(...UNION + * ALL...)` predicate into a second, independent form). Legacy statement: + * `SELECT 1 FROM trip_photos tp WHERE tp.photo_id = ? AND tp.shared = 1 + * AND EXISTS(...trip_members/trips...) LIMIT 1` — the `EXISTS` half is + * exactly `findAccessible`'s own predicate, so this method hands back the + * candidate trip ids and the caller does the short-circuiting `OR` over + * `findAccessible`'s results instead. + * + * Full-row `find()`, not a `fields: ['trip_id']` projection: `trip_id` is a + * `persist(false)` scalar twin of the `trip` relation, and a narrowed + * `fields` selection silently drops it from the hydrated result (the same + * bare-`persist(false)`-select trap `RoadtripDayBoundariesRepository` + * documents — verified empirically here too, not assumed) — only an + * unrestricted `find()` (or `columnRef` through a `qb()`) hydrates it. + */ + async listTripIdsSharedForPhoto(photo_id: number): Promise { + const rows = await this.find({ photo: photo_id, shared: 1 }); + return rows.map((row) => row.trip_id); + } + + /** + * UM4 (`UnifiedMemoriesService._addTripPhoto`) — `INSERT OR IGNORE INTO + * trip_photos (trip_id, user_id, photo_id, shared, album_link_id) VALUES + * (?, ?, ?, ?, ?)`. `(trip, user, photo)` is the entity's real `uniques` + * constraint (this file's `TripPhotosSchema`), so `ON CONFLICT + * (trip_id, user_id, photo_id) DO NOTHING` is the exact SQLite equivalent + * of the legacy `OR IGNORE`. Returns whether a row was actually inserted + * (`result.changes > 0` in the legacy code) — `numInsertedOrUpdatedRows` + * is Kysely's dialect-general affected-row count, 0 when the conflict + * target already existed and the insert was ignored. + */ + async insertIgnore(row: { trip_id: number | string; user_id: number; photo_id: number; shared: number; album_link_id: number | string | null }): Promise { + const result = await this.kysely() + .insertInto('trip_photos') + .values({ trip_id: row.trip_id, user_id: row.user_id, photo_id: row.photo_id, shared: row.shared, album_link_id: row.album_link_id }) + .onConflict((oc) => oc.columns(['trip_id', 'user_id', 'photo_id']).doNothing()) + .executeTakeFirst(); + return (result?.numInsertedOrUpdatedRows ?? 0n) > 0n; + } + + /** UM5 (`UnifiedMemoriesService.setTripPhotoSharing`) — `UPDATE trip_photos SET shared = ? WHERE trip_id = ? AND user_id = ? AND photo_id = ?`. */ + async setShared(trip_id: number | string, user_id: number, photo_id: number, shared: number): Promise { + await this.kysely() + .updateTable('trip_photos') + .set({ shared }) + .where('trip_id', '=', trip_id) + .where('user_id', '=', user_id) + .where('photo_id', '=', photo_id) + .execute(); + } + + /** UM6 (`UnifiedMemoriesService.removeTripPhoto`) — `DELETE FROM trip_photos WHERE trip_id = ? AND user_id = ? AND photo_id = ?`. */ + async deleteForUserPhoto(trip_id: number | string, user_id: number, photo_id: number): Promise { + await this.kysely() + .deleteFrom('trip_photos') + .where('trip_id', '=', trip_id) + .where('user_id', '=', user_id) + .where('photo_id', '=', photo_id) + .execute(); + } + + /** UM8 (`UnifiedMemoriesService.removeAlbumLink`'s pre-tx orphan-candidate list) — `SELECT photo_id FROM trip_photos WHERE trip_id = ? AND album_link_id = ?`. */ + async listPhotoIdsForAlbumLink(trip_id: number | string, album_link_id: number | string): Promise { + const rows = await this.kysely() + .selectFrom('trip_photos') + .select(['photo_id']) + .where('trip_id', '=', trip_id) + .where('album_link_id', '=', album_link_id) + .execute(); + return rows.map((r) => r.photo_id); + } + + /** UM9 (`UnifiedMemoriesService.removeAlbumLink`, inside its transaction) — `DELETE FROM trip_photos WHERE trip_id = ? AND album_link_id = ?`. */ + async deleteForAlbumLink(trip_id: number | string, album_link_id: number | string): Promise { + await this.kysely() + .deleteFrom('trip_photos') + .where('trip_id', '=', trip_id) + .where('album_link_id', '=', album_link_id) + .execute(); + } + + /** + * UM2 (`UnifiedMemoriesService.listTripPhotos`) — `SELECT tp.photo_id, + * tkp.asset_id, tkp.provider, tp.user_id, tp.shared, tp.added_at, + * u.username, u.avatar FROM trip_photos tp JOIN trek_photos tkp JOIN + * users u WHERE tp.trip_id = ? AND (tp.user_id = ? OR tp.shared = 1) AND + * tkp.provider IN (dynamic) ORDER BY tp.added_at ASC`. The caller already + * guards the empty-`enabled_providers` case (`ServiceResult` 400 before + * this is ever reached); the early return here is defensive, matching an + * `IN ()` that can never match any row. + */ + async listForTrip(trip_id: number | string, user_id: number, enabled_providers: string[]): Promise { + if (enabled_providers.length === 0) return []; + const rows = await this.kysely() + .selectFrom('trip_photos as tp') + .innerJoin('trek_photos as tkp', 'tkp.id', 'tp.photo_id') + .innerJoin('users as u', 'u.id', 'tp.user_id') + .select([ + 'tp.photo_id as photo_id', + 'tkp.asset_id as asset_id', + 'tkp.provider as provider', + 'tp.user_id as user_id', + 'tp.shared as shared', + 'tp.added_at as added_at', + 'u.username as username', + 'u.avatar as avatar', + ]) + .where('tp.trip_id', '=', trip_id) + .where((eb) => eb.or([eb('tp.user_id', '=', user_id), eb('tp.shared', '=', 1)])) + .where('tkp.provider', 'in', enabled_providers) + .orderBy('tp.added_at', 'asc') + .execute(); + return rows; + } +} diff --git a/server/src/db/repositories/Trips.repository.ts b/server/src/db/repositories/Trips.repository.ts new file mode 100644 index 000000000..a40ea9aa2 --- /dev/null +++ b/server/src/db/repositories/Trips.repository.ts @@ -0,0 +1,1115 @@ +import { QueryFlag } from '@mikro-orm/core'; +import type { Trips } from '../entities/Trips.entity'; +import { coalesceParam, currentTimestamp, nowDateOffset } from '../dialect/sql-functions'; +import type { AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; +import { tripAccessExpr } from './_shared/trip-access'; + +/** What a trip-scoped request learns about the trip once access is verified. */ +export interface TripAccess { + id: number; + user_id: number; + currency: string | null; +} + +/** + * `SELECT * FROM trips WHERE id = ?`, every scalar column — the row + * `TripReadModelService.getTripSummary` (TR-B) and, once Task 7 lands, + * `TripsService.getRaw` (TP22) both need, `feed_token` included: the + * `withoutFeedToken()` JS strip that guards the credential stays in the + * SERVICE (TR-B's ruling), so this repository method must hand the column + * back intact for that strip to have something to delete. + */ +export interface TripRawRow { + id: number; + user_id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string | null; + cover_image: string | null; + is_archived: number | null; + reminder_days: number | null; + feed_token: string | null; + created_at: string | null; + updated_at: string | null; +} + +const _tripRawRowKeys: AssertRowKeys = true; + +/** + * `TRIP_SELECT`'s output row (inventory §11a) — every `TripRawRow` column + * plus the four computed/joined columns the projection adds. `feed_token` + * is always `null` here (SQL-side blanking via `NULL AS feed_token`/its + * Kysely equivalent, never a JS strip) — see `TripsRepository + * .tripSelectQuery`'s docstring. + */ +export interface TripSelectRow extends Omit { + feed_token: null; + day_count: number; + place_count: number; + is_owner: number; + owner_username: string; + shared_count: number; +} + +/** The narrow `trips`/`users`/`days`/`places`/`trip_members` shape `tripSelectQuery` needs (`this.kysely()`'s typed `DB` argument). */ +interface TripSelectKyselyDB { + trips: { + // `number | string`, not a bare `number`: the raw-bind seam (D4's T5 + // escape hatch) — `findForViewer`'s `WHERE t.id = ?` accepts the route's + // unconverted id with no coercion, the same seam every QB-based method + // in this file documents. Widening the TYPE (not spelling `sql\`...\``, + // which `no-restricted-syntax` bans in `src/db/repositories/**`) is what + // lets `.where('t.id', '=', trip_id)` accept a `string` here. + id: number | string; + user_id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string | null; + cover_image: string | null; + is_archived: number | null; + reminder_days: number | null; + feed_token: string | null; + created_at: string | null; + updated_at: string | null; + }; + users: { id: number; username: string }; + trip_members: { id: number; trip_id: number; user_id: number }; + days: { id: number; trip_id: number }; + places: { id: number; trip_id: number }; +} + +// Task 7 review L4, absorbed here (Task 8 touches the same file): the 13 +// hand-listed columns of `TripSelectKyselyDB['trips']` above are a second, +// independently-typed copy of `Trips`'s own scalar columns (Kysely's typed +// `DB` argument can't be derived from the entity metadata the way `qb()`'s +// generics are) — this fails `tsc` the moment a column is added to or +// removed from `Trips` without updating this interface to match, instead of +// silently drifting (the same drift guard `_tripRawRowKeys`/`_dayRowKeys`/… +// already give every hand-written row interface in this program). +const _tripSelectKyselyDbKeys: AssertRowKeys = true; + +/** TP21's output row (inventory §11c) — `relevance` is the computed sort key, never sent to the client (the controller destructures `{id, title, start_date, end_date}` only). */ +export interface ActiveTripRow { + id: number; + title: string; + start_date: string | null; + end_date: string | null; + relevance: number; +} + +/** The narrow `trips`/`trip_members` shape `activeTrip` needs. */ +interface ActiveTripKyselyDB { + trips: { id: number; title: string; start_date: string | null; end_date: string | null; user_id: number; is_archived: number | null }; + trip_members: { id: number; trip_id: number; user_id: number }; +} + +export class TripsRepository extends TrekRepository { + /** + * The `LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + * WHERE (t.user_id = ? OR m.user_id IS NOT NULL)` half `findAccessible` + * and `listAccessibleIds` (Plan 3c Task 1, TB3) share — one join builder, + * per the task brief's ruling that the two must derive from the same + * source rather than hand-keeping two copies in sync (the cross-plan note + * in the inventory: TB3 and `TripsService.list`'s id half are "the same + * query with different projections"). No explicit return-type annotation, + * for the same inference reason `TrekRepository.kysely()` gives (the + * QueryBuilder's fluent generic parameters do not survive being spelled + * out via a separate type alias) — every caller chains `.select()`/ + * `.where()`/`.orderBy()` straight off this method's return value. + */ + private accessibleTripsQuery(user_id: number) { + return this.qb('t') + .leftJoin('t.trip_members_collection', 'm', { 'm.user': user_id }) + .andWhere({ $or: [{ 't.user': user_id }, { 'm.user': { $ne: null } }] }); + } + + /** + * The trip if the user owns it or is a member of it, else undefined. + * + * Byte-for-byte the legacy `canAccessTrip` statement: + * SELECT t.id, t.user_id, t.currency FROM trips t + * LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? + * WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL) + * + * `trip_id: number | string` (Plan 3c Task 0b): the legacy free function + * bound whatever `req.params.tripId` resolved to straight into the + * statement with no `Number()`/`toRowId` conversion first — the raw-bind + * seam. `TripAccessGuard` still does `Number(tripId)` before calling this + * (unchanged), but `DatabaseService.canAccessTrip`/`isOwner` and every + * other of the 41 in-cluster callers pass a `number | string` through + * unconverted, so this method (and `isOwner` below) must accept and bind + * that raw value rather than coerce it — a coercion here would silently + * change the `'0x10'`/`'007'`-shaped id parity the boot matrix pins + * (`Number('0x10') === 16`, a real trip id, where SQLite's own text/integer + * affinity comparison of the unconverted string never matches one). + */ + async findAccessible(trip_id: number | string, user_id: number): Promise { + const row = await this.accessibleTripsQuery(user_id) + .select(['t.id', 't.user', 't.currency']) + // Raw condition (D4's T5 escape hatch), not `.where({ 't.id': trip_id })`: + // MikroORM's typed filter rejects a `string` against `t.id`'s branded + // `number` type, and coercing to `Number(trip_id)` first would be + // exactly the parity break the class docstring above describes. + // `t.id`/`t.user_id` are the physical column names (not translated + // through the entity's `trip`/`user` property aliasing the way a typed + // filter object is), so the raw text names them directly. + .andWhere('t.id = ?', [trip_id]) + // `mapResults: false` leaves the driver's row alone: the keys are the + // column names, which is why the result type is spelled out here rather + // than inferred from the entity's properties. + .execute<{ id: number; user_id: number; currency: string | null } | undefined>('get', false); + if (!row) return undefined; + return { id: row.id, user_id: row.user_id, currency: row.currency ?? null }; + } + + /** + * DY35 (`days.service.ts::insert`'s dated path) — `UPDATE trips SET + * end_date = ? WHERE id = ?`: a dated insert extends the trip by one day. + * Plan 3c Task 7 (`TripsService`) reuses this for its own `end_date` + * writes rather than duplicating the statement. + */ + async setEndDate(id: number, end_date: string | null): Promise { + await this.nativeUpdate({ id }, { end_date }); + } + + /** + * DY38/DY42 (`DaysService.appendDated`, `DayRemovalService.renumber`) — + * `UPDATE trips SET end_date = ?, updated_at = CURRENT_TIMESTAMP WHERE id + * = ?`: a day added after the last date, or a deleted day that took the last + * date along, moves the trip's end and stamps it. + */ + async setEndDateTouched(id: number, end_date: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { end_date, updated_at: currentTimestamp(platform) }); + } + + /** + * TP79 (`trips.service.ts::searchPlaces`, #2190) — `SELECT p.trip_id, + * p.name FROM places p JOIN trips t ON t.id = p.trip_id LEFT JOIN + * trip_members m ON m.trip_id = t.id AND m.user_id = :userId WHERE + * (t.user_id = :userId OR m.user_id IS NOT NULL) AND (p.name LIKE :like + * ESCAPE '\' OR p.address LIKE :like ESCAPE '\') ORDER BY p.trip_id, p.name + * LIMIT 500`. `likePattern` arrives pre-escaped (`%…%`, the service's job), + * as in PlacesRepository.listForTrip. + */ + async searchPlaceNames(user_id: number, likePattern: string): Promise<{ trip_id: number; name: string }[]> { + return await this.accessibleTripsQuery(user_id) + .join('t.places_collection', 'p') + .select(['p.trip', 'p.name']) + .andWhere("(p.name LIKE ? ESCAPE '\\' OR p.address LIKE ? ESCAPE '\\')", [likePattern, likePattern]) + .orderBy([{ 'p.trip': 'asc' }, { 'p.name': 'asc' }]) + .limit(500) + // The limit is on the joined place rows, like the legacy statement's. + // Without the flag MikroORM moves a limit over a to-many join into a + // subquery on trips and drops the LIKE filter from the outer select. + .setFlag(QueryFlag.DISABLE_PAGINATE) + .execute<{ trip_id: number; name: string }[]>('all', false); + } + + /** `SELECT id FROM trips WHERE id = ? AND user_id = ?` */ + async isOwner(trip_id: number | string, user_id: number): Promise { + const row = await this.qb('t') + .select(['t.id']) + .where('t.id = ?', [trip_id]) + .andWhere({ user: user_id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * `SELECT user_id FROM trips WHERE id = ?` (`trip-membership.service.ts:23`, + * TB1) — `row ? row.user_id : null` stays the caller's decision to make + * ("owner id for budget/MCP guards"); this returns exactly that shape. + * Raw-bind (D4's T5 escape hatch), same `number | string` seam as + * `findAccessible`/`isOwner` above. + */ + async getOwnerId(trip_id: number | string): Promise { + const row = await this.qb('t') + .select(['t.user']) + .where('t.id = ?', [trip_id]) + .execute<{ user_id: number } | undefined>('get', false); + return row ? row.user_id : null; + } + + /** + * `SELECT id, user_id FROM trips WHERE id = ?` (`trip-membership.service.ts:66`, + * TB4) — `joinTripAsMember`'s first check-then-act read (§18.4: the + * sequence stays non-transactional, unchanged by this conversion). The + * only caller (`TripMembershipService.joinTripAsMember(tripId: number, …)`) + * always passes a real `number`, so this takes one too — no raw-bind seam + * to preserve here, unlike `findAccessible`/`isOwner`/`getOwnerId`. + */ + async findIdAndOwner(trip_id: number): Promise<{ id: number; user_id: number } | undefined> { + return this.qb('t') + .select(['t.id', 't.user']) + .where({ id: trip_id }) + .execute<{ id: number; user_id: number } | undefined>('get', false); + } + + /** + * MR8 (Plan 3j Task 5, `host/rpc/meta.rpc.ts#entityTrip`'s `'trip'` arm) — + * `SELECT id FROM trips WHERE id = ?`, a bare existence probe (the entity + * type IS the trip, so the id resolves to itself once the row is + * confirmed to exist). + */ + async existsById(id: number): Promise { + const row = await this.qb('t') + .select(['t.id']) + .where({ id }) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * HR9 (Plan 3j Task 5, `host/rpc/host-surface.rpc.ts#sharesATrip`, private) + * — byte-for-byte the legacy self-joined statement: + * SELECT 1 FROM trips t + * LEFT JOIN trip_members m1 ON m1.trip_id = t.id AND m1.user_id = ? + * LEFT JOIN trip_members m2 ON m2.trip_id = t.id AND m2.user_id = ? + * WHERE (t.user_id = ? OR m1.user_id IS NOT NULL) + * AND (t.user_id = ? OR m2.user_id IS NOT NULL) + * LIMIT 1 + * — "does ANY trip exist where both users are owner-or-member" (the + * `users.getById` plugin RPC's own access gate — a bespoke bilateral + * membership check, NOT `canAccessTrip`-shaped, which is why it lives here + * rather than reusing `accessibleTripsQuery` — that builder joins ONE + * user's membership; this needs two independent joins, one per user, so + * neither side's `$or` can be satisfied by the other's row. + */ + async sharesTripWith(userIdA: number, userIdB: number): Promise { + const row = await this.qb('t') + .leftJoin('t.trip_members_collection', 'm1', { 'm1.user': userIdA }) + .leftJoin('t.trip_members_collection', 'm2', { 'm2.user': userIdB }) + .select(['t.id']) + .andWhere({ + $and: [ + { $or: [{ 't.user': userIdA }, { 'm1.user': { $ne: null } }] }, + { $or: [{ 't.user': userIdB }, { 'm2.user': { $ne: null } }] }, + ], + }) + .limit(1) + .execute<{ id: number } | undefined>('get', false); + return !!row; + } + + /** + * `SELECT t.id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id + * AND m.user_id = :userId WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) + * ORDER BY t.created_at DESC` (`trip-membership.service.ts:40-46`, TB3) — + * the id half of `TripsService.list(userId, null)`, sharing `findAccessible`'s + * join builder per the task brief's ruling (this class's `accessibleTripsQuery` + * above). + */ + async listAccessibleIds(user_id: number): Promise { + const rows = await this.accessibleTripsQuery(user_id) + .select(['t.id']) + .orderBy({ 't.created_at': 'desc' }) + .execute<{ id: number }[]>('all', false); + return rows.map((r) => r.id); + } + + /** + * `SELECT title FROM trips WHERE id = ?` (`trip-members.service.ts:156`, + * TM7) — `addMember`'s notification-body read; the caller applies its own + * `?? 'Untitled'` fallback (D4). Raw-bind (D4's T5 escape hatch), the same + * `number | string` seam `findAccessible` documents. + */ + async getTitle(trip_id: number | string): Promise { + const row = await this.qb('t') + .select(['t.title']) + .where('t.id = ?', [trip_id]) + .execute<{ title: string } | undefined>('get', false); + return row?.title ?? null; + } + + /** + * `SELECT id, title, user_id FROM trips WHERE id = ?` + * (`trip-members.service.ts:180`, TM9) — `transferOwnership`'s first + * check-then-act read (miss → 404 `'Trip not found'`; wrong owner → 400 + * `'Only the owner can transfer ownership'`, both decided by the caller). + * Raw-bind, the same seam as `getTitle` above. + */ + async findIdTitleOwner(trip_id: number | string): Promise<{ id: number; title: string; user_id: number } | undefined> { + return this.qb('t') + .select(['t.id', 't.title', 't.user']) + .where('t.id = ?', [trip_id]) + .execute<{ id: number; title: string; user_id: number } | undefined>('get', false); + } + + /** + * `UPDATE trips SET user_id = ? WHERE id = ?` (`trip-members.service.ts:196`, + * TM13) — security-sensitive: the ownership handover itself, the first of + * `transferOwnership`'s three transactional statements. `trip_id: number` + * (Plan 4 Task 8a, narrowed from `number | string`): its one production + * caller already passes `trip.id`, the real row `findIdTitleOwner` + * resolved earlier in the same transaction, never the route's raw string + * (see `transferOwnership`'s own comment on why). `nativeUpdate`, typed + * filter throughout — nothing here needs the raw-bind escape hatch any + * more. + */ + async setOwner(trip_id: number, user_id: number): Promise { + await this.nativeUpdate({ id: trip_id }, { user: user_id }); + } + + /** + * `SELECT * FROM trips WHERE id = ?` — `TripReadModelService.getTripSummary`'s + * trip read (`trip-read-model.service.ts:52`, TR-B) and, once Task 7 lands, + * `TripsService.getRaw` (TP22, `trips.service.ts:389`) — one method, two + * sites, per the inventory's own note that they are the identical + * statement. `feed_token` comes back intact; `withoutFeedToken()` stays a + * JS strip in each CALLER (TR-B's ruling — this repository never blanks + * it itself, unlike `TRIP_SELECT`'s `NULL AS feed_token` SQL-side trick). + * `.select(['t.*'])` — MikroORM's QueryBuilder recognises the bare + * `.*` marker (verified against `QueryBuilder.js`'s own + * `prepareFields`, not assumed) and emits every column unprefixed, the + * same `SELECT t.*` shape the legacy statement used. Raw-bind, the same + * `number | string` seam `getTitle`/`findIdTitleOwner` above preserve. + */ + async findRaw(trip_id: number | string): Promise { + const row = await this.qb('t') + .select(['t.*']) + .where('t.id = ?', [trip_id]) + .execute('get', false); + return row ?? null; + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 7 (`TripsService`) — `TRIP_SELECT` (inventory §11a) becomes + // ONE builder here (`tripSelectQuery`), expressed through `this.kysely()` + // (a typed one-table-plus-joins DB interface, the same escape hatch + // `DayAssignmentsRepository.effectiveStart`/`DayAssignmentsRepository + // .listForTimeSort` use): three correlated scalar `COUNT(*)` subqueries a + // QueryBuilder select list cannot express, a `CASE WHEN` projected column, + // and `NULL AS feed_token` placed AFTER `t.*` (via `.selectAll('t')` then + // a later `.select()` of the literal) so the duplicate key wins in the row + // object exactly the way the legacy statement's column order does — not by + // hand-listing every `trips` column (§18.5: that loses the "blanked once, + // survives a schema change" guarantee the legacy comment describes). + // Verified directly with `.compile()` against a real in-memory DB before + // wiring in (task report, TRIP_SELECT SQL captures) — a fully seeded trip + // (owner, member, stranger; day/place counts; `feed_token` set in the DB) + // produces `feed_token: null`, `is_owner`, `owner_username`, all four + // counts, byte-identical in shape to a raw run of the legacy statement. + // --------------------------------------------------------------------------- + + /** + * `this.kysely()`'s typed `DB` argument for every `TRIP_SELECT`-shaped + * query below — narrowed to the columns those queries read or join + * through, per `WebauthnChallengesRepository.claimChallenge`'s docstring + * (entity-metadata inference is not what a hand-written statement wants). + */ + private tripSelectQuery(user_id: number) { + return this.kysely() + .selectFrom('trips as t') + .innerJoin('users as u', 'u.id', 't.user_id') + .selectAll('t') + .select((eb) => [ + eb.val(null).as('feed_token'), + eb.selectFrom('days as d').select((eb2) => eb2.fn.countAll().as('c')).whereRef('d.trip_id', '=', 't.id').as('day_count'), + eb.selectFrom('places as p').select((eb2) => eb2.fn.countAll().as('c')).whereRef('p.trip_id', '=', 't.id').as('place_count'), + eb.case().when('t.user_id', '=', user_id).then(1).else(0).end().as('is_owner'), + 'u.username as owner_username', + eb.selectFrom('trip_members as tm').select((eb2) => eb2.fn.countAll().as('c')).whereRef('tm.trip_id', '=', 't.id').as('shared_count'), + ]); + } + + /** + * DY37 (`DaysService.getTripForViewer`) — `TRIP_SELECT + * WHERE t.id = :tripId`, with no access join: a day write that changed the + * trip (its end date, its day count) re-reads it in list shape for its own + * caller, who already holds access, and the rows of every viewer are the + * same apart from `is_owner`. + */ + async findListShapeById(trip_id: number | string, user_id: number): Promise { + const row = await this.tripSelectQuery(user_id).where('t.id', '=', trip_id).executeTakeFirst(); + return row as TripSelectRow | undefined; + } + + /** + * TP20 (`trips.service.ts::get`) — `TRIP_SELECT` scoped to one trip AND + * the access predicate (`t.user_id = :userId OR m.user_id IS NOT NULL`), + * i.e. "the trip if this viewer may see it, else nothing". Also serves + * TP19 (`create`'s post-insert re-select) and TP29 (`updateTrip`'s + * post-write re-select): both call sites are only ever reached after the + * acting user has already passed an access check for this exact trip + * (creator = owner; `updateTrip`'s callers all gate on `canAccessTrip`/ + * `requireTripEdit` first), so the access predicate here is always + * trivially satisfied in those two contexts and the result is identical + * to the legacy's unscoped `WHERE t.id = :tripId` re-select. Also serves + * `TripMembersService.getTripForViewer` (TM1, Task 6 review's "for Task + * 7" note) — same reasoning: every caller already holds access. + * + * Raw-bind (`number | string`, D4's T5 escape hatch — `TripSelectKyselyDB`'s + * `trips.id` is typed `number | string` for exactly this): the legacy + * statement bound the route's unconverted `tripId` with no `Number()`/ + * `toRowId` conversion, matching `findAccessible`'s documented seam. + */ + async findForViewer(trip_id: number | string, user_id: number): Promise { + const row = await this.tripSelectQuery(user_id) + .leftJoin('trip_members as m', (join) => join.onRef('m.trip_id', '=', 't.id').on('m.user_id', '=', user_id)) + .where('t.id', '=', trip_id) + .where((eb) => tripAccessExpr(eb, 't.user_id', 'm.user_id', user_id)) + .executeTakeFirst(); + return row as TripSelectRow | undefined; + } + + /** + * TP16/TP17 (`trips.service.ts::list`) — `TRIP_SELECT` + the access join, + * optionally filtered by `is_archived`, `ORDER BY t.created_at DESC`. + * `archived === null` reproduces TP16 (no filter); any other value (0 or + * 1, the controller's own coercion) reproduces TP17. + */ + async listForUser(user_id: number, archived: number | null): Promise { + let query = this.tripSelectQuery(user_id) + .leftJoin('trip_members as m', (join) => join.onRef('m.trip_id', '=', 't.id').on('m.user_id', '=', user_id)) + .where((eb) => tripAccessExpr(eb, 't.user_id', 'm.user_id', user_id)); + if (archived !== null) query = query.where('t.is_archived', '=', archived); + const rows = await query.orderBy('t.created_at', 'desc').execute(); + return rows as TripSelectRow[]; + } + + /** + * TP21 (`trips.service.ts::activeTrip`) — the triple `CASE WHEN … + * relevance` projection and the double-`CASE WHEN` `ORDER BY` (one ASC, + * one DESC). The legacy statement's `ORDER BY` items reference the + * `relevance` SELECT-list alias (`CASE WHEN relevance < 2 …`, `CASE WHEN + * relevance = 2 …`); reproduced here by recomputing the equivalent + * boolean expression inline at each of the four sites (the `.select()` + * and three `.orderBy()` calls below) rather than referencing the alias, + * since Kysely's typed `ORDER BY` can't name a computed SELECT alias and + * `sql`-tagged templates are banned in `src/db/repositories/**` — and a + * shared private helper can't be typed across these four callbacks either + * (`leftJoin`'s alias widens each callback's own `ExpressionBuilder` + * table-set in a way a separate generic method signature can't match + * structurally). Semantically identical to the legacy statement — SQLite + * evaluates both forms to the same three-way ordering, verified directly + * against `.compile()`/real rows before wiring in (task report, + * TRIP_SELECT SQL captures): `running` = `relevance = 0`, `upcoming` = + * `relevance = 1`, `running OR upcoming` = `relevance < 2`. + */ + async activeTrip(user_id: number, today: string): Promise { + const row = await this.kysely() + .selectFrom('trips as t') + .leftJoin('trip_members as m', (join) => join.onRef('m.trip_id', '=', 't.id').on('m.user_id', '=', user_id)) + .select(['t.id', 't.title', 't.start_date', 't.end_date']) + .select((eb) => [ + eb.case() + .when(eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)])) + .then(0) + .when(eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)])) + .then(1) + .else(2) + .end() + .as('relevance'), + ]) + .where((eb) => tripAccessExpr(eb, 't.user_id', 'm.user_id', user_id)) + .where('t.is_archived', '=', 0) + .orderBy((eb) => + eb.case() + .when(eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)])) + .then(0) + .when(eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)])) + .then(1) + .else(2) + .end(), 'asc') + .orderBy((eb) => + eb.case() + .when(eb.or([ + eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)]), + eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)]), + ])) + .then(eb.ref('t.start_date')) + .end(), 'asc') + .orderBy((eb) => + eb.case() + .when(eb.not(eb.or([ + eb.and([eb('t.start_date', 'is not', null), eb('t.end_date', 'is not', null), eb('t.start_date', '<=', today), eb('t.end_date', '>=', today)]), + eb.and([eb('t.start_date', 'is not', null), eb('t.start_date', '>=', today)]), + ]))) + .then(eb.ref('t.start_date')) + .end(), 'desc') + .limit(1) + .executeTakeFirst(); + return row as ActiveTripRow | undefined; + } + + /** + * TP18 (`trips.service.ts::create`'s INSERT) — the column set of the + * legacy statement, written verbatim (the `||`/clamp defaults are the + * service's own, decided before this call, per the program's D2 split). + * Returns `em.insert()`'s PK (R6's `lastInsertRowid` replacement). + */ + async insertTrip(input: { + user_id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string; + reminder_days: number; + }): Promise { + return await this.insert({ + user: input.user_id, + title: input.title, + description: input.description, + start_date: input.start_date, + end_date: input.end_date, + currency: input.currency, + reminder_days: input.reminder_days, + }); + } + + // --------------------------------------------------------------------------- + // Plan 3c Task 8 (`TripsService.copy`) — additive. + // --------------------------------------------------------------------------- + + /** + * TP37 (`trips.service.ts::copy`'s INSERT) — `INSERT INTO trips (user_id, + * title, description, start_date, end_date, currency, cover_image, + * is_archived, reminder_days) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`. A + * DIFFERENT column set from `insertTrip` (TP18, `create`'s own INSERT): + * this one also writes `cover_image` (verbatim from the source row) and + * `is_archived` as a hard-coded literal `0` — a copy is never archived, + * regardless of the source's own flag, so `is_archived` takes no + * parameter at all here, matching the legacy statement's own `0` literal + * rather than a bound value. The `title || src.title` fallback and the + * `reminder_days ?? 3` default are the SERVICE's own decisions (already + * resolved before this call), same split as `insertTrip`. + */ + async insertTripCopy(input: { + user_id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string | null; + cover_image: string | null; + reminder_days: number; + }): Promise { + return await this.insert({ + user: input.user_id, + title: input.title, + description: input.description, + start_date: input.start_date, + end_date: input.end_date, + currency: input.currency, + cover_image: input.cover_image, + is_archived: 0, + reminder_days: input.reminder_days, + }); + } + + /** + * TP25 (`trips.service.ts::updateTrip`) — `UPDATE trips SET title=?, + * description=?, start_date=?, end_date=?, currency=?, is_archived=?, + * cover_image=?, reminder_days=?, updated_at=CURRENT_TIMESTAMP WHERE + * id=?`. The `||`/`!== undefined` pre-image fallbacks are the SERVICE's + * own (R5/§18.6 — this write stays OUTSIDE the days-regeneration + * transaction, unchanged); this takes the final, already-decided values + * and writes them verbatim, `updated_at` stamped unconditionally like the + * legacy statement. Named `updateTripRow` (not `updateTrip`, the + * deliverable list's own name) to keep it unambiguous next to + * `TripsService.updateTrip`, which calls it. + * + * `is_archived: number | null` (Task 7 security review L1, absorbed here + * — not `number`): the caller's own pre-image fallback + * (`data.is_archived !== undefined ? … : trip.is_archived`) can legitimately + * be `null` (a stored `NULL` the request never touched), and the legacy + * statement bound that value through unfolded — a `number`-only signature + * here would tempt a caller to paper over that with its own `?? 0`, the + * exact regression the review found. + */ + async updateTripRow(id: number, data: { + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string; + is_archived: number | null; + cover_image: string | null; + reminder_days: number; + }): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { ...data, updated_at: currentTimestamp(platform) }); + } + + /** TP35 (`trips.service.ts::updateCoverImage`) — `UPDATE trips SET cover_image=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`. */ + async setCoverImage(id: number, cover_image: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { cover_image, updated_at: currentTimestamp(platform) }); + } + + /** TP34 (`trips.service.ts::remove`) — `DELETE FROM trips WHERE id = ?` (security-sensitive: cascades the whole trip). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 5 (`FeedsService`) — additive: the anonymous ICS feed token + // lifecycle. FD1/FD2-4/FD9/FD11 (inventory §5). `trip_id`/`user_id` are + // always real `number`s here, unlike `findAccessible`/`getTitle`/etc above: + // the two feed-token REST routes resolve and validate the trip id through + // `TripAccessGuard` (`@Trip()` hands the controller the already-numeric, + // already-access-checked `TripAccess.id`) before `FeedsService` ever runs, + // and the MCP surface's own Zod schema types `tripId` as + // `z.number().int().positive()` — there is no raw route-string seam to + // preserve on this file's newest methods the way `findAccessible`'s + // docstring describes for the pre-existing ones. + // --------------------------------------------------------------------------- + + /** + * FD1 (`feeds.service.ts::tripTokenRow`) — `SELECT feed_token FROM trips + * WHERE id = ? AND (user_id = ? OR id IN (SELECT trip_id FROM trip_members + * WHERE user_id = ?))`. R3 (one visibility predicate, one source): reuses + * `accessibleTripsQuery` above — the SAME join builder `findAccessible`/ + * `listAccessibleIds` already share — rather than re-deriving the + * "reachable" predicate a third way. `null` covers both "no such trip / + * not reachable" and "reachable but the column is NULL" on purpose: every + * caller (`FeedsService.getTripToken`/`generateTripToken`) treats the two + * identically (no usable token), the same collapse the legacy + * `row?.feed_token` optional-chain already made. + */ + async getFeedTokenIfReachable(trip_id: number, user_id: number): Promise { + const row = await this.accessibleTripsQuery(user_id) + .select(['t.feed_token']) + .andWhere({ id: trip_id }) + .execute<{ feed_token: string | null } | undefined>('get', false); + return row ? row.feed_token : null; + } + + /** + * FD2/FD3/FD4 (`generateTripToken`/`rotateTripToken`/`disableTripToken`) — + * ONE method for the three legacy statements (identical text, `token | + * null` is the only thing that varies): `UPDATE trips SET feed_token = ? + * WHERE `. Returns the affected row count — §18.7's 0-row case + * (`generateTripToken` still hands back a URL for a token this write never + * stored, past the guard; R4 mirrors that exactly, unfixed) is now visible + * to a caller instead of a fire-and-forget `.run()`. + * + * `nativeUpdate`, not `accessibleTripsQuery(...).update(...)`: SQLite has + * no `UPDATE ... JOIN` — chaining `.update()` onto a query builder that + * already carries `accessibleTripsQuery`'s `.leftJoin()` throws + * (`near "left": syntax error`, proven directly against a real DB before + * choosing this shape, not assumed). The typed `FilterQuery` below reaches + * for the SAME relation the join builder does (`trip_members_collection`, + * `t.trip_members_collection` in `accessibleTripsQuery`) rather than a + * hand-rolled string predicate (rule 23): MikroORM detects the to-many + * relation in the `$or` and auto-rewrites the whole `nativeUpdate` into an + * `UPDATE trips SET ... WHERE id IN (SELECT id FROM (SELECT DISTINCT t.id + * FROM trips t LEFT JOIN trip_members t1 ON t.id = t1.trip_id WHERE t.id = + * ? AND (t.user_id = ? OR t1.user_id = ?)) AS t)` — the legacy statement's + * `IN`-subquery shape, generated from the identical join, not typed twice. + * Proven row-identical to `accessibleTripsQuery`/`findAccessible` by + * TRIPREPO-041's extended cross-method parity matrix (owner/member/ + * stranger/archived). + */ + async setFeedTokenIfReachable(trip_id: number, user_id: number, token: string | null): Promise { + return await this.nativeUpdate( + { id: trip_id, $or: [{ user: user_id }, { trip_members_collection: { user: user_id } }] }, + { feed_token: token }, + ); + } + + /** + * FD9 (`feeds.service.ts::buildTripIcs`) — `SELECT id FROM trips WHERE + * feed_token = ?`. The anonymous credential lookup: `token` arrives + * untrusted, straight from the URL path, with no access check (the token + * itself IS the access check — `idx_trips_feed_token`'s partial UNIQUE + * index, `WHERE feed_token IS NOT NULL`, is what makes it a safe credential + * to look up by at all: a row with a NULL token can never match, so a + * disabled feed's old URL 404s instead of resolving to whichever trip + * happens to have a NULL column). + */ + async findIdByFeedToken(token: string): Promise { + const row = await this.findOne({ feed_token: token }, { fields: ['id'] }); + return row?.id; + } + + /** + * FD11 (`feeds.service.ts::buildUserIcs`) — `SELECT id FROM trips WHERE + * (user_id = ? OR id IN (SELECT trip_id FROM trip_members WHERE user_id = + * ?)) AND is_archived = 0 AND (end_date IS NULL OR end_date >= ?) ORDER BY + * start_date ASC`. The scope of the anonymous all-trips feed: reachable + * (`accessibleTripsQuery`, R3 again), unarchived, not yet ended (or + * undated). Named `listReachableActiveTrips` per the task brief's ruling. + */ + async listReachableActiveTrips(user_id: number, cutoff: string): Promise { + const rows = await this.accessibleTripsQuery(user_id) + .select(['t.id']) + .andWhere({ is_archived: 0 }) + .andWhere({ $or: [{ end_date: null }, { end_date: { $gte: cutoff } }] }) + .orderBy({ 't.start_date': 'asc' }) + .execute<{ id: number }[]>('all', false); + return rows.map((r) => r.id); + } + + // --------------------------------------------------------------------------- + // Plan 3d Task 5 (`PublicApiService`) — additive: the trip reads on 3d + // (public-api joins Plan 3d for its reads on 3d tables, R3/§14.6). All + // three convert an existing `public-api.service.ts` statement in place; + // `reservations`/`day_accommodations` statements in that file stay raw + // (Tasks 2/3 own those tables and had not landed when this task ran — see + // the task report). + // --------------------------------------------------------------------------- + + /** + * `PublicApiService.listTrips`'s statement — `SELECT id, title, + * description, start_date, end_date, currency, is_archived, updated_at + * FROM trips WHERE id IN (...) ORDER BY start_date DESC, id DESC`. `ids` + * is already the caller's own access-checked list + * (`TripMembershipService.listAccessibleTripIds`, unchanged, not this + * plan's file) — this method does no access check of its own, matching + * the legacy statement, which trusted the same pre-filtered id list. + */ + async listSummariesByIds(ids: number[]): Promise { + if (ids.length === 0) return []; + return this.qb('t') + .select(['t.id', 't.title', 't.description', 't.start_date', 't.end_date', 't.currency', 't.is_archived', 't.updated_at']) + .where({ id: { $in: ids } }) + .orderBy({ start_date: 'desc', id: 'desc' }) + .execute('all', false); + } + + /** + * `PublicApiService.getTrip`'s row read — the SAME 8-column projection as + * `listSummariesByIds` above, for one trip. The caller's own + * `db.canAccessTrip` check (unchanged, a different file) runs first; this + * method, like the legacy statement it replaces, does no access check. + */ + async findSummaryById(id: number): Promise { + const row = await this.qb('t') + .select(['t.id', 't.title', 't.description', 't.start_date', 't.end_date', 't.currency', 't.is_archived', 't.updated_at']) + .where({ id }) + .execute('get', false); + return row ?? null; + } + + /** + * `share.service.ts:264` SH7 (`getSharedTripData`) — `SELECT id, title, + * description, start_date, end_date, cover_image, currency FROM trips + * WHERE id = ?`. A different 7-column public projection from + * `TripSummaryProjectionRow`'s 8 above (`cover_image` in, `is_archived`/ + * `updated_at` out — a public share-link viewer never sees either). No + * access check here, matching the legacy statement — the caller's own + * `verifyTripAccess`/token validity gates this read. + * + * `id: number` (Plan 4 Task 8a, rule 23): unlike `findAccessible`/ + * `isOwner`, this is never on the guard's raw-`req.params` path — its one + * caller (`share.service.ts#getSharedTripData`) passes `shareRow.trip_id`, + * a `number` straight off a Kysely-typed `share_tokens` row, never an + * unconverted URL param — so the `number | string` raw-bind seam those + * two methods' own docstrings describe does not apply here, and this one + * narrows to a typed filter now rather than waiting on that seam's guard- + * level parse (deferred, out of this task's scope). + */ + async findPublicForShare(id: number): Promise { + return await this.qb('t') + .select(['t.id', 't.title', 't.description', 't.start_date', 't.end_date', 't.cover_image', 't.currency']) + .where({ 't.id': id }) + .execute('get', false); + } + + /** + * `PublicApiService.buildTravellers`'s statement — the owner row (a + * literal `1 AS is_owner`) `UNION ALL` every member row (`0 AS is_owner`), + * `ORDER BY is_owner DESC`. A QueryBuilder `UNION ALL` of two different + * FROMs has no single alias to chain a `.select()`/`.where()` off (unlike + * `accessibleTripsQuery`'s single-FROM join), so this is Kysely + * (`this.kysely()`, the same escape hatch `tripSelectQuery`'s own + * docstring documents) — `eb.val(1)`/`eb.val(0)` for the + * two literal branches, the same construct `tripSelectQuery`'s `NULL AS + * feed_token` already uses in this file. + */ + async listTravellerUsernames(trip_id: number): Promise { + const owner = this.kysely() + .selectFrom('trips as t') + .innerJoin('users as u', 'u.id', 't.user_id') + .select((eb) => ['u.username as username', eb.val(1).as('is_owner')]) + .where('t.id', '=', trip_id); + const member = this.kysely() + .selectFrom('trip_members as m') + .innerJoin('users as u', 'u.id', 'm.user_id') + .select((eb) => ['u.username as username', eb.val(0).as('is_owner')]) + .where('m.trip_id', '=', trip_id); + const rows = await owner.unionAll(member).orderBy('is_owner', 'desc').execute(); + return rows as TravellerUsernameRow[]; + } + + // --------------------------------------------------------------------------- + // Plan 3e Task 2 (budget) — additive, append-only per that task's own + // file-ownership rule. + // --------------------------------------------------------------------------- + + /** BG15/BG16/BGM2/BGM3 — `SELECT currency FROM trips WHERE id = ?`, four legacy call sites, one statement. */ + async getCurrency(id: number | string): Promise { + const row = await this.kysely<{ trips: { id: number; currency: string | null } }>() + .selectFrom('trips') + .select('currency') + .where('id', '=', id as number) + .executeTakeFirst(); + return row?.currency; + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 4 (`ReminderJobsService`) — additive. + // --------------------------------------------------------------------------- + + /** + * RJ2 (`reminder-jobs.service.ts`'s boot banner, inside `runOnBoot`) — + * `SELECT COUNT(*) as c FROM trips WHERE reminder_days > 0 AND start_date + * IS NOT NULL`. + */ + async countActiveWithReminders(): Promise { + return this.count({ reminder_days: { $gt: 0 }, start_date: { $ne: null } }); + } + + /** + * RJ3 (`reminder-jobs.service.ts#tripTick`) — restructured per Task 0's R9 + * ruling: the legacy statement concatenated a per-row COLUMN + * (`t.reminder_days`) into a `date('now', '+' || t.reminder_days || ' + * days')` modifier, which no bound-parameter or JS-constant-spelled helper + * can express. This narrows the SQL read to the same WHERE minus the date + * comparison (`SELECT t.id, t.title, t.user_id, t.reminder_days, + * t.start_date FROM trips t WHERE t.reminder_days > 0 AND t.start_date IS + * NOT NULL`); the caller (`ReminderJobsService.tripTick`) does the + * per-row date-equality check in JS against `start_date`, per R9's exact + * verified shape. + */ + async listReminderCandidates(): Promise { + return await this.qb('t') + .select(['t.id', 't.title', 't.user', 't.reminder_days', 't.start_date']) + .andWhere({ reminder_days: { $gt: 0 }, start_date: { $ne: null } }) + .execute('all', false); + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 6 (`SystemNoticesService`) — additive. + // --------------------------------------------------------------------------- + + /** SN3 (`systemNotices/service.ts`'s former `getActiveNoticesFor`) — `SELECT COUNT(*) AS count FROM trips WHERE user_id = ?`, feeding the `noTrips` condition kind. */ + async countForUser(userId: number): Promise { + return this.count({ user: userId }); + } + + // --------------------------------------------------------------------------- + // Plan 3f Task 1 (`AtlasService`) — additive. + // --------------------------------------------------------------------------- + + /** + * AT1 (`AtlasService#getUserTrips`, the join every other atlas read + * relies on transitively) — `SELECT DISTINCT t.* FROM trips t LEFT JOIN + * trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.user_id = + * ? OR m.user_id = ? ORDER BY t.start_date DESC`. Reuses + * `accessibleTripsQuery` (Plan 3c Task 1) rather than re-deriving the + * join: its `m.user: { $ne: null }` half is the LEFT-JOIN-then-`IS NOT + * NULL` spelling of the legacy `m.user_id = ?` — the join's own `ON … + * AND m.user_id = ?` already restricts every matched `m` row to this + * user, so the two WHERE spellings pick out the identical row set. No + * explicit `.distinct()`: `TripMembers`' own `uniques: [['trip',' + * user']]` constraint means at most one `m` row per `(trip, user)`, so + * the join can never fan a `t` row out to more than one result row. + */ + async listOwnedOrMember(user_id: number): Promise { + return await this.accessibleTripsQuery(user_id) + .select(['t.*']) + .orderBy({ 't.start_date': 'desc' }) + .execute('all', false); + } + + /** + * AT39 (`AtlasService#lastTrip`) — `SELECT t.id, t.title, t.start_date, + * t.end_date FROM trips t LEFT JOIN trip_members tm ON t.id = tm.trip_id + * WHERE (t.user_id = ? OR tm.user_id = ?) AND COALESCE(t.start_date, + * t.end_date) IS NOT NULL AND COALESCE(t.start_date, t.end_date) <= + * date('now') ORDER BY COALESCE(t.end_date, t.start_date) DESC, t.id DESC + * LIMIT 1`. `today` (`date('now')`, UTC) is resolved by the caller once + * (`todayUtc()`) and bound here — the `activeTrip(user_id, today)` + * precedent above for a literal `date('now')` comparison. + */ + async lastStartedTrip(user_id: number, today: string): Promise<{ id: number; title: string; start_date: string | null; end_date: string | null } | undefined> { + return await this.kysely() + .selectFrom('trips as t') + .leftJoin('trip_members as tm', 'tm.trip_id', 't.id') + .select(['t.id', 't.title', 't.start_date', 't.end_date']) + .where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', '=', user_id)])) + .where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), 'is not', null)) + .where((eb) => eb(eb.fn.coalesce('t.start_date', 't.end_date'), '<=', today)) + .orderBy((eb) => eb.fn.coalesce('t.end_date', 't.start_date'), 'desc') + .orderBy('t.id', 'desc') + .limit(1) + .executeTakeFirst(); + } + + /** + * AT47 (`AtlasService#nextTrip`, #2542) — `SELECT t.id, t.title, + * t.start_date, t.end_date FROM trips t LEFT JOIN trip_members tm ON t.id = + * tm.trip_id WHERE (t.user_id = ? OR tm.user_id = ?) AND t.start_date IS NOT + * NULL AND t.start_date > date('now') ORDER BY t.start_date ASC, t.id ASC + * LIMIT 1`. `today` is bound like {@link lastStartedTrip}'s; the legacy + * `days_until` column (`CAST(julianday(start_date) - julianday(date('now')) + * AS INTEGER)`) is computed by the caller from the same `today`. + */ + async nextUpcomingTrip(user_id: number, today: string): Promise<{ id: number; title: string; start_date: string; end_date: string | null } | undefined> { + const row = await this.kysely() + .selectFrom('trips as t') + .leftJoin('trip_members as tm', 'tm.trip_id', 't.id') + .select(['t.id', 't.title', 't.start_date', 't.end_date']) + .where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', '=', user_id)])) + .where('t.start_date', 'is not', null) + .where('t.start_date', '>', today) + .orderBy('t.start_date', 'asc') + .orderBy('t.id', 'asc') + .limit(1) + .executeTakeFirst(); + return row as { id: number; title: string; start_date: string; end_date: string | null } | undefined; + } + + /** + * AT42 (`AtlasService#getTravelStats`) — `SELECT COUNT(DISTINCT t.id) as + * trips, COUNT(DISTINCT d.id) as days FROM trips t LEFT JOIN days d ON + * d.trip_id = t.id LEFT JOIN trip_members tm ON t.id = tm.trip_id WHERE + * (t.user_id = ? OR tm.user_id = ?)`. Archived trips still count (the + * legacy statement's own doc comment, `atlas.service.ts:1049-1051`) — no + * `is_archived` filter here, on purpose. + */ + async countTripsAndDaysForUser(user_id: number): Promise<{ trips: number; days: number }> { + const row = await this.kysely() + .selectFrom('trips as t') + .leftJoin('days as d', 'd.trip_id', 't.id') + .leftJoin('trip_members as tm', 'tm.trip_id', 't.id') + .select((eb) => [eb.fn.count('t.id').distinct().as('trips'), eb.fn.count('d.id').distinct().as('days')]) + .where((eb) => eb.or([eb('t.user_id', '=', user_id), eb('tm.user_id', '=', user_id)])) + .executeTakeFirst(); + return { trips: Number(row?.trips ?? 0), days: Number(row?.days ?? 0) }; + } + + // --------------------------------------------------------------------------- + // Plan 3h Task 3 (`DawarichSyncService`/`DawarichTracksService`) — additive. + // --------------------------------------------------------------------------- + + /** + * DSY2 (`dawarich-sync.service.ts::listTripsToSync`) — `SELECT DISTINCT + * t.id, t.start_date, t.end_date FROM trips t LEFT JOIN trip_members m ON + * m.trip_id = t.id AND m.user_id = ? WHERE (t.user_id = ? OR m.user_id IS + * NOT NULL) AND COALESCE(t.is_archived, 0) = 0 AND t.start_date IS NOT + * NULL AND (t.end_date IS NULL OR t.end_date >= date('now', '-400 days')) + * AND t.start_date <= date('now', '+1 day') ORDER BY t.start_date DESC`. + * `accessibleTripsQuery` for the LEFT JOIN/OR half (the same join builder + * `findAccessible`/`listReachableActiveTrips` share); `coalesceParam` for + * the `COALESCE(is_archived, 0) = 0` guard (mirrors a nullable column the + * same way the legacy statement does, even though `is_archived` is + * effectively never NULL in practice); `nowDateOffset` (Task 0) for both + * `date('now', …)` literal-offset forms — expressible entirely through + * the QueryBuilder, so no `nowDateOffsetKysely` twin was needed for this + * site (Task 0's own CONTROLLER NOTE flagged this as a possibility, not a + * certainty). + */ + async listTripsToSync(user_id: number): Promise<{ id: number; start_date: string | null; end_date: string | null }[]> { + const platform = this.getEntityManager().getPlatform(); + return await this.accessibleTripsQuery(user_id) + .select(['t.id', 't.start_date', 't.end_date'], true) + .andWhere({ [coalesceParam(platform, 'is_archived', 0)]: 0 }) + .andWhere({ start_date: { $ne: null } }) + .andWhere({ $or: [{ end_date: null }, { end_date: { $gte: nowDateOffset(platform, -400) } }] }) + .andWhere({ start_date: { $lte: nowDateOffset(platform, 1) } }) + .orderBy({ 't.start_date': 'desc' }) + .execute<{ id: number; start_date: string | null; end_date: string | null }[]>('all', false); + } + + /** DTR1 (`dawarich-tracks.service.ts::forTrip`) — `SELECT start_date, end_date FROM trips WHERE id = ?`. */ + async findDatesById(id: number): Promise<{ start_date: string | null; end_date: string | null } | undefined> { + return await this.qb('t') + .select(['t.start_date', 't.end_date']) + .where({ id }) + .execute<{ start_date: string | null; end_date: string | null } | undefined>('get', false); + } + + // --------------------------------------------------------------------------- + // Plan 4 Task 1 (`registration-invites.service.ts::listTripsForInvite`) — + // additive, per the class's own docstring pointing at this precondition + // ("Plan 3c is the one that builds a TripsRepository") now being met. + // --------------------------------------------------------------------------- + + /** + * RI2 — `SELECT id, title FROM trips ORDER BY title COLLATE NOCASE ASC` + * (the admin invite dialog's trip picker). Kysely, `PlacesRepository + * .listImportable`'s precedent: `ORDER BY … COLLATE NOCASE` is Kysely's + * own `OrderByItemBuilder.collate('nocase')`, a portable Kysely builder + * API, not a raw SQLite fragment — no `sql-functions.ts` helper needed. + */ + async listIdTitleOrderedByTitle(): Promise<{ id: number; title: string }[]> { + return await this.kysely() + .selectFrom('trips') + .select(['id', 'title']) + .orderBy('title', (ob) => ob.collate('nocase').asc()) + .execute(); + } +} + +/** {@link TripsRepository.listIdTitleOrderedByTitle}'s narrow `trips` shape. */ +interface TripIdTitleKyselyDB { + trips: { id: number; title: string }; +} + +/** {@link TripsRepository.lastStartedTrip}'s and {@link TripsRepository.nextUpcomingTrip}'s narrow `trips`/`trip_members` shape. */ +interface LastStartedTripKyselyDB { + trips: { id: number; title: string; start_date: string | null; end_date: string | null; user_id: number }; + trip_members: { trip_id: number; user_id: number }; +} + +/** {@link TripsRepository.countTripsAndDaysForUser}'s narrow `trips`/`days`/`trip_members` shape. */ +interface CountTripsAndDaysKyselyDB { + trips: { id: number; user_id: number }; + days: { id: number; trip_id: number }; + trip_members: { trip_id: number; user_id: number }; +} + +/** {@link TripsRepository.listReminderCandidates}'s row — `t.user` selected bare (not joined here), so it aliases to the physical `user_id` column per `findAccessible`'s documented precedent. */ +export interface TripReminderCandidateRow { + id: number; + title: string; + user_id: number; + reminder_days: number; + start_date: string; +} + +/** `PublicApiService.listTrips`/`getTrip`'s 8-column trip projection. */ +export interface TripSummaryProjectionRow { + id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + currency: string | null; + is_archived: number | null; + updated_at: string | null; +} + +/** {@link TripsRepository.findPublicForShare}'s projection (SH7). */ +export interface TripPublicShareRow { + id: number; + title: string; + description: string | null; + start_date: string | null; + end_date: string | null; + cover_image: string | null; + currency: string | null; +} + +/** `PublicApiService.buildTravellers`'s output row. */ +export interface TravellerUsernameRow { + username: string; + is_owner: number; +} + +/** The narrow `trips`/`users`/`trip_members` shape `listTravellerUsernames` needs. */ +interface TravellerUsernameKyselyDB { + trips: { id: number; user_id: number }; + users: { id: number; username: string }; + trip_members: { id: number; trip_id: number; user_id: number }; +} diff --git a/server/src/db/repositories/UserNoticeDismissals.repository.ts b/server/src/db/repositories/UserNoticeDismissals.repository.ts new file mode 100644 index 000000000..b163da495 --- /dev/null +++ b/server/src/db/repositories/UserNoticeDismissals.repository.ts @@ -0,0 +1,74 @@ +import type { UserNoticeDismissals } from '../entities/UserNoticeDismissals.entity'; +import { toRow } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** SN4's projection — `notice_id`/`dismissed_app_version` (the caller already knows which user it asked for). */ +export interface UserNoticeDismissalRow { + notice_id: string; + dismissed_app_version: string | null; +} + +/** + * `user_notice_dismissals` — a genuine TWO-column composite primary key + * (`user` relation + `notice_id` text, both `.primary()` — Plan 3f Task 0's + * R5/§14 finding, confirmed against the migration DDL: + * `Migration20200101014400_system_notices.ts` + the later + * `dismissed_app_version` column addition). Every write here goes through + * `em.upsert` targeting both columns as the conflict target, never a + * synthetic `id`. `user_id` is a `persist(false)` mirror of the `user` + * relation — filtered/written through `user` throughout, never the mirror + * (the program-wide trap). + */ +export class UserNoticeDismissalsRepository extends TrekRepository { + /** + * SN4 — `SELECT notice_id, dismissed_app_version FROM user_notice_dismissals + * WHERE user_id = ?`. `SystemNoticesService.getActiveFor` maps the result to + * a `Map` for the per-version re-show logic. + */ + async listForUser(userId: number): Promise { + const rows = await this.find({ user: userId }, { fields: ['notice_id', 'dismissed_app_version'] }); + // `dismissed_app_version` is declared optional (`?`) on the entity class + // (the `AppSettings`/`AppSettingsRow` precedent — `AppSettings + // .repository.ts#findByKeyPrefix`'s own `toRow(...) as ...Row` cast), so + // a fields-narrowed `Loaded<...>` types it as possibly-undefined even + // though a fields-narrowed read always returns the column (`null`, never + // absent) — `toRow` + the row cast bridges that gap. + return rows.map((row) => toRow(row) as UserNoticeDismissalRow); + } + + /** + * SN5 — `dismissNotice`'s `INSERT INTO user_notice_dismissals (user_id, + * notice_id, dismissed_at, dismissed_app_version) VALUES (?, ?, ?, ?) ON + * CONFLICT(user_id, notice_id) DO UPDATE SET dismissed_at = + * excluded.dismissed_at, dismissed_app_version = excluded.dismissed_app_version`. + * + * Composite-PK `em.upsert` — the shape Task 0 (R5) pinned against the + * migration DDL and Task 3's `NotificationChannelPreferencesRepository + * .upsertPreference` proved first with a rendered-SQL test + * (`NCPREPO-UPSERT-SQL`), re-checked here rather than re-derived + * (`UserNoticeDismissals.repository.test.ts`'s `UNDREPO-UPSERT-SQL`). + * `onConflictFields` names the two primary-key properties by their ENTITY + * property names (`user`, not the `persist(false)` `user_id` mirror); + * `onConflictAction: 'merge'` reproduces the `ON CONFLICT ... DO UPDATE`'s + * "overwrite `dismissed_at`/`dismissed_app_version`" semantics exactly — + * there is no other non-key column for it to diverge on. + * + * Named `upsertDismissal`, not `upsert`: `TrekRepository` already declares + * (and overrides) `upsert` with MikroORM's own generic signature, so a + * method of the same name here would have to be a compatible override, not + * a differently-shaped convenience wrapper — same reason + * `AppSettingsRepository.setValue`/`NotificationChannelPreferencesRepository + * .upsertPreference` use their own names rather than `upsert`. + * + * `dismissedAt` stays the caller's raw epoch-ms integer (`Date.now()`, not + * `CURRENT_TIMESTAMP`) — this table is the one place in the whole plan + * that stores a timestamp this way; preserved exactly, not normalized to + * match every other domain's `CURRENT_TIMESTAMP` convention. + */ + async upsertDismissal(userId: number, noticeId: string, dismissedAt: number, dismissedAppVersion: string): Promise { + await this.upsert( + { user: userId, notice_id: noticeId, dismissed_at: dismissedAt, dismissed_app_version: dismissedAppVersion }, + { onConflictFields: ['user', 'notice_id'], onConflictAction: 'merge' }, + ); + } +} diff --git a/server/src/db/repositories/Users.repository.ts b/server/src/db/repositories/Users.repository.ts new file mode 100644 index 000000000..6a37d836d --- /dev/null +++ b/server/src/db/repositories/Users.repository.ts @@ -0,0 +1,1644 @@ +import { Users } from '../entities/Users.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { coalesce, coalesceOverrideWhileSame, columnIncrementedBy, currentTimestamp, lower, lowerParam } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * The three third-party keys that belong to the instance rather than to a + * person (see `nest/settings/instance-api-keys.ts`). A closed union, never a + * dynamic column string — `getApiKeyColumn` dispatches through MikroORM's + * typed `fields` option, which only accepts a real property of `Users`, so a + * name outside this union fails `tsc` rather than reaching a query. + * + * This repository is the union's source of truth; `instance-api-keys.ts` + * imports it rather than keeping its own copy (Task 5). + */ +export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key' | 'amap_api_key'; + +/** + * A full `users` row (`SELECT * FROM users WHERE ...`), as the API emits it. + * Every scalar column of the entity, hand-nullability-annotated the way + * `AppSettingsRepository`/`CategoriesRepository`'s row interfaces are — + * `AssertRowKeys` below fails `tsc` the moment this list and the entity's + * scalar columns drift. + */ +export interface UserRow { + id: number; + username: string; + email: string; + password_hash: string; + role: string; + maps_api_key: string | null; + unsplash_api_key: string | null; + amap_api_key: string | null; + openweather_api_key: string | null; + avatar: string | null; + oidc_sub: string | null; + oidc_issuer: string | null; + last_login: string | null; + mfa_enabled: number | null; + mfa_secret: string | null; + mfa_backup_codes: string | null; + immich_url: string | null; + immich_access_token: string | null; + synology_url: string | null; + synology_username: string | null; + synology_password: string | null; + synology_sid: string | null; + must_change_password: number | null; + password_version: number; + feed_token: string | null; + is_guest: number; + created_at: string | null; + updated_at: string | null; + immich_api_key: string | null; + synology_skip_ssl: number; + synology_did: string | null; + first_seen_version: string; + login_count: number; + immich_auto_upload: number; + airtrail_url: string | null; + airtrail_api_key: string | null; + airtrail_allow_insecure_tls: number | null; + airtrail_write_enabled: number | null; + display_name: string | null; + immich_allow_insecure_tls: number; +} + +const _userRowKeys: AssertRowKeys = true; + +/** `AuthService.registerUser` (AU10) / `OidcService.findOrCreateUser` (O14) — the two column sets `insertUser` covers as one method (Task 1 brief's Required set: "one method, nullable oidc/avatar fields"). */ +export interface NewUserRow { + username: string; + email: string; + password_hash: string; + role: string; + first_seen_version: string; + oidc_sub?: string | null; + oidc_issuer?: string | null; + avatar?: string | null; +} + +/** + * `TripMembersService.createGuest` (TM16, `trip-members.service.ts:222-224`) — + * a credential-less guest account (#1362). Additive, NOT a widening of + * `NewUserRow` above (Plan 3b Task 1's shape, covering only the AU10/O14 + * real-account column sets): this method's `password_hash`/`role`/`is_guest` + * are fixed literals the caller can never override, which is the whole + * point — a second, narrower method keeps the "credential-less account" + * invariant IN the method rather than in caller discipline (Task 6 brief's + * explicit ruling). + */ +export interface NewGuestUserRow { + username: string; + email: string; + display_name: string; +} + +/** `UserProfileService.updateApiKeys` (UP3) — the four encrypted key columns, already resolved by the service. */ +export interface ApiKeysPatch { + maps_api_key: string | null; + openweather_api_key: string | null; + unsplash_api_key: string | null; + amap_api_key: string | null; +} + +/** `UserProfileService.updateSettings`'s bounded dynamic SET clause (UP7) — a closed key union, never a built SQL string. */ +export interface UserProfilePatch { + maps_api_key?: string | null; + openweather_api_key?: string | null; + unsplash_api_key?: string | null; + amap_api_key?: string | null; + username?: string; + email?: string; +} + +/** `UserProfileService.getApiKeyColumns`/`currentKeys` (UP1). */ +export interface UserApiKeyColumns { + role: string; + maps_api_key: string | null; + openweather_api_key: string | null; + unsplash_api_key: string | null; + amap_api_key: string | null; +} + +/** `UserProfileService.updateApiKeys`/`updateSettings`'s re-select (UP4/UP8). */ +export interface UserProfileWithKeys { + id: number; + username: string; + email: string; + role: string; + maps_api_key: string | null; + openweather_api_key: string | null; + unsplash_api_key: string | null; + amap_api_key: string | null; + avatar: string | null; + mfa_enabled: number | null; +} + +/** `UserProfileService.saveAvatar`'s re-select (UP11). */ +export interface UserProfileBasic { + id: number; + username: string; + email: string; + role: string; + avatar: string | null; +} + +/** `UserProfileService.listUsers` (UP14). */ +export interface UserDirectoryRow { + id: number; + username: string; + avatar: string | null; +} + +/** `AuthService.getCurrentUser` (AU14). */ +export interface CurrentUserRow { + id: number; + username: string; + email: string; + role: string; + avatar: string | null; + oidc_issuer: string | null; + created_at: string | null; + mfa_enabled: number | null; + must_change_password: number | null; +} + +/** `jwt-verify.ts#verifyJwtAndLoadUser` (JV1). */ +export interface UserWithPasswordVersion { + id: number; + username: string; + email: string; + role: string; + password_version: number; +} + +/** `realtime.gateway.ts#handleConnection`'s WS-handshake row (Plan 4 Task 1). */ +export interface UserWsHandshakeRow { + id: number; + username: string; + email: string; + role: string; + mfa_enabled: number | boolean | null; + password_version: number; +} + +/** `AuthService.requestPasswordReset` (AU36). */ +export interface PasswordResetLookup { + id: number; + email: string; + password_hash: string; + oidc_sub: string | null; +} + +/** `AuthService.resetPassword`'s reset-target read (AU40). */ +export interface ResetTargetRow { + id: number; + email: string; + mfa_enabled: number | null; + mfa_secret: string | null; + mfa_backup_codes: string | null; + password_version: number; +} + +/** AD1 (`admin.service.ts#listUsers`) — the admin user-list projection. */ +export interface AdminUserListRow { + id: number; + username: string; + email: string; + role: string; + avatar: string | null; + created_at: string | null; + updated_at: string | null; + last_login: string | null; +} + +/** AD5/AD14 (`admin.service.ts#createUser`/`updateUser`'s post-write re-select), byte-identical text at both sites. */ +export interface AdminUserSummaryRow { + id: number; + username: string; + email: string; + role: string; + created_at: string | null; + updated_at: string | null; +} + +/** AD16 (`admin.service.ts#resetUserMfa`'s pre-write read). */ +export interface AdminUserMfaRow { + id: number; + email: string; + mfa_enabled: number | null; +} + +/** AD4 (`admin.service.ts#createUser`'s write) — narrower than {@link NewUserRow}: no `first_seen_version`/`login_count`/oidc/avatar columns named, letting the entity's own defaults (`'0.0.0'`/`0`) fill them, the same values the legacy INSERT's omitted columns fell back to via the schema's `DEFAULT`. */ +export interface NewAdminUserRow { + username: string; + email: string; + password_hash: string; + role: string; +} + +/** AD11 (`admin.service.ts#updateUser`, inside `uow.transactional`) — the columns the legacy COALESCE-shaped UPDATE can touch; a key the caller omits is left unchanged, the same net effect as binding NULL into `COALESCE(?, column)`. */ +export interface AdminEditPatch { + username?: string; + email?: string; + role?: string; + password_hash?: string; + password_version?: number; +} + +export class UsersRepository extends TrekRepository { + /** + * **Ruling (Plan 3b Task 1 fix round, supersedes Plan 3a's I1 "`refresh: + * true` on every PK-only `findOne`"):** every row-out read in this + * repository (`findOne`/`find`) passes `disableIdentityMap: true` instead. + * MikroORM forks the EM with `keepTransactionContext: true`, loads inside + * that fork (so the read still sees the transaction's own uncommitted + * writes), and clears the fork afterwards + * (`node_modules/@mikro-orm/core/EntityManager.js:807-814`) — the entity + * is never added to the *request's* identity map, so it can never become a + * stale pending change-set entry that a later `flush()` (including the + * implicit one at `UnitOfWork.transactional` commit) writes back over an + * intervening `nativeUpdate`. `refresh: true` is dropped as redundant: it + * only mattered for an entity that stayed in the map, and none does now. + * See https://mikro-orm.io/docs/entity-manager#disableidentitymap and + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` (B1). + * + * **Plan 3b interlude B update:** this option is now applied by the base + * class, not per call — `TrekRepository`'s `find`/`findOne` overrides + * (`_shared/trek-repository.ts`) merge `{ disableIdentityMap: true }` into + * every call's options unless the caller passes `disableIdentityMap: + * false` explicitly, so no method below names it any more. The base class + * also closes the gap the Task 1 re-review found in this mechanism + * (`task-1-rereview.md` F-R1): `disableIdentityMap: true` resolves with + * MikroORM's non-validating `getContext(false)`, so a converted read no + * longer failed closed outside a request context — every override + * validates first (`this.getEntityManager().getContext()`, the + * VALIDATING form) and only then delegates, restoring the ratchet for + * reads AND for the write paths that never validated at all (F7 INFO). + */ + async getEmail(userId: number): Promise { + const row = await this.findOne({ id: userId }, { fields: ['email'] }); + return row?.email ?? null; + } + + /** + * One of the three instance-API-key columns, selected by `name`: + * `SELECT FROM users WHERE id = ?`. `name` is the typed union above, + * not an interpolated column — MikroORM's `fields` option only accepts a + * real property of `Users`, so this can never select an arbitrary column. + * + * `disableIdentityMap: true` per the class-level ruling above. + */ + async getApiKeyColumn(userId: number, name: InstanceApiKeyName): Promise { + const row = await this.findOne({ id: userId }, { fields: [name] }); + return row ? (row[name] ?? null) : null; + } + + // --------------------------------------------------------------------- + // JV1 — jwt-verify.ts + // --------------------------------------------------------------------- + + /** + * `SELECT id, username, email, role, password_version FROM users WHERE id = ?` + * + * The canonical session-invalidation read: `verifyJwtAndLoadUser` compares + * the returned `password_version` against the JWT's `pv` claim on every + * request. `disableIdentityMap: true` per the class-level ruling above. + */ + async findByIdWithPasswordVersion(id: number): Promise { + const row = await this.findOne( + { id }, + { fields: ['id', 'username', 'email', 'role', 'password_version'] }, + ); + return row ? { id: row.id, username: row.username, email: row.email, role: row.role, password_version: row.password_version } : null; + } + + /** + * `SELECT id, username, email, role, mfa_enabled, password_version FROM + * users WHERE id = ?` (`realtime.gateway.ts#handleConnection`, Plan 4 Task + * 1). Same construction as {@link findByIdWithPasswordVersion} above (the + * canonical password-version read this class already establishes) plus + * `mfa_enabled`, which the WS handshake's own MFA gate also needs from the + * SAME row the legacy statement read — kept as one narrowed read rather + * than two separate repository calls, so a user deleted between two calls + * can't split what was one atomic check under the legacy raw statement. + */ + async findForWsHandshake(id: number): Promise { + const row = await this.findOne( + { id }, + { fields: ['id', 'username', 'email', 'role', 'mfa_enabled', 'password_version'] }, + ); + return row + ? { + id: row.id, + username: row.username, + email: row.email, + role: row.role, + mfa_enabled: row.mfa_enabled ?? null, + password_version: row.password_version, + } + : null; + } + + // --------------------------------------------------------------------- + // AU2 / TK7 / O2 — password_version (session-invalidation gate) + // --------------------------------------------------------------------- + + /** `SELECT password_version FROM users WHERE id = ?`. `?? 0` stays in the caller (AU2/TK7/O2 each fall back differently in spirit, identically in value). */ + async getPasswordVersion(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['password_version'] }); + return row?.password_version ?? null; + } + + // --------------------------------------------------------------------- + // AU4 / AU7 / O10 — first-user / admin-role decisions + // --------------------------------------------------------------------- + + /** `SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0`. `is_guest` is `INTEGER NOT NULL DEFAULT 0` (verified in the baseline + guest-members migrations) — no row can store NULL there, so `{ is_guest: 0 }` is exact parity for the `COALESCE(is_guest, 0) = 0` guard, not an approximation. */ + async countNonGuest(): Promise { + return this.count({ is_guest: 0 }); + } + + // --------------------------------------------------------------------- + // AU6 — setup_complete + // --------------------------------------------------------------------- + + /** `SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1`. */ + async findAdminNeedingPasswordChange(): Promise { + const row = await this.findOne({ role: 'admin', must_change_password: 1 }, { fields: ['id'] }); + return row?.id ?? null; + } + + // --------------------------------------------------------------------- + // AU9 — registration collision check + // --------------------------------------------------------------------- + + /** + * `SELECT id FROM users WHERE (LOWER(email) = LOWER(?) OR LOWER(username) = LOWER(?)) + * AND COALESCE(is_guest, 0) = 0` + * + * Both operands of each comparison are folded by SQLite's own `LOWER()` + * (`lowerParam` binds the RAW value) — never a JS-lowered bind. SQLite's + * `LOWER()` is ASCII-only; `String.prototype.toLowerCase()` is + * full-Unicode, and mixing them locks out any non-ASCII cased identifier + * (program rule 18; Plan 3b Task 7 review, H1). + */ + async findIdByEmailOrUsernameCI(email: string, username: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { + $or: [ + { [lower(platform, 'email')]: lowerParam(platform, email) }, + { [lower(platform, 'username')]: lowerParam(platform, username) }, + ], + is_guest: 0, + }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + // --------------------------------------------------------------------- + // AU10 / O14 — account creation + // --------------------------------------------------------------------- + + /** + * `INSERT INTO users (username, email, password_hash, role, first_seen_version, login_count) + * VALUES (?, ?, ?, ?, ?, 0)` (AU10, local registration) and + * `INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, avatar, first_seen_version, login_count) + * VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)` (O14, SSO registration) — ONE method + * (Task 1 brief's Required set), the `oidc_sub`/`oidc_issuer`/`avatar` + * columns nullable so AU10's caller simply omits them. `login_count` is + * always the literal `0` on insert, never a parameter, matching both + * statements exactly. + * + * `this.insert` (F2, Plan 3b Task 1 fix round; routed through the + * repository rather than `this.getEntityManager().insert(Users, …)` so it + * goes through the base class's validation — see + * `_shared/trek-repository.ts`), never `create()` + `persist().flush()`: + * `flush()` commits the *whole* unit of work of the request's + * `EntityManager`, not just this row — the review caught it emitting a + * full-row `UPDATE users … WHERE id = ?` against an unrelated, + * previously-read user in the same EM. `insert` fires a single native + * INSERT with no side effects on the context/identity map (the same rule + * as `AuditLogRepository.insertEntry`). Followed by a read-back + * (`findById`, `disableIdentityMap: true` by the base class's default): + * the returned row carries the generated `id` and the `defaultRaw` + * `created_at` the same way a legacy INSERT-then-reselect pair did. + */ + async insertUser(row: NewUserRow): Promise { + const id = await this.insert({ + username: row.username, + email: row.email, + password_hash: row.password_hash, + role: row.role, + first_seen_version: row.first_seen_version, + login_count: 0, + oidc_sub: row.oidc_sub ?? null, + oidc_issuer: row.oidc_issuer ?? null, + avatar: row.avatar ?? null, + }); + const inserted = await this.findById(id); + if (!inserted) { + throw new Error('insertUser: read-back after insert found no row'); + } + return inserted; + } + + // --------------------------------------------------------------------- + // AU12 / O4 — email lookup (login + OIDC email-fallback) + // --------------------------------------------------------------------- + + /** + * `SELECT * FROM users WHERE LOWER(email) = LOWER(?) AND COALESCE(is_guest, 0) = 0` + * — AU9's sibling for AU12 (`AuthService.loginUser`, `auth.service.ts`). + * + * Both sides folded by SQLite's own `LOWER()` (`lowerParam` binds the + * RAW, untransformed email) — the legacy statement lowered BOTH operands + * in SQL, so a JS-lowered bind here would disagree with the column on + * every non-ASCII cased character (program rule 18; Plan 3b Task 7 + * review, H1: a `JOSÉ@x.com` account could register but never log back + * in). Distinct from `findByEmailLoweredBind` below, which mirrors O4's + * different legacy statement — do not collapse the two back into one + * method (D4). + */ + async findByEmailCI(email: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const user = await this.findOne({ [lower(platform, 'email')]: lowerParam(platform, email), is_guest: 0 }); + return user ? (toRow(user) as UserRow) : null; + } + + /** + * `SELECT * FROM users WHERE LOWER(email) = ? AND COALESCE(is_guest, 0) = 0` + * — O4 (`OidcService.findOrCreateUser`, `oidc.service.ts:655`), whose only + * caller JS-lowers the email itself (`oidc.service.ts:620`, + * `userInfo.email.trim().toLowerCase()`) before calling this method, + * exactly mirroring the legacy statement's `LOWER(email) = ?` (SQL + * `LOWER()` on the column, a bind the CALLER already lowered — not a + * second SQL `LOWER()` on the value side). Do not point a caller that has + * NOT already lowered its value at this method — use `findByEmailCI` + * above instead. + */ + async findByEmailLoweredBind(alreadyLoweredEmail: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const user = await this.findOne({ [lower(platform, 'email')]: alreadyLoweredEmail, is_guest: 0 }); + return user ? (toRow(user) as UserRow) : null; + } + + // --------------------------------------------------------------------- + // AuthService.demoLogin (auth.service.ts:~376) — inventory-correction + // addition, Plan 3b Task 1 fix round item 7 (pre-authorised for Task 5). + // --------------------------------------------------------------------- + + /** + * `SELECT * FROM users WHERE email = ?` — case-**sensitive**, **no** guest + * filter, full row. Distinct from both `findByEmailCI` (case-insensitive + * + guest-filtered) and `findForPasswordReset` (case-sensitive but + * projected + guest-filtered): `AuthService.demoLogin` needs the exact, + * unfiltered legacy statement, including matching a guest row. + */ + async findByEmailExact(email: string): Promise { + const user = await this.findOne({ email }); + return user ? (toRow(user) as UserRow) : null; + } + + // --------------------------------------------------------------------- + // AU13 / AU34 / AU35 / PK12 / O16 — login bookkeeping + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 + * WHERE id = ?` — ONE statement via `nativeUpdate` with a raw sibling-column + * increment (`columnIncrementedBy`), never a read-modify-write: two + * concurrent calls both read `login_count` from the row at commit time, not + * from a value fetched earlier, so both increments land (proven by + * `USERSREPO` `touchLastLogin` concurrency test — two concurrent calls → + * `+2`, never `+1`). + */ + async touchLastLogin(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { last_login: currentTimestamp(platform), login_count: columnIncrementedBy(platform, 'login_count', 1) }, + ); + } + + // --------------------------------------------------------------------- + // AU14 — /api/auth/me + // --------------------------------------------------------------------- + + /** `SELECT id, username, email, role, avatar, oidc_issuer, created_at, mfa_enabled, must_change_password FROM users WHERE id = ?` */ + async findMeRow(id: number): Promise { + const row = await this.findOne( + { id }, + { + fields: ['id', 'username', 'email', 'role', 'avatar', 'oidc_issuer', 'created_at', 'mfa_enabled', 'must_change_password'], + }, + ); + return row + ? { + id: row.id, + username: row.username, + email: row.email, + role: row.role, + avatar: row.avatar ?? null, + oidc_issuer: row.oidc_issuer ?? null, + created_at: row.created_at ?? null, + mfa_enabled: row.mfa_enabled ?? null, + must_change_password: row.must_change_password ?? null, + } + : null; + } + + // --------------------------------------------------------------------- + // AU15 — current-password check + // --------------------------------------------------------------------- + + /** `SELECT password_hash, password_version FROM users WHERE id = ?` */ + async getPasswordHashAndVersion(id: number): Promise<{ password_hash: string; password_version: number } | null> { + const row = await this.findOne({ id }, { fields: ['password_hash', 'password_version'] }); + return row ? { password_hash: row.password_hash, password_version: row.password_version } : null; + } + + // --------------------------------------------------------------------- + // AU16 / AU43 — password change / reset + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET password_hash = ?, must_change_password = 0, + * password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` + * (AU16 `changePassword`, AU43 `resetPassword` — identical statement). + */ + async setPassword(id: number, passwordHash: string, passwordVersion: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { password_hash: passwordHash, must_change_password: 0, password_version: passwordVersion, updated_at: currentTimestamp(platform) }, + ); + } + + // --------------------------------------------------------------------- + // AU19 / O7 — last-admin guard + // --------------------------------------------------------------------- + + /** `SELECT COUNT(*) as count FROM users WHERE role = 'admin'` */ + async countAdmins(): Promise { + return this.count({ role: 'admin' }); + } + + // --------------------------------------------------------------------- + // AU20 / AU22 — admin gate + // --------------------------------------------------------------------- + + /** `SELECT role FROM users WHERE id = ?` */ + async getRole(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['role'] }); + return row?.role ?? null; + } + + // --------------------------------------------------------------------- + // AU23 / AU27 / MP2 — MFA state + // --------------------------------------------------------------------- + + /** + * `SELECT mfa_enabled FROM users WHERE id = ?` + * + * Returns the ROW, not the bare column: `mfa-policy.guard.ts` (MP2) needs + * to tell "no row for this id" (`null` — the user vanished mid-request, + * `MfaPolicyGuard` lets the request through) apart from "row present with + * `mfa_enabled` unset" (`{ mfa_enabled: null }` — still governed by the + * policy) — the same distinction the legacy `if (!row) return true;` + * before `row.mfa_enabled === 1` made. A bare `T | null` return would + * collapse both into the same value. + */ + async getMfaEnabled(id: number): Promise<{ mfa_enabled: number | null } | null> { + const row = await this.findOne({ id }, { fields: ['mfa_enabled'] }); + return row ? { mfa_enabled: row.mfa_enabled ?? null } : null; + } + + // --------------------------------------------------------------------- + // AU28 — enable MFA + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, + * updated_at = CURRENT_TIMESTAMP WHERE id = ?`. `secret`/`codes` arrive + * already encrypted/hashed — this repository never touches either. + */ + async enableMfa(id: number, encryptedSecret: string, backupCodes: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { mfa_enabled: 1, mfa_secret: encryptedSecret, mfa_backup_codes: backupCodes, updated_at: currentTimestamp(platform) }, + ); + } + + // --------------------------------------------------------------------- + // AU30 / AU32 / PK4 / PK10 / O15 — full-row reads + // --------------------------------------------------------------------- + + /** + * `SELECT * FROM users WHERE id = ?` — the full row, reused by every + * caller that needs more than a handful of columns (AU30 `disableMfa`, + * AU32 `verifyMfaLogin`, PK4 `passkeyRegisterOptions`, PK10 + * `passkeyLoginVerify`, O15 `findOrCreateUser`'s post-transaction re-select). + * `disableIdentityMap: true` per the class-level ruling above. + */ + async findById(id: number): Promise { + const user = await this.findOne({ id }); + return user ? (toRow(user) as UserRow) : null; + } + + // --------------------------------------------------------------------- + // AU31 — disable MFA + // --------------------------------------------------------------------- + + /** `UPDATE users SET mfa_enabled = 0, mfa_secret = NULL, mfa_backup_codes = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?` */ + async disableMfa(id: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { mfa_enabled: 0, mfa_secret: null, mfa_backup_codes: null, updated_at: currentTimestamp(platform) }, + ); + } + + // --------------------------------------------------------------------- + // AU33 / AU44 — backup-code burn + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP + * WHERE id = ?` (AU33, `verifyMfaLogin`'s backup-code branch — inside the + * same transaction as `touchLastLogin`). + */ + async setBackupCodesAndTouch(id: number, backupCodes: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { mfa_backup_codes: backupCodes, updated_at: currentTimestamp(platform) }); + } + + /** + * `UPDATE users SET mfa_backup_codes = ? WHERE id = ?` (AU44, + * `resetPassword`'s conditional backup-code splice — no `updated_at`, + * unlike AU33's otherwise-identical statement; two methods, byte-exact + * parity per statement). + */ + async setBackupCodes(id: number, backupCodes: string): Promise { + await this.nativeUpdate({ id }, { mfa_backup_codes: backupCodes }); + } + + // --------------------------------------------------------------------- + // AU36 — password-reset request lookup + // --------------------------------------------------------------------- + + /** + * `SELECT id, email, password_hash, oidc_sub FROM users WHERE email = ? + * AND COALESCE(is_guest, 0) = 0` — case-SENSITIVE on `email`, unlike + * every other email lookup in this repository (no `LOWER()` in the legacy + * statement here); kept exactly as written, not "fixed" to match the + * others (parity is law). + */ + async findForPasswordReset(email: string): Promise { + const row = await this.findOne( + { email, is_guest: 0 }, + { fields: ['id', 'email', 'password_hash', 'oidc_sub'] }, + ); + return row ? { id: row.id, email: row.email, password_hash: row.password_hash, oidc_sub: row.oidc_sub ?? null } : null; + } + + // --------------------------------------------------------------------- + // AU40 — reset-target read + // --------------------------------------------------------------------- + + /** `SELECT id, email, mfa_enabled, mfa_secret, mfa_backup_codes, password_version FROM users WHERE id = ?` */ + async findResetTarget(id: number): Promise { + const row = await this.findOne( + { id }, + { fields: ['id', 'email', 'mfa_enabled', 'mfa_secret', 'mfa_backup_codes', 'password_version'] }, + ); + return row + ? { + id: row.id, + email: row.email, + mfa_enabled: row.mfa_enabled ?? null, + mfa_secret: row.mfa_secret ?? null, + mfa_backup_codes: row.mfa_backup_codes ?? null, + password_version: row.password_version, + } + : null; + } + + // --------------------------------------------------------------------- + // PK15 — re-auth before passkey deletion + // --------------------------------------------------------------------- + + /** + * `SELECT password_hash FROM users WHERE id = ?` + * + * The `?? null` fallback's own "row present but `password_hash` itself + * null" branch is unreachable: `password_hash TEXT NOT NULL` in the + * baseline schema (never relaxed by a later migration) — the same + * defensive-but-unreachable shape as `countNonGuest`'s `COALESCE(is_guest, + * 0)` above. The `?.`/`??` combination still earns its keep for the + * "row missing" case (`?.` alone would leave `undefined`, not `null`). + */ + async getPasswordHash(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['password_hash'] }); + return row?.password_hash ?? null; + } + + // --------------------------------------------------------------------- + // PK17 — admin passkey-reset target + // --------------------------------------------------------------------- + + /** `SELECT id, email FROM users WHERE id = ?` */ + async findIdAndEmail(id: number): Promise<{ id: number; email: string } | null> { + const row = await this.findOne({ id }, { fields: ['id', 'email'] }); + return row ? { id: row.id, email: row.email } : null; + } + + // --------------------------------------------------------------------- + // O3 — OIDC primary identity match + // --------------------------------------------------------------------- + + /** `SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?` */ + async findByOidcIdentity(sub: string, issuer: string): Promise { + const user = await this.findOne({ oidc_sub: sub, oidc_issuer: issuer }); + return user ? (toRow(user) as UserRow) : null; + } + + // --------------------------------------------------------------------- + // O5 / O6 — OIDC identity linking + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?` (O5, the + * email-matched/unlinked/verified-email branch; O6, the provider-switch + * branch — identical statement, two call sites, no `updated_at`). + */ + async linkOidcIdentity(id: number, sub: string, issuer: string): Promise { + await this.nativeUpdate({ id }, { oidc_sub: sub, oidc_issuer: issuer }); + } + + // --------------------------------------------------------------------- + // O8 — claims-based role change + // --------------------------------------------------------------------- + + /** `UPDATE users SET role = ? WHERE id = ?` — no `updated_at` (legacy statement). */ + async setRole(id: number, role: string): Promise { + await this.nativeUpdate({ id }, { role }); + } + + // --------------------------------------------------------------------- + // O9 — OIDC avatar sync + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET avatar = ? WHERE id = ?` — no `updated_at`, unlike + * `setAvatar` (UP10/UP13) below, which is the profile-upload path's + * otherwise-identical statement. Two methods, byte-exact parity per + * statement (same rule as `setBackupCodes`/`setBackupCodesAndTouch`). + */ + async setAvatarRaw(id: number, avatar: string | null): Promise { + await this.nativeUpdate({ id }, { avatar }); + } + + // --------------------------------------------------------------------- + // O12 / UP5 — username collision checks + // --------------------------------------------------------------------- + + /** + * `SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ? + * AND COALESCE(is_guest, 0) = 0` (UP5, `updateSettings`'s own-username + * collision check). + * + * Both sides folded by SQLite's own `LOWER()` (`lowerParam` binds the RAW + * value) — program rule 18. + */ + async findIdByUsernameCI(username: string, excludeId: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { [lower(platform, 'username')]: lowerParam(platform, username), id: { $ne: excludeId }, is_guest: 0 }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + /** + * `SELECT id FROM users WHERE LOWER(username) = LOWER(?)` (O12, OIDC + * derived-username collision avoidance — no `excludeId`, no guest filter: + * a genuinely different WHERE than UP5's, so a separate method per D4 + * rather than an optional parameter that would silently change UP5's + * shape. + * + * Both sides folded by SQLite's own `LOWER()` (`lowerParam` binds the RAW + * value) — program rule 18. The only caller sanitises the username to + * `[a-zA-Z0-9_.-]` before calling (`oidc.service.ts`), so this cannot + * diverge in practice today; fixed anyway so the guarantee lives in the + * method, not the caller. + */ + async findIdByUsernameCIAny(username: string): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { [lower(platform, 'username')]: lowerParam(platform, username) }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + // --------------------------------------------------------------------- + // UP1 — API-key columns read (`currentKeys`) + // --------------------------------------------------------------------- + + /** `SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?` */ + async getApiKeyColumns(id: number): Promise { + const row = await this.findOne( + { id }, + { fields: ['role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'] }, + ); + return row + ? { + role: row.role, + maps_api_key: row.maps_api_key ?? null, + openweather_api_key: row.openweather_api_key ?? null, + unsplash_api_key: row.unsplash_api_key ?? null, + amap_api_key: row.amap_api_key ?? null, + } + : null; + } + + // --------------------------------------------------------------------- + // UP2 — single-key (maps) update + // --------------------------------------------------------------------- + + /** `UPDATE users SET maps_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` */ + async updateMapsKey(id: number, encryptedMapsApiKey: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { maps_api_key: encryptedMapsApiKey, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // UP3 — four-key update + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET maps_api_key = ?, openweather_api_key = ?, + * unsplash_api_key = ?, amap_api_key = ?, updated_at = CURRENT_TIMESTAMP + * WHERE id = ?`. The per-column "encrypt the new value or keep the + * current one" branching (a deleted-row race degrading to a 0-row UPDATE, + * not a crash) is the service's, per D4 — this writes exactly the four + * final values it is given. + */ + async updateApiKeys(id: number, keys: ApiKeysPatch): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { ...keys, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // UP4 / UP8 — profile-with-keys re-select + // --------------------------------------------------------------------- + + /** `SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?` */ + async findProfileWithKeys(id: number): Promise { + const row = await this.findOne( + { id }, + { + fields: ['id', 'username', 'email', 'role', 'maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key', 'avatar', 'mfa_enabled'], + }, + ); + return row + ? { + id: row.id, + username: row.username, + email: row.email, + role: row.role, + maps_api_key: row.maps_api_key ?? null, + openweather_api_key: row.openweather_api_key ?? null, + unsplash_api_key: row.unsplash_api_key ?? null, + amap_api_key: row.amap_api_key ?? null, + avatar: row.avatar ?? null, + mfa_enabled: row.mfa_enabled ?? null, + } + : null; + } + + // --------------------------------------------------------------------- + // UP6 — email collision check + // --------------------------------------------------------------------- + + /** + * `SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ? AND COALESCE(is_guest, 0) = 0` + * + * Both sides folded by SQLite's own `LOWER()` (`lowerParam` binds the RAW + * value) — program rule 18. + */ + async findIdByEmailCI(email: string, excludeId: number): Promise { + const platform = this.getEntityManager().getPlatform(); + const row = await this.findOne( + { [lower(platform, 'email')]: lowerParam(platform, email), id: { $ne: excludeId }, is_guest: 0 }, + { fields: ['id'] }, + ); + return row?.id ?? null; + } + + // --------------------------------------------------------------------- + // UP7 — bounded dynamic-column profile patch + // --------------------------------------------------------------------- + + /** + * `` UPDATE users SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP + * WHERE id = ? `` — the SET clause is built at the CALL SITE from a fixed + * set of 6 possible literal column names, never here: `patchProfile` takes + * only the already-decided final columns as a typed partial and writes + * them in ONE `nativeUpdate` (never `assign`+`flush`, which would be a + * second statement shape for the identical intent) — the entity-API + * equivalent of the legacy's dynamic `SET` list, without ever building SQL + * text. `updated_at` is always stamped, matching the legacy: this method's + * only caller (`UserProfileService.updateSettings`) calls it only when at + * least one field changed (`updates.length > 0`), the same guard the + * legacy statement was built under. + */ + async patchProfile(id: number, changes: UserProfilePatch): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { ...changes, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // UP9 / UP12 — avatar read (before cleanup/replace) + // --------------------------------------------------------------------- + + /** `SELECT avatar FROM users WHERE id = ?` */ + async getAvatar(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['avatar'] }); + return row?.avatar ?? null; + } + + // --------------------------------------------------------------------- + // UP10 / UP13 — avatar write + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?` + * (UP10 `saveAvatar` with a filename, UP13 `deleteAvatar` with `NULL` — + * same statement shape, `avatar` nullable either way). + */ + async setAvatar(id: number, avatar: string | null): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { avatar, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // UP11 — post-avatar-write re-select + // --------------------------------------------------------------------- + + /** `SELECT id, username, email, role, avatar FROM users WHERE id = ?` */ + async findProfileBasic(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'avatar'] }); + return row ? { id: row.id, username: row.username, email: row.email, role: row.role, avatar: row.avatar ?? null } : null; + } + + // --------------------------------------------------------------------- + // UP14 — user directory (trip member / contributor pickers) + // --------------------------------------------------------------------- + + /** `SELECT id, username, avatar FROM users WHERE id != ? AND COALESCE(is_guest, 0) = 0 ORDER BY username ASC` */ + async listOthersNonGuest(excludeId: number): Promise { + const rows = await this.find( + { id: { $ne: excludeId }, is_guest: 0 }, + { fields: ['id', 'username', 'avatar'], orderBy: { username: 'asc' } }, + ); + return rows.map((row) => ({ id: row.id, username: row.username, avatar: row.avatar ?? null })); + } + + // --------------------------------------------------------------------- + // UP15 — key-validation probe + // --------------------------------------------------------------------- + + /** `SELECT role, openweather_api_key FROM users WHERE id = ?` */ + async getRoleAndWeatherKey(id: number): Promise<{ role: string; openweather_api_key: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['role', 'openweather_api_key'] }); + return row ? { role: row.role, openweather_api_key: row.openweather_api_key ?? null } : null; + } + + // --------------------------------------------------------------------- + // TM3 — TripMembersService.listMembers's owner row + // --------------------------------------------------------------------- + + /** + * `SELECT id, COALESCE(display_name, username) AS username, email, avatar + * FROM users WHERE id = ?` (`trip-members.service.ts:129`, TM3). The + * legacy statement's own docstring records this COALESCE as a quirk fix on + * top of the 1:1 move (the ORIGINAL raw statement read the bare + * `username`) — kept exactly as the code already stands, not re-litigated. + */ + async findOwnerSummary(id: number): Promise<{ id: number; username: string; email: string; avatar: string | null } | null> { + const platform = this.getEntityManager().getPlatform(); + const row = await this.qb('u') + .select(['u.id', coalesce(platform, 'u.display_name', 'u.username').as('username'), 'u.email', 'u.avatar']) + .where({ id }) + .execute<{ id: number; username: string; email: string; avatar: string | null } | undefined>('get', false); + return row ? { id: row.id, username: row.username, email: row.email, avatar: row.avatar ?? null } : null; + } + + // --------------------------------------------------------------------- + // TM4 — TripMembersService.addMember's invite-target lookup + // --------------------------------------------------------------------- + + /** + * `SELECT id, username, email, avatar FROM users WHERE (email = ? OR + * username = ?) AND COALESCE(is_guest, 0) = 0` (`trip-members.service.ts:142-144`, + * TM4) — case-SENSITIVE (unlike `findByEmailCI`/`findIdByEmailCI` above; + * kept exactly as written, program rule 18 only applies where the legacy + * statement itself folds case), guests excluded so a trip-scoped guest can + * never be resolved (and re-attached to another trip) through the invite + * box. `identifier` is bound to both sides — the caller passes the SAME + * already-trimmed string for both the email and username branch, matching + * the legacy statement's `identifier.trim()` bound twice. `is_guest: 0` is + * exact parity for `COALESCE(is_guest, 0) = 0` (`countNonGuest`'s + * docstring above: the column is `NOT NULL DEFAULT 0`, so no row can store + * `NULL` there). + */ + async findInvitableByEmailOrUsername(identifier: string): Promise<{ id: number; username: string; email: string; avatar: string | null } | null> { + const row = await this.findOne( + { $or: [{ email: identifier }, { username: identifier }], is_guest: 0 }, + { fields: ['id', 'username', 'email', 'avatar'] }, + ); + return row ? { id: row.id, username: row.username, email: row.email, avatar: row.avatar ?? null } : null; + } + + // --------------------------------------------------------------------- + // TM10 — TripMembersService.transferOwnership's new-owner guest check + // --------------------------------------------------------------------- + + /** `SELECT id, email, is_guest FROM users WHERE id = ?` (`trip-members.service.ts:185`, TM10) — guest → 400 `'Cannot transfer ownership to a guest'` (caller's decision). */ + async findIdEmailGuest(id: number): Promise<{ id: number; email: string; is_guest: number } | null> { + const row = await this.findOne({ id }, { fields: ['id', 'email', 'is_guest'] }); + return row ? { id: row.id, email: row.email, is_guest: row.is_guest } : null; + } + + // --------------------------------------------------------------------- + // TM16 — TripMembersService.createGuest (Plan 3c Task 6, security-sensitive) + // --------------------------------------------------------------------- + + /** + * `INSERT INTO users (username, email, password_hash, role, is_guest, + * display_name) VALUES (?, ?, '', 'user', 1, ?)` (`trip-members.service.ts:222-224`, + * TM16) — security-sensitive: creates a credential-less account (#1362). + * `password_hash`/`role`/`is_guest` are fixed literals, never a caller + * input (see `NewGuestUserRow`'s docstring above for why this is its own + * method rather than a widened `insertUser`). Returns the generated id + * only — the legacy caller reads `res.lastInsertRowid` and never re-selects + * the row. + */ + async insertGuest(row: NewGuestUserRow): Promise { + return this.insert({ + username: row.username, + email: row.email, + password_hash: '', + role: 'user', + is_guest: 1, + display_name: row.display_name, + }); + } + + // --------------------------------------------------------------------- + // TM19 — TripMembersService.renameGuest (security-sensitive) + // --------------------------------------------------------------------- + + /** + * `UPDATE users SET display_name = ?, updated_at = CURRENT_TIMESTAMP WHERE + * id = ? AND is_guest = 1` (`trip-members.service.ts:248`, TM19) — + * security-sensitive: the `AND is_guest = 1` is a SECOND guard (belt and + * braces) on top of `TripMembersRepository.isGuestOfTrip`'s trip-scoping + * check that runs before this is ever called (#1362) — pinned by a test + * that this method, called directly on a real non-guest id, updates the + * row count of exactly zero rows. + */ + async renameGuest(id: number, displayName: string): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id, is_guest: 1 }, { display_name: displayName, updated_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // TM20 — TripMembersService.deleteGuest (security-sensitive: account erasure) + // --------------------------------------------------------------------- + + /** + * `DELETE FROM users WHERE id = ? AND is_guest = 1` (`trip-members.service.ts:268`, + * TM20) — security-sensitive: account erasure, the same belt-and-braces + * `is_guest = 1` predicate as `renameGuest` above. Cascades `trip_members` + * and every assignment/budget/packing join via the entity's FK delete + * rules, unchanged by this conversion. + */ + async deleteGuest(id: number): Promise { + await this.nativeDelete({ id, is_guest: 1 }); + } + + // --------------------------------------------------------------------- + // UC11 — account erasure (Plan 3b Task 1 review, "contract gaps" item 2: + // named in the plan's Task 1 bullet, deliberately deferred to Task 5 — + // the ONE pre-authorised UsersRepository addition this task makes). + // --------------------------------------------------------------------- + + /** + * `DELETE FROM users WHERE id = ?` — `UserCleanupService.deleteUserCompletely`'s + * final statement, the root of its 13-table transaction + * (**security-sensitive**: account erasure). Named `deleteById`, not + * `remove`/`delete`: `remove` is `EntityManager`'s (persist-marking, + * needs-a-`flush()`), not `EntityRepository`'s — MikroORM 7.2.1's + * `EntityRepository` has no `remove` method at all (Task 7 review, L1; + * verified against the installed `.d.ts`, not assumed — that is why + * `OauthClientsRepository.remove`/`TagsRepository.remove`/ + * `CategoriesRepository.remove` compile as their own, unrelated methods). + * `deleteById` is chosen for naming consistency with this program's other + * native, immediate deletes (`McpTokensRepository.deleteById`), not to + * avoid a compiler conflict that does not exist. + */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + // --------------------------------------------------------------------- + // Plan 3d Task 5 (`FeedsService`) — additive: the all-trips ICS feed + // token's `users` half (FD5–FD8, FD10; inventory §5). `id`/`token` are + // always real values here — no raw-bind seam on this repository's newest + // methods (`FeedsService`'s callers already resolve `userId` off + // `@CurrentUser()`/the JWT, never off a route param). + // --------------------------------------------------------------------- + + /** FD5 (`feeds.service.ts::getUserToken`, also the probe inside `generateUserToken`) — `SELECT feed_token FROM users WHERE id = ?`. */ + async getFeedToken(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['feed_token'] }); + return row?.feed_token ?? null; + } + + /** + * FD6/FD7/FD8 (`generateUserToken`/`rotateUserToken`/`disableUserToken`) — + * ONE method for the three: `UPDATE users SET feed_token = ? WHERE id = + * ?`, `token: null` for disable. Unlike the trip token's + * `setFeedTokenIfReachable`, there is no reachability predicate to fold in + * here — the legacy statement is scoped by `id` alone (the acting user's + * own row, already resolved from the JWT), so a plain `nativeUpdate` + * suffices. + */ + async setFeedToken(id: number, token: string | null): Promise { + await this.nativeUpdate({ id }, { feed_token: token }); + } + + /** + * FD10 (`feeds.service.ts::buildUserIcs`) — `SELECT id, username FROM + * users WHERE feed_token = ?`. The anonymous all-trips credential lookup — + * same shape as `TripsRepository.findIdByFeedToken` (FD9): `token` is + * untrusted, and `idx_users_feed_token`'s partial UNIQUE index (`WHERE + * feed_token IS NOT NULL`) is what makes a disabled feed's old token 404 + * instead of matching a row whose column happens to be NULL. + */ + async findIdAndUsernameByFeedToken(token: string): Promise<{ id: number; username: string } | undefined> { + const row = await this.findOne({ feed_token: token }, { fields: ['id', 'username'] }); + return row ? { id: row.id, username: row.username } : undefined; + } + + // --------------------------------------------------------------------- + // Plan 3e Task 7 (memories' provider half) — additive: the Immich/Synology + // encrypted-credential statements (IM1-5/SY1-8, inventory §7d, R6). Every + // value stays exactly what the SERVICE (`ImmichService`/`SynologyService`) + // hands this repository — encrypted, plaintext or null, at the same point + // in the pipeline the raw SQL saw it. This repository never calls + // `encrypt_api_key`/`decrypt_api_key`/`maybe_encrypt_api_key` itself. + // --------------------------------------------------------------------- + + /** + * IM1 (`ImmichService.getImmichCredentials`) — `SELECT immich_url, + * immich_api_key, immich_allow_insecure_tls FROM users WHERE id = ?` + * (the TLS switch joined the read with #2475). + */ + async getImmichCredentials( + id: number, + ): Promise<{ immich_url: string | null; immich_api_key: string | null; immich_allow_insecure_tls: number | null } | null> { + const row = await this.findOne({ id }, { fields: ['immich_url', 'immich_api_key', 'immich_allow_insecure_tls'] }); + return row + ? { + immich_url: row.immich_url ?? null, + immich_api_key: row.immich_api_key ?? null, + immich_allow_insecure_tls: row.immich_allow_insecure_tls ?? null, + } + : null; + } + + /** JV1 (`JourneyService.immichAutoUploadEnabled`) — `SELECT immich_auto_upload FROM users WHERE id = ?`. */ + async getImmichAutoUpload(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['immich_auto_upload'] }); + return row?.immich_auto_upload ?? null; + } + + /** + * IM2 (`ImmichService.getConnectionSettings`'s prefs read) — `SELECT + * immich_auto_upload, immich_allow_insecure_tls FROM users WHERE id = ?` + * (the TLS switch joined the read with #2475). + */ + async getImmichConnectionPrefs( + id: number, + ): Promise<{ immich_auto_upload: number | null; immich_allow_insecure_tls: number | null } | null> { + const row = await this.findOne({ id }, { fields: ['immich_auto_upload', 'immich_allow_insecure_tls'] }); + return row + ? { immich_auto_upload: row.immich_auto_upload ?? null, immich_allow_insecure_tls: row.immich_allow_insecure_tls ?? null } + : null; + } + + /** IM3 (`ImmichService.setImmichAutoUpload`) — `UPDATE users SET immich_auto_upload = ? WHERE id = ?`. */ + async setImmichAutoUpload(id: number, enabled: number): Promise { + await this.nativeUpdate({ id }, { immich_auto_upload: enabled }); + } + + /** + * IM4 (`ImmichService.saveImmichSettings`'s URL branch) — `UPDATE users SET + * immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = CASE WHEN + * immich_url IS ? THEN COALESCE(?, immich_allow_insecure_tls) ELSE + * COALESCE(?, 0) END WHERE id = ?` (#2475). + * + * One statement, as the legacy write was: SET expressions read the row as + * it was, so the CASE compares the STORED url with the new one and decides + * the switch inside the UPDATE itself (`coalesceOverrideWhileSame`). + * `allow_insecure_tls` null keeps the stored choice while the URL stays the + * same, and a new URL without an explicit value starts off. + */ + async setImmichSettings( + id: number, + immich_url: string, + immich_api_key: string | null, + allow_insecure_tls: number | null, + ): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate( + { id }, + { + immich_url, + immich_api_key, + immich_allow_insecure_tls: coalesceOverrideWhileSame(platform, allow_insecure_tls, 'immich_allow_insecure_tls', 'immich_url', immich_url), + }, + ); + } + + /** + * IM5 (`ImmichService.saveImmichSettings`'s disconnect branch) — `UPDATE + * users SET immich_url = ?, immich_api_key = ?, immich_allow_insecure_tls = 0 + * WHERE id = ?` with a `null` URL: disconnecting always turns the switch off. + */ + async clearImmichSettings(id: number, immich_api_key: string | null): Promise { + await this.nativeUpdate({ id }, { immich_url: null, immich_api_key, immich_allow_insecure_tls: 0 }); + } + + /** + * SY1 (`SynologyService._readSynologyUser`) — `SELECT synology_url, + * synology_username, synology_password, synology_sid, synology_did, + * synology_skip_ssl FROM users WHERE id = ?`, column-filtered in JS by the + * legacy code. This method selects only the requested columns (MikroORM's + * typed `fields`) rather than all six and filtering after — the RESULT is + * identical (only the requested keys populated, matching the legacy + * `filtered` object exactly; an unrequested key is `undefined`, same as + * the legacy object never having had it set). `null` means no such user + * row (SY1's "User not found" branch). + * + * Explicit per-column `if` guards, not a generic `Pick` + + * indexed-assignment loop: MikroORM's `fields` option types against + * `AutoPath`, which does not resolve for a free type + * parameter `K` the way it does for the concrete literal union used here + * (the same reason `getApiKeyColumn` above stays non-generic); an + * indexed-assignment loop over a union key has the identical problem on + * the write side. Six `if`s reads worse but needs neither a generic nor a + * cast. + */ + async getSynologyFields(id: number, columns: SynologyUserColumn[]): Promise { + const row = await this.findOne({ id }, { fields: columns }); + if (!row) return null; + const result: SynologyFieldsRow = {}; + if (columns.includes('synology_url')) result.synology_url = row.synology_url ?? null; + if (columns.includes('synology_username')) result.synology_username = row.synology_username ?? null; + if (columns.includes('synology_password')) result.synology_password = row.synology_password ?? null; + if (columns.includes('synology_sid')) result.synology_sid = row.synology_sid ?? null; + if (columns.includes('synology_did')) result.synology_did = row.synology_did ?? null; + if (columns.includes('synology_skip_ssl')) result.synology_skip_ssl = row.synology_skip_ssl; + return result; + } + + /** SY2 (`SynologyService._clearSynologySID`) — `UPDATE users SET synology_sid = NULL WHERE id = ?`. */ + async clearSynologySID(id: number): Promise { + await this.nativeUpdate({ id }, { synology_sid: null }); + } + + /** SY3 (`SynologyService._clearSynologySession`) — `UPDATE users SET synology_sid = NULL, synology_did = NULL WHERE id = ?`. */ + async clearSynologySession(id: number): Promise { + await this.nativeUpdate({ id }, { synology_sid: null, synology_did: null }); + } + + /** + * SY4/SY7 (`SynologyService._getSynologySession`'s session-refresh write, + * `testSynologyConnection`'s sid persist) — byte-identical `UPDATE users + * SET synology_sid = ? WHERE id = ?` at both legacy sites, one method (D4). + */ + async setSynologySid(id: number, synology_sid: string): Promise { + await this.nativeUpdate({ id }, { synology_sid }); + } + + /** SY5 (`SynologyService.updateSynologySettings`) — `UPDATE users SET synology_url = ?, synology_username = ?, synology_password = ?, synology_skip_ssl = ? WHERE id = ?`. */ + async setSynologySettings(id: number, synology_url: string, synology_username: string, synology_password: string | null, synology_skip_ssl: number): Promise { + await this.nativeUpdate({ id }, { synology_url, synology_username, synology_password, synology_skip_ssl }); + } + + /** SY6 (`SynologyService.getSynologyStatus`) — `SELECT synology_username FROM users WHERE id = ?`. */ + async getSynologyUsername(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['synology_username'] }); + return row?.synology_username ?? null; + } + + /** SY8 (`SynologyService.testSynologyConnection`'s did persist) — `UPDATE users SET synology_did = ? WHERE id = ?`. */ + async setSynologyDid(id: number, synology_did: string): Promise { + await this.nativeUpdate({ id }, { synology_did }); + } + + // --------------------------------------------------------------------- + // UM11 — UnifiedMemoriesService._notifySharedTripPhotos + // --------------------------------------------------------------------- + + /** UM11 — `SELECT username, email FROM users WHERE id = ?`, the shared-photos notification's actor lookup. */ + async findUsernameEmail(id: number): Promise<{ username: string; email: string } | undefined> { + const row = await this.findOne({ id }, { fields: ['username', 'email'] }); + return row ? { username: row.username, email: row.email } : undefined; + } + + // --------------------------------------------------------------------- + // Plan 3f Task 4 (MS2 — mailer.service.ts#getUserEmail) — additive. + // --------------------------------------------------------------------- + + /** + * MS2 — `SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0` + * — defense-in-depth (#1362 class): a guest's synthetic email must never be + * emailed. `is_guest: 0` is exact parity for the `COALESCE(is_guest, 0) = 0` + * guard (`countNonGuest`'s docstring above: the column is `NOT NULL DEFAULT + * 0`, so no row can store `NULL` there). + */ + async getEmailNonGuest(id: number): Promise { + const row = await this.findOne({ id, is_guest: 0 }, { fields: ['email'] }); + return row?.email ?? null; + } + + // --------------------------------------------------------------------- + // Plan 3h Task 2 (`CollectionsService.sendInvite`) — additive. + // --------------------------------------------------------------------- + + /** CL79 (`sendInvite`) — `SELECT id, username FROM users WHERE id = ?`. */ + async findIdUsername(id: number): Promise<{ id: number; username: string } | null> { + const row = await this.findOne({ id }, { fields: ['id', 'username'] }); + return row ? { id: row.id, username: row.username } : null; + } + + // --------------------------------------------------------------------- + // Plan 3h Task 4 (`AirtrailService` — R7, the encrypt-in-service + // discipline identical to Task 3's `DawarichConnectionsRepository`: + // every value below is exactly what the SERVICE hands this repository — + // encrypted, plaintext or null, at the same point in the pipeline the + // raw SQL saw it. This repository never calls + // maybe_encrypt_api_key/decrypt_api_key itself. + // --------------------------------------------------------------------- + + /** ATC1 (`airtrail.service.ts#readRow`) — `SELECT airtrail_url, airtrail_api_key, airtrail_allow_insecure_tls, airtrail_write_enabled FROM users WHERE id = ?`. */ + async getAirtrailConnRow(id: number): Promise<{ + airtrail_url: string | null; + airtrail_api_key: string | null; + airtrail_allow_insecure_tls: number | null; + airtrail_write_enabled: number | null; + } | null> { + const row = await this.findOne( + { id }, + { fields: ['airtrail_url', 'airtrail_api_key', 'airtrail_allow_insecure_tls', 'airtrail_write_enabled'] }, + ); + return row + ? { + airtrail_url: row.airtrail_url ?? null, + airtrail_api_key: row.airtrail_api_key ?? null, + airtrail_allow_insecure_tls: row.airtrail_allow_insecure_tls ?? null, + airtrail_write_enabled: row.airtrail_write_enabled ?? null, + } + : null; + } + + /** ATC2 (`airtrail.service.ts#isAirtrailWriteEnabled`) — `SELECT airtrail_write_enabled FROM users WHERE id = ?`. */ + async getAirtrailWriteEnabled(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['airtrail_write_enabled'] }); + return row?.airtrail_write_enabled ?? null; + } + + /** + * ATC3 (`airtrail.service.ts#saveSettings`, newKey branch) — `UPDATE users + * SET airtrail_url = ?, airtrail_api_key = ?, airtrail_allow_insecure_tls + * = ?, airtrail_write_enabled = ? WHERE id = ?`. **No transaction** — the + * plan's Risks section flags this as a pre-existing asymmetry with + * `DawarichConnectionsRepository`'s equivalent `saveSettings` (which DOES + * wrap its writes in `uow.transactional`): preserved exactly, not + * "fixed" to match Dawarich's shape. + */ + async setAirtrailSettingsWithKey(id: number, url: string | null, apiKey: string, allowInsecureTls: number, writeEnabled: number): Promise { + await this.nativeUpdate( + { id }, + { airtrail_url: url, airtrail_api_key: apiKey, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled }, + ); + } + + /** + * ATC4 (`airtrail.service.ts#saveSettings`, no-newKey branch) — `UPDATE + * users SET airtrail_url = ?, airtrail_allow_insecure_tls = ?, + * airtrail_write_enabled = ? WHERE id = ?`. Same no-transaction asymmetry + * as {@link setAirtrailSettingsWithKey} — preserved, not fixed. + */ + async setAirtrailSettings(id: number, url: string | null, allowInsecureTls: number, writeEnabled: number): Promise { + await this.nativeUpdate({ id }, { airtrail_url: url, airtrail_allow_insecure_tls: allowInsecureTls, airtrail_write_enabled: writeEnabled }); + } + + /** + * ATC5 (`airtrail.service.ts#saveSettings`, URL-cleared branch) — `UPDATE + * users SET airtrail_api_key = NULL WHERE id = ?`. **SECURITY**: + * credential scrub — a cleared URL with no key left makes the connection + * meaningless, so the key is dropped too. Same no-transaction asymmetry + * as the two methods above — this is a THIRD, separate statement in the + * legacy `saveSettings` (its own `if (!trimmedUrl)` branch), not folded + * into {@link setAirtrailSettings}. + */ + async clearAirtrailApiKey(id: number): Promise { + await this.nativeUpdate({ id }, { airtrail_api_key: null }); + } + + // --------------------------------------------------------------------- + // Plan 3i Task 1 (`AdminService` — AD1-AD17, admin's user-CRUD surface). + // Every statement not already covered by an existing method above + // (`findById` = AD6, `getRole` = AD9, `countAdmins` = AD10, `disableMfa` + // = AD17, `findIdAndEmail` = AD15 — all reused as-is, no new method). + // --------------------------------------------------------------------- + + /** + * AD1 (`listUsers`) — `SELECT id, username, email, role, avatar, + * created_at, updated_at, last_login FROM users WHERE COALESCE(is_guest, + * 0) = 0 ORDER BY created_at DESC`. `is_guest: 0` is exact parity for the + * COALESCE guard (same reasoning as `countNonGuest` above — the column is + * `NOT NULL DEFAULT 0`). + */ + async listForAdmin(): Promise { + const rows = await this.find( + { is_guest: 0 }, + { + fields: ['id', 'username', 'email', 'role', 'avatar', 'created_at', 'updated_at', 'last_login'], + orderBy: { created_at: 'desc' }, + }, + ); + return rows.map((row) => ({ + id: row.id, + username: row.username, + email: row.email, + role: row.role, + avatar: row.avatar ?? null, + created_at: row.created_at ?? null, + updated_at: row.updated_at ?? null, + last_login: row.last_login ?? null, + })); + } + + /** + * AD2 (`createUser`) — `SELECT id FROM users WHERE username = ? AND + * COALESCE(is_guest, 0) = 0`. Case-**sensitive** (plain equality, no + * `LOWER()`) — distinct from `findIdByUsernameCIAny`'s folded comparison, + * which is a genuinely different legacy statement. + */ + async findIdByUsernameExact(username: string): Promise { + const row = await this.findOne({ username, is_guest: 0 }, { fields: ['id'] }); + return row?.id ?? null; + } + + /** AD3 (`createUser`) — `SELECT id FROM users WHERE email = ? AND COALESCE(is_guest, 0) = 0`. Case-sensitive, same reasoning as {@link findIdByUsernameExact}. */ + async findIdByEmailExact(email: string): Promise { + const row = await this.findOne({ email, is_guest: 0 }, { fields: ['id'] }); + return row?.id ?? null; + } + + /** + * AD4 (`createUser`'s write) — `INSERT INTO users (username, email, + * password_hash, role) VALUES (?, ?, ?, ?)`. Only these four columns are + * named, matching the legacy statement's own column list exactly; every + * other column (`first_seen_version`, `login_count`, …) is left to the + * entity's own default the same way the legacy INSERT left them to the + * schema's `DEFAULT` — not reused from {@link insertUser} (AU10/O14), + * whose `NewUserRow` requires `first_seen_version` as a caller-supplied + * value, a column this statement never named at all. Returns the + * generated id only (no re-select) — AD5 is a separate, distinct + * statement handled by {@link findAdminSummary}. + */ + async insertAdminCreatedUser(row: NewAdminUserRow): Promise { + return await this.insert({ + username: row.username, + email: row.email, + password_hash: row.password_hash, + role: row.role, + }); + } + + /** + * AD5/AD14 (`createUser`'s post-insert re-select, `updateUser`'s + * post-update re-select) — `SELECT id, username, email, role, created_at, + * updated_at FROM users WHERE id = ?`, byte-identical text at both sites, + * one method (D4). + */ + async findAdminSummary(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['id', 'username', 'email', 'role', 'created_at', 'updated_at'] }); + return row + ? { id: row.id, username: row.username, email: row.email, role: row.role, created_at: row.created_at ?? null, updated_at: row.updated_at ?? null } + : null; + } + + /** + * AD7 (`updateUser`) — `SELECT id FROM users WHERE username = ? AND id != + * ? AND COALESCE(is_guest, 0) = 0`. Case-**sensitive**, excludes the + * user's own row — distinct from `findIdByUsernameCI`'s `LOWER()`-folded, + * otherwise identically-shaped statement (UP5's own-username check). + */ + async findIdByUsernameExactExcluding(username: string, excludeId: number): Promise { + const row = await this.findOne({ username, id: { $ne: excludeId }, is_guest: 0 }, { fields: ['id'] }); + return row?.id ?? null; + } + + /** AD8 (`updateUser`) — `SELECT id FROM users WHERE email = ? AND id != ? AND COALESCE(is_guest, 0) = 0`. Case-sensitive, excludes self, same reasoning as {@link findIdByUsernameExactExcluding}. */ + async findIdByEmailExactExcluding(email: string, excludeId: number): Promise { + const row = await this.findOne({ email, id: { $ne: excludeId }, is_guest: 0 }, { fields: ['id'] }); + return row?.id ?? null; + } + + /** + * AD11 (`updateUser`, inside `uow.transactional` alongside AD12/AD13) — + * `UPDATE users SET username=COALESCE(?,username), email=COALESCE(?,email), + * role=COALESCE(?,role), password_hash=COALESCE(?,password_hash), + * password_version=COALESCE(?,password_version), updated_at=CURRENT_TIMESTAMP + * WHERE id=?`. The legacy binds a NULL parameter for "no change" — a + * `nativeUpdate` that simply omits an unchanged key from `patch` has the + * identical net effect (coalesceOverride: `COALESCE(?, column)` — the new + * value wins when the caller supplies a key, the existing column wins + * when it is left out; `coalesceParam`'s `COALESCE(column, ?)` is the + * opposite direction — see `dialect/sql-functions.ts`) — + * the caller decides which keys to include, the same discipline + * `patchProfile` (UP7) already uses for this table. `updated_at` is + * ALWAYS stamped, even when `patch` is `{}` — matching the legacy + * statement's own unconditional `updated_at = CURRENT_TIMESTAMP` (every + * `updateUser` call runs this UPDATE regardless of what changed). + */ + async applyAdminEdit(id: number, patch: AdminEditPatch): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { ...patch, updated_at: currentTimestamp(platform) }); + } + + /** + * AD16 (`resetUserMfa`'s pre-write read) — `SELECT id, email, mfa_enabled + * FROM users WHERE id = ?`. Distinct from `getMfaEnabled` (MP2/AU23/AU27), + * which selects only `mfa_enabled` for a different caller. + */ + async findIdEmailMfaEnabled(id: number): Promise { + const row = await this.findOne({ id }, { fields: ['id', 'email', 'mfa_enabled'] }); + return row ? { id: row.id, email: row.email, mfa_enabled: row.mfa_enabled ?? null } : null; + } + + // --------------------------------------------------------------------- + // HR1 (Plan 3j Task 5, `plugins/host/rpc/host-surface.rpc.ts#getUser`) + // --------------------------------------------------------------------- + + /** + * HR1 — `SELECT id, username, display_name, avatar FROM users WHERE id = + * ?`. Returned RAW to the calling plugin (the RPC handler's own access + * gate runs first — self, or `TripsRepository.sharesTripWith`) — this + * repository method itself does no shaping, matching `findProfileBasic`'s + * narrower-but-similarly-shaped precedent above. + */ + async findPublicIdentity(id: number): Promise<{ id: number; username: string; display_name: string | null; avatar: string | null } | null> { + const row = await this.findOne({ id }, { fields: ['id', 'username', 'display_name', 'avatar'] }); + return row ? { id: row.id, username: row.username, display_name: row.display_name ?? null, avatar: row.avatar ?? null } : null; + } +} + +/** + * The six Synology credential/session columns `_readSynologyUser` reads + * selectively — a closed union, not a dynamic column string, matching + * `InstanceApiKeyName`'s reasoning above. + */ +export type SynologyUserColumn = 'synology_url' | 'synology_username' | 'synology_password' | 'synology_sid' | 'synology_did' | 'synology_skip_ssl'; + +/** + * {@link UsersRepository.getSynologyFields}'s return shape — structurally + * identical to `synology.service.ts`'s own `SynologyUserRecord` (every field + * optional, so an unrequested column is simply absent), deliberately + * defined here rather than imported from the service: a repository does not + * depend on a domain service's types, and the two stay structurally + * assignable without either side casting. + */ +export interface SynologyFieldsRow { + synology_url?: string | null; + synology_username?: string | null; + synology_password?: string | null; + synology_sid?: string | null; + synology_did?: string | null; + synology_skip_ssl?: number | null; +} diff --git a/server/src/db/repositories/VacayCompanyHolidays.repository.ts b/server/src/db/repositories/VacayCompanyHolidays.repository.ts new file mode 100644 index 000000000..3b4f2e62f --- /dev/null +++ b/server/src/db/repositories/VacayCompanyHolidays.repository.ts @@ -0,0 +1,72 @@ +import type { VacayCompanyHolidays } from '../entities/VacayCompanyHolidays.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class VacayCompanyHolidaysRepository extends TrekRepository { + /** + * VC29/VC67 — `SELECT date, fraction FROM vacay_company_holidays WHERE + * plan_id = ?` (`updatePlan`'s make-room-on-enable, narrower columns) and + * `SELECT date, note, fraction FROM vacay_company_holidays WHERE plan_id = ?` + * (`dissolvePlan`'s migration read, wider columns). One method returning all + * three columns — `updatePlan` destructures only `date`/`fraction` off the + * result, same rows either way. `fraction` is the half-holiday size (#2439). + */ + async listForPlan(planId: number): Promise<{ date: string; note: string | null; fraction: number }[]> { + const rows = await this.find({ plan: planId }, { fields: ['date', 'note', 'fraction'] }); + return rows.map((row) => ({ date: row.date, note: row.note ?? null, fraction: row.fraction })); + } + + /** VC93/VC112 — `SELECT date, fraction 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; fraction: number }[]> { + const rows = await this.find({ plan: planId, date: { $gte: start, $lt: end } }, { fields: ['date', 'fraction'], orderBy: { date: 'asc' } }); + return rows.map((row) => ({ date: row.date, fraction: row.fraction })); + } + + /** 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; fraction: number }[]> { + 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, fraction: row.fraction })); + } + + /** VC24-sibling: VC25 — `DELETE FROM vacay_company_holidays WHERE plan_id = ? AND date = ?` (`applyHolidayCalendars`'s auto-clear). */ + async deleteForPlanAndDate(planId: number, date: string): Promise { + 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 { + await this.nativeDelete({ plan: planId, date: { $gte: start, $lt: end } }); + } + + /** VC70/VC73 — `INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note, fraction) VALUES (?, ?, ?, ?)` (`dissolvePlan`'s two migration branches — identical statement). */ + async insertIgnore(planId: number, date: string, note: string, fraction: number): Promise { + await this.upsert({ plan: planId, date, note, fraction }, { onConflictFields: ['plan', 'date'], onConflictAction: 'ignore' }); + } + + /** + * VC118/VC132 — `SELECT id, fraction FROM vacay_company_holidays WHERE + * plan_id = ? AND date = ?` (`toggleCompanyHoliday`'s existing-row read) and + * `SELECT fraction FROM vacay_company_holidays WHERE plan_id = ? AND date = ?` + * (`toggleEntry`'s half-company-holiday read, #2439). Named + * `findByPlanAndDate`, not `find` — `EntityRepository#find` already exists + * with an incompatible signature. + */ + async findByPlanAndDate(planId: number, date: string): Promise<{ id: number; fraction: number } | null> { + const row = await this.findOne({ plan: planId, date }, { fields: ['id', 'fraction'] }); + return row ? { id: row.id, fraction: row.fraction } : null; + } + + /** VC133 — `UPDATE vacay_company_holidays SET fraction = ? WHERE id = ?` (`toggleCompanyHoliday`'s whole/half conversion, #2439). */ + async updateFraction(id: number, fraction: number): Promise { + await this.nativeUpdate({ id }, { fraction }); + } + + /** VC119 — `DELETE FROM vacay_company_holidays WHERE id = ?` (`toggleCompanyHoliday`'s clear-on-repeat-click). */ + async deleteById(id: number): Promise { + await this.nativeDelete({ id }); + } + + /** VC120 — `INSERT INTO vacay_company_holidays (plan_id, date, note, fraction) VALUES (?, ?, ?, ?)` (`toggleCompanyHoliday`'s new row). */ + async insertHoliday(planId: number, date: string, note: string, fraction: number): Promise { + await this.insert({ plan: planId, date, note, fraction }); + } +} diff --git a/server/src/db/repositories/VacayEntries.repository.ts b/server/src/db/repositories/VacayEntries.repository.ts new file mode 100644 index 000000000..8476dcc0b --- /dev/null +++ b/server/src/db/repositories/VacayEntries.repository.ts @@ -0,0 +1,213 @@ +import { dateAdd } from '../dialect/sql-functions'; +import type { VacayEntries } from '../entities/VacayEntries.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +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(, '+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 { + /** + * 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 { + const row = await this.kysely() + .selectFrom('vacay_entries') + .select((eb) => eb.fn.coalesce(eb.fn.sum(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(); + // Plan 4 Task 8b: both `row?.` AND `?? 0` are unreachable here — an + // unqualified, ungrouped `COALESCE(SUM(...), 0)` always returns exactly + // one row with a non-null value (the `COALESCE` already resolves the + // "no matching rows" case to `0` at the SQL level). Kept for type-shape + // symmetry with `executeTakeFirst()`'s `| undefined` return type, same + // accepted class as `OauthClients:147`. + 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 { + const row = await this.kysely() + .selectFrom('vacay_entries') + .select((eb) => eb.fn.coalesce(eb.fn.sum('fraction'), eb.val(0)).as('used')) + .where('user_id', '=', userId) + .where('plan_id', '=', planId) + .where('date', '>=', start) + .where('date', '<', end) + .where('kind', '=', 'comp') + .executeTakeFirst(); + // Plan 4 Task 8b: same unreachable `row?.`/`?? 0` shape as {@link + // sumFraction} above — `COALESCE(SUM(...), 0)` never returns a NULL + // value or a missing row for an unqualified, ungrouped aggregate. + 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 { + const platform = this.getEntityManager().getPlatform(); + // M1 (task-7-review.md): legacy's single `UPDATE OR IGNORE` walks rows in + // rowid order; without an explicit orderBy here, SQLite returns rows in + // (user, plan, date) index order instead, so the per-row collision check + // above skips different rows than legacy did on consecutive days inserted + // out of id order. + const rows = await this.find({ plan: planId, user: userId, date: { $gte: start, $lte: end } }, { fields: ['id', 'date'], orderBy: { id: 'asc' } }); + 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, and `makeRoomForCompanyHoliday`'s whole-holiday clear, reached from `updatePlan`'s company-holiday enable and `toggleCompanyHoliday`). */ + async deleteForPlanAndDate(planId: number, date: string): Promise { + await this.nativeDelete({ plan: planId, date }); + } + + /** VC134 — `UPDATE vacay_entries SET fraction = 0.5 WHERE plan_id = ? AND date = ? AND fraction > 0.5` (`makeRoomForCompanyHoliday`: a half company holiday halves a whole leave day, #2439). */ + async halveForPlanAndDate(planId: number, date: string): Promise { + await this.nativeUpdate({ plan: planId, date, fraction: { $gt: 0.5 } }, { fraction: 0.5 }); + } + + /** 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 { + 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() + .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 { + 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 { + 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() + .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 { + 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 { + 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 { + await this.insert({ plan: planId, user: userId, date, note, fraction, kind }); + } +} diff --git a/server/src/db/repositories/VacayHolidayCalendars.repository.ts b/server/src/db/repositories/VacayHolidayCalendars.repository.ts new file mode 100644 index 000000000..3ffe6d2e5 --- /dev/null +++ b/server/src/db/repositories/VacayHolidayCalendars.repository.ts @@ -0,0 +1,127 @@ +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 = 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`) 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 { + /** + * SH14 (`deleteRegion`'s cross-domain integrity guard, the reverse + * direction of vacay's own VC45) — `SELECT id FROM vacay_holiday_calendars + * WHERE type = 'school_holiday' AND region = ? LIMIT 1`. `region` is a + * plain `.text()` column with no FK/relation to `SchoolHolidayRegions` at + * all (§14/§15.9 of the plan's inventory, verified against both entities + * directly, not assumed) — it stores the synthesized `-MANUAL- + * ` code as a free-form string, matched by VALUE, so this stays a + * plain `WHERE` filter on two scalar columns, never a joined relation. + */ + async existsForSchoolRegion(region: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.nativeDelete({ id }); + } +} diff --git a/server/src/db/repositories/VacayPlanMembers.repository.ts b/server/src/db/repositories/VacayPlanMembers.repository.ts new file mode 100644 index 000000000..c85fbdb37 --- /dev/null +++ b/server/src/db/repositories/VacayPlanMembers.repository.ts @@ -0,0 +1,190 @@ +import type { VacayPlanMembers } from '../entities/VacayPlanMembers.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** 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 = 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 { + /** + * 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 { + return this.qb('m') + .join('m.user', 'u') + .select(['u.id', 'u.username', 'u.email']) + .where({ 'm.plan': planId, 'm.status': 'accepted' }) + .execute('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 { + 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() + .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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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('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 { + 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('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 { + 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() + .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(); + } +} diff --git a/server/src/db/repositories/VacayPlans.repository.ts b/server/src/db/repositories/VacayPlans.repository.ts new file mode 100644 index 000000000..7471c0a99 --- /dev/null +++ b/server/src/db/repositories/VacayPlans.repository.ts @@ -0,0 +1,123 @@ +import type { VacayPlans } from '../entities/VacayPlans.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * 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 = 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 { + /** 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 { + 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 { + 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 { + 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 { + 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 { + 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().selectFrom('users').select(['id', 'username', 'email']).where('id', '=', id).executeTakeFirst(); + return row ?? null; + } +} diff --git a/server/src/db/repositories/VacayShares.repository.ts b/server/src/db/repositories/VacayShares.repository.ts new file mode 100644 index 000000000..77fa1d5b2 --- /dev/null +++ b/server/src/db/repositories/VacayShares.repository.ts @@ -0,0 +1,180 @@ +import type { VacayShares } from '../entities/VacayShares.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** 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 = 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 { + /** 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 { + interface UsersKyselyDB { + users: { id: number; is_guest: number | null }; + } + const row = await this.kysely() + .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 { + 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('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 { + 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('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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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() + .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() + .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(); + } +} diff --git a/server/src/db/repositories/VacayUserColors.repository.ts b/server/src/db/repositories/VacayUserColors.repository.ts new file mode 100644 index 000000000..4f97d1c00 --- /dev/null +++ b/server/src/db/repositories/VacayUserColors.repository.ts @@ -0,0 +1,52 @@ +import type { VacayUserColors } from '../entities/VacayUserColors.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class VacayUserColorsRepository extends TrekRepository { + /** 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 | null): Promise { + 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 { + await this.upsert( + { user: userId, plan: planId, color }, + { onConflictFields: ['user', 'plan'], onConflictAction: 'merge', onConflictMergeFields: ['color'] }, + ); + } +} diff --git a/server/src/db/repositories/VacayUserSettings.repository.ts b/server/src/db/repositories/VacayUserSettings.repository.ts new file mode 100644 index 000000000..a2d880baa --- /dev/null +++ b/server/src/db/repositories/VacayUserSettings.repository.ts @@ -0,0 +1,48 @@ +import type { VacayUserSettings } from '../entities/VacayUserSettings.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** 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 = true; + +export class VacayUserSettingsRepository extends TrekRepository { + /** + * 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 { + 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 { + await this.upsert( + { user: userId, year_type: yearType, year_start_month: yearStartMonth, year_start_day: yearStartDay, hire_date: hireDate }, + { onConflictFields: ['user'], onConflictAction: 'merge' }, + ); + } +} diff --git a/server/src/db/repositories/VacayUserYears.repository.ts b/server/src/db/repositories/VacayUserYears.repository.ts new file mode 100644 index 000000000..590f260e1 --- /dev/null +++ b/server/src/db/repositories/VacayUserYears.repository.ts @@ -0,0 +1,104 @@ +import type { VacayUserYears } from '../entities/VacayUserYears.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { TrekRepository } from './_shared/trek-repository'; + +/** 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 = true; + +export class VacayUserYearsRepository extends TrekRepository { + /** + * 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 { + 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 { + 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 | null, carriedOver: number | null): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.nativeUpdate({ user: userId, plan: planId, year }, { carried_over: carriedOver }); + } +} diff --git a/server/src/db/repositories/VacayYears.repository.ts b/server/src/db/repositories/VacayYears.repository.ts new file mode 100644 index 000000000..035420b85 --- /dev/null +++ b/server/src/db/repositories/VacayYears.repository.ts @@ -0,0 +1,55 @@ +import type { VacayYears } from '../entities/VacayYears.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +export class VacayYearsRepository extends TrekRepository { + /** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + await this.nativeDelete({ plan: planId, year }); + } +} diff --git a/server/src/db/repositories/VisitedCountries.repository.ts b/server/src/db/repositories/VisitedCountries.repository.ts new file mode 100644 index 000000000..2f2b35f9b --- /dev/null +++ b/server/src/db/repositories/VisitedCountries.repository.ts @@ -0,0 +1,123 @@ +import type { VisitedCountries } from '../entities/VisitedCountries.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `visited_countries` — every scalar column, `AT10`'s own read shape. */ +export interface VisitedCountryRow { + country_code: string; + created_at: string | null; + source: string; +} + +/** + * Two separate table shapes for the SAME `visited_countries` table, not one + * shared interface: {@link listForUser} SELECTs `created_at`, while + * {@link markVisited}/{@link markFromRegion} INSERT it never — the legacy + * statements' own column lists omit it, leaving the schema's `DEFAULT + * CURRENT_TIMESTAMP` to fill it in. Kysely validates `.values()` against + * exactly the columns its `DB` type parameter declares (it has no notion of + * "this column exists in the real table but is optional here" short of the + * `Generated<>` wrapper, which — unlike every other column type in this + * program — did not resolve `created_at` out of `InsertObject`'s required + * set when tried here); declaring a NARROWER write-only shape that never + * mentions `created_at` at all reproduces the legacy statement's own column + * list exactly, with no wrapper needed. + */ +interface VisitedCountriesReadKyselyDB { + visited_countries: { user_id: number; country_code: string; source: string; created_at: string | null }; +} +interface VisitedCountriesWriteKyselyDB { + visited_countries: { user_id: number; country_code: string; source: string }; +} + +/** + * `visited_countries` — the user's own explicit "I've been here" marks + * (Plan 3f Task 1, atlas). `markVisited` (AT12) needs the `changes > 0` + * signal the legacy `.run(...).changes` gave (whether this call actually + * ADDED the mark, not just re-confirmed one already there, so a caller + * reporting "N countries added" doesn't double-count) — Kysely's + * `InsertResult.numInsertedOrUpdatedRows` is the SQLite `changes` count + * verbatim (`BudgetCategoryOrderRepository`'s own `insertIgnore`-vs-this + * distinction: that one doesn't need the signal, this one does), so this one + * write goes through Kysely rather than `em.upsert` (which has no comparable + * "was this actually new" return). + */ +export class VisitedCountriesRepository extends TrekRepository { + private readDb() { + return this.kysely(); + } + + private writeDb() { + return this.kysely(); + } + + /** AT10 (`listVisitedCountries`) — `SELECT country_code, created_at, source FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC`. */ + async listForUser(userId: number): Promise { + return await this.readDb() + .selectFrom('visited_countries') + .select(['country_code', 'created_at', 'source']) + .where('user_id', '=', userId) + .orderBy('created_at', 'desc') + .execute(); + } + + /** + * AT5/AT7/AT43 (`stats`'s no-trip early return, `stats`'s manual-country + * merge, `getTravelStats`'s manual-country merge — three identical-text + * call sites) — `SELECT country_code FROM visited_countries WHERE user_id + * = ?`. Returns the bare code list; every one of the three callers only + * ever reads `.country_code` off the legacy row shape. + */ + async listCodesForUser(userId: number): Promise { + const rows = await this.writeDb().selectFrom('visited_countries').select('country_code').where('user_id', '=', userId).execute(); + return rows.map((r) => r.country_code); + } + + /** AT8/AT9 (`countryPlaces`'s no-trip early return, `countryPlaces`'s mark lookup — identical text) — `SELECT source FROM visited_countries WHERE user_id = ? AND country_code = ?`. */ + async findSource(userId: number, countryCode: string): Promise<{ source: string } | undefined> { + return await this.writeDb() + .selectFrom('visited_countries') + .select('source') + .where('user_id', '=', userId) + .where('country_code', '=', countryCode) + .executeTakeFirst(); + } + + /** + * AT12 (`markCountry`, inside its transaction) — `INSERT OR IGNORE INTO + * visited_countries (user_id, country_code, source) VALUES (?, ?, ?)`. + * Returns whether this call actually inserted a new row (see class + * docstring) — the legacy `.changes > 0` this repository preserves. + */ + async markVisited(userId: number, countryCode: string, source: 'manual' | 'dawarich'): Promise { + const result = await this.writeDb() + .insertInto('visited_countries') + .values({ user_id: userId, country_code: countryCode, source }) + .onConflict((oc) => oc.doNothing()) + .executeTakeFirst(); + return (result.numInsertedOrUpdatedRows ?? 0n) > 0n; + } + + /** AT14 (`unmarkCountry`, inside its transaction) — `DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?`. */ + async unmark(userId: number, countryCode: string): Promise { + await this.writeDb().deleteFrom('visited_countries').where('user_id', '=', userId).where('country_code', '=', countryCode).execute(); + } + + /** + * AT19 (`markRegion`, inside its transaction) — `INSERT OR IGNORE INTO + * visited_countries (user_id, country_code) VALUES (?, ?)`, a DISTINCT + * 2-column variant of {@link markVisited}'s 3-column insert. The legacy + * statement's column list omits `source`, letting the schema's own + * `DEFAULT 'manual'` fill it — reproduced here by passing that literal + * explicitly (`VisitedCountries.entity.ts`'s `source: p.text() + * .default('manual')`, the exact same value the omitted column would + * have taken). No `changes` signal here; the legacy statement never + * checked it either. + */ + async markFromRegion(userId: number, countryCode: string): Promise { + await this.writeDb() + .insertInto('visited_countries') + .values({ user_id: userId, country_code: countryCode, source: 'manual' }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } +} diff --git a/server/src/db/repositories/VisitedRegions.repository.ts b/server/src/db/repositories/VisitedRegions.repository.ts new file mode 100644 index 000000000..3163b6b0e --- /dev/null +++ b/server/src/db/repositories/VisitedRegions.repository.ts @@ -0,0 +1,94 @@ +import type { VisitedRegions } from '../entities/VisitedRegions.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `visited_regions` — the columns `AT21`'s read needs. */ +export interface VisitedRegionRow { + region_code: string; + region_name: string; + country_code: string; +} + +/** + * Two separate table shapes for the SAME `visited_regions` table (the + * `VisitedCountriesRepository`'s own docstring explains why: {@link + * listForUser} SELECTs `created_at`, `markVisited` INSERTs it never — the + * legacy statement's own column list omits it, leaving the schema's + * `DEFAULT CURRENT_TIMESTAMP` to fill it). + */ +interface VisitedRegionsReadKyselyDB { + visited_regions: { user_id: number; region_code: string; region_name: string; country_code: string; created_at: string | null }; +} +interface VisitedRegionsWriteKyselyDB { + visited_regions: { user_id: number; region_code: string; region_name: string; country_code: string }; +} + +/** + * `visited_regions` — the user's own explicit region-level marks, plus the + * regions `place_regions` derives (Plan 3f Task 1, atlas). `unmarkAllInCountry` + * (AT15, filters on `country_code`) and `unmark` (AT26, filters on + * `region_code`) are DIFFERENT statements over the same table — one deletes + * every region in a country (`unmarkCountry`'s cascade), the other deletes + * one specific region (`unmarkRegion`'s own delete) — never collapsed into + * one method with a flag, matching the plan's own "two genuinely different + * scoped checks stay two methods" ruling (R6) for this table's shape too. + */ +export class VisitedRegionsRepository extends TrekRepository { + private readDb() { + return this.kysely(); + } + + private writeDb() { + return this.kysely(); + } + + /** AT21 (`listManuallyVisitedRegions`) — `SELECT region_code, region_name, country_code FROM visited_regions WHERE user_id = ? ORDER BY created_at DESC`. */ + async listForUser(userId: number): Promise { + return await this.readDb() + .selectFrom('visited_regions') + .select(['region_code', 'region_name', 'country_code']) + .where('user_id', '=', userId) + .orderBy('created_at', 'desc') + .execute(); + } + + /** AT24 (`hasVisibleRegionForCountry`) — `SELECT region_code FROM visited_regions WHERE user_id = ? AND country_code = ?`. */ + async listRegionCodesForCountry(userId: number, countryCode: string): Promise { + const rows = await this.writeDb() + .selectFrom('visited_regions') + .select('region_code') + .where('user_id', '=', userId) + .where('country_code', '=', countryCode) + .execute(); + return rows.map((r) => r.region_code); + } + + /** AT25 (`unmarkRegion`, inside its transaction) — `SELECT country_code FROM visited_regions WHERE user_id = ? AND region_code = ?`. */ + async findCountryCode(userId: number, regionCode: string): Promise { + const row = await this.writeDb() + .selectFrom('visited_regions') + .select('country_code') + .where('user_id', '=', userId) + .where('region_code', '=', regionCode) + .executeTakeFirst(); + return row?.country_code; + } + + /** AT17 (`markRegion`, inside its transaction) — `INSERT OR IGNORE INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)`. */ + async markVisited(userId: number, regionCode: string, regionName: string, countryCode: string): Promise { + await this.writeDb() + .insertInto('visited_regions') + .values({ user_id: userId, region_code: regionCode, region_name: regionName, country_code: countryCode }) + .onConflict((oc) => oc.doNothing()) + .execute(); + } + + /** AT15 (`unmarkCountry`, inside its transaction) — `DELETE FROM visited_regions WHERE user_id = ? AND country_code = ?` (every region in the country). */ + async unmarkAllInCountry(userId: number, countryCode: string): Promise { + await this.writeDb().deleteFrom('visited_regions').where('user_id', '=', userId).where('country_code', '=', countryCode).execute(); + } + + /** AT26 (`unmarkRegion`, inside its transaction) — `DELETE FROM visited_regions WHERE user_id = ? AND region_code = ?` (one region). */ + async unmark(userId: number, regionCode: string): Promise { + await this.writeDb().deleteFrom('visited_regions').where('user_id', '=', userId).where('region_code', '=', regionCode).execute(); + } +} diff --git a/server/src/db/repositories/WebauthnChallenges.repository.ts b/server/src/db/repositories/WebauthnChallenges.repository.ts new file mode 100644 index 000000000..ce52f860b --- /dev/null +++ b/server/src/db/repositories/WebauthnChallenges.repository.ts @@ -0,0 +1,103 @@ +import type { WebauthnChallenges } from '../entities/WebauthnChallenges.entity'; +import { TrekRepository } from './_shared/trek-repository'; + +/** `PasskeyService.storeChallenge` (PK2). */ +export interface NewChallengeRow { + challenge: string; + user_id: number | null; + type: 'registration' | 'authentication'; + expires_at: number; +} + +/** + * The `webauthn_challenges` table shape as `claimChallenge`'s + * `em.getKysely()` call needs it — an explicit type argument rather than + * relying on `getKysely()`'s automatic entity-metadata inference (kept + * narrow to the one column this repository ever reads back). + */ +interface WebauthnChallengesKyselyDB { + webauthn_challenges: { + challenge: string; + user_id: number | null; + type: string; + expires_at: number; + }; +} + +/** The single-use, TTL'd WebAuthn ceremony challenge store — PK1–PK3. */ +export class WebauthnChallengesRepository extends TrekRepository { + /** + * `DELETE FROM webauthn_challenges WHERE expires_at < ?` — best-effort + * sweep run before minting a new challenge (no cron for this table; see + * the inventory's surprise #9). + */ + async purgeExpired(now: number): Promise { + await this.nativeDelete({ expires_at: { $lt: now } }); + } + + /** + * `INSERT INTO webauthn_challenges (challenge, user_id, type, expires_at) + * VALUES (?, ?, ?, ?)`. `EntityRepository.insert()`, not `create()` + + * `persist().flush()` — a native insert with no identity-map/UnitOfWork + * side effects (see `WebauthnCredentialsRepository.insertCredential`'s + * docstring for why `persist().flush()` is the hazard here: it flushes + * the entire request's UnitOfWork, which can write back a stale + * identity-mapped entity from an unrelated earlier read in the same + * request). + */ + async insertChallenge(row: NewChallengeRow): Promise { + await this.insert({ + challenge: row.challenge, + user: row.user_id, + type: row.type, + expires_at: row.expires_at, + }); + } + + /** + * `DELETE FROM webauthn_challenges WHERE challenge = ? AND type = ? AND + * expires_at > ? RETURNING user_id` — the anti-replay primitive: it runs + * BEFORE any async verification, so a concurrent double-submit of the + * same assertion can spend the row exactly once. + * + * Built on `em.getKysely()` (D4's T6 tier), not the QueryBuilder's own + * `.returning()` (the InviteTokensRepository.incrementUsedCount shape): + * verified empirically against the installed MikroORM 7.2.1 that the + * QueryBuilder's `processReturningStatement` (`@mikro-orm/sql`'s + * `QueryBuilder.js`) only ever honours an explicit `.returning()` hint + * for `INSERT`/`UPDATE` — a bare `DELETE` has no `data` in its internal + * state, and the very first branch of that method (`if (!data && + * !this.#state.insertSubQuery) return;`) exits before the "always + * respect explicit returning hint" branch below it is ever reached. A + * `delete().returning([...]).execute('run')` therefore silently drops + * the RETURNING clause: it deletes the row (`affectedRows: 1`) but + * `result.row`/`result.rows` come back empty every time — this is NOT + * the `update().returning()` shape Task 0 proved; it's a different, + * narrower gap the docs don't call out. `em.getKysely()` is bound to the + * ambient transaction the same way the QueryBuilder is (see its own + * doc comment — inside `em.transactional`/`uow.transactional` it reuses + * the open transaction's connection), so D6's single-connection- + * serialises-writers guarantee still holds: no SELECT→await→DELETE + * window for either caller to lose, proven by `WEBAUTHN-CHAL-REPO`'s + * concurrent-claim test asserting exactly one non-null winner. + * + * `.returning(['user_id'])` only (not `.selectAll()`/`returningAll()`), + * matching the legacy statement's own narrower `RETURNING user_id`. + * + * `this.kysely()` (`_shared/trek-repository.ts`, Plan 3b interlude B), not + * `this.getEntityManager().getKysely()` directly: `em.getKysely()` + * resolves its `EntityManager` with `getContext(false)`, the same + * validation gap the base class closes for every other repository path — + * the base's `kysely()` helper validates first, then delegates. + */ + async claimChallenge(challenge: string, type: 'registration' | 'authentication', now: number): Promise<{ user_id: number | null } | null> { + const row = await this.kysely() + .deleteFrom('webauthn_challenges') + .where('challenge', '=', challenge) + .where('type', '=', type) + .where('expires_at', '>', now) + .returning(['user_id']) + .executeTakeFirst(); + return row ?? null; + } +} diff --git a/server/src/db/repositories/WebauthnCredentials.repository.ts b/server/src/db/repositories/WebauthnCredentials.repository.ts new file mode 100644 index 000000000..290144130 --- /dev/null +++ b/server/src/db/repositories/WebauthnCredentials.repository.ts @@ -0,0 +1,262 @@ +import type { WebauthnCredentials } from '../entities/WebauthnCredentials.entity'; +import { toRow, type AssertRowKeys } from './_shared/rows'; +import { currentTimestamp } from '../dialect/sql-functions'; +import { TrekRepository } from './_shared/trek-repository'; + +/** + * A full `webauthn_credentials` row (`SELECT * ...`), as PK9 (`passkeyLoginVerify`) + * needs it — `public_key` stays a `Buffer` end to end: `BlobType` (the entity's + * `p.blob()` column type) has no `toJSON` override, so `EntityTransformer`'s + * default (return the value unchanged) applies and `toRow`'s `wrap().toObject()` + * never touches the bytes. + */ +export interface WebauthnCredentialRow { + id: number; + user_id: number; + credential_id: string; + public_key: Buffer; + counter: number; + transports: string | null; + device_type: string | null; + backed_up: number; + name: string | null; + aaguid: string | null; + created_at: string | null; + last_used_at: string | null; +} + +const _webauthnCredentialRowKeys: AssertRowKeys = true; + +/** `PasskeyService.passkeyRegisterVerify` (PK7) — only verifier-vouched fields. */ +export interface NewCredentialRow { + user_id: number; + credential_id: string; + public_key: Buffer; + counter: number; + transports: string | null; + device_type: string | null; + backed_up: number; + name: string | null; + aaguid: string | null; +} + +/** PK5's `excludeCredentials` shape and PK8/PK13's shared management-panel shape. */ +export interface CredentialTransportsRow { + credential_id: string; + transports: string | null; +} + +/** The column set PK8 (post-registration re-select) and PK13 (`listPasskeys`) both project. */ +export interface PasskeyPanelRow { + id: number; + name: string | null; + device_type: string | null; + backed_up: number; + created_at: string | null; + last_used_at: string | null; +} + +const PANEL_FIELDS = ['id', 'name', 'device_type', 'backed_up', 'created_at', 'last_used_at'] as const; + +function toPanelRow(row: { id: number; name?: string | null; device_type?: string | null; backed_up: number; created_at?: string | null; last_used_at?: string | null }): PasskeyPanelRow { + return { + id: row.id, + name: row.name ?? null, + device_type: row.device_type ?? null, + backed_up: row.backed_up, + created_at: row.created_at ?? null, + last_used_at: row.last_used_at ?? null, + }; +} + +/** + * Plan 3b Task 1 reserved this class and implemented `hasAny` (MP3/AU24 — + * shared with `mfa-policy.guard.ts` and `AuthService.updateAppSettings`). + * Task 3 (`PasskeyService`) adds everything else: the full WebAuthn + * credential-store surface (PK5–PK18, minus PK6's dup check which the + * service still gates the same insert with, race-proofed by the surrounding + * `uow.transactional`). `hasAny` is untouched — nothing here changes its + * name or shape. + */ +export class WebauthnCredentialsRepository extends TrekRepository { + /** + * `SELECT 1 FROM webauthn_credentials WHERE user_id = ? LIMIT 1` (MP3/AU24 + * — identical statement, two callers). `findOne` with a narrow `fields` + * selection mirrors the legacy existence probe without pulling a full + * row; MikroORM's `count()` has no built-in short-circuit, so this is the + * closer match to "does one exist" than `count() > 0`, which would still + * scan/aggregate every matching row. + * + * `disableIdentityMap: true` (every row-out read in this file, applied by + * the base class's default since Plan 3b interlude B — + * `_shared/trek-repository.ts`): this + * method's caller (`mfa-policy.guard.ts`, `AuthService.updateAppSettings`) + * can run in the same request as other `webauthn_credentials`/`users` + * reads and writes; a `findOne` that joins the identity map (even for a + * `fields: ['id']` existence probe) registers a managed entity whose + * unselected columns hold whatever the query returned as undefined/stale + * — if that entity is later merged with a broader projection AND a + * sibling `nativeUpdate` changes a column out of band in the same + * transaction, the closing `flush()` can write the identity-mapped + * entity's stale in-memory snapshot back over the `nativeUpdate`'s write + * (reproduced live: `PUT /api/auth/me/settings` returning 200 while + * discarding the change). `disableIdentityMap: true` returns an isolated, + * unmanaged entity instead — nothing this method reads is ever merged + * into the request's identity map, so it can never be flushed. + */ + async hasAny(userId: number): Promise { + const row = await this.findOne({ user: userId }, { fields: ['id'] }); + return row !== null; + } + + // --------------------------------------------------------------------- + // PK5 — passkeyRegisterOptions' excludeCredentials + // --------------------------------------------------------------------- + + /** `SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?` */ + async listExcludeCredentials(userId: number): Promise { + const rows = await this.find({ user: userId }, { fields: ['credential_id', 'transports'] }); + return rows.map((row) => ({ credential_id: row.credential_id, transports: row.transports ?? null })); + } + + // --------------------------------------------------------------------- + // PK6 — passkeyRegisterVerify's in-transaction duplicate check + // --------------------------------------------------------------------- + + /** + * `SELECT id FROM webauthn_credentials WHERE credential_id = ?` — called + * from inside `uow.transactional` alongside `insertCredential` so the + * check-then-insert can never race a UNIQUE violation past the service's + * `DUPLICATE_CREDENTIAL` sentinel (identity-compared, not string-matched). + * `disableIdentityMap: true` per `hasAny`'s docstring above — this read + * runs inside the same transaction as PK7's write, so keeping it out of + * the identity map matters even more here. + */ + async existsByCredentialId(credentialId: string): Promise { + const row = await this.findOne({ credential_id: credentialId }, { fields: ['id'] }); + return row !== null; + } + + // --------------------------------------------------------------------- + // PK7 — credential enrollment (same transaction as PK6) + // --------------------------------------------------------------------- + + /** + * `INSERT INTO webauthn_credentials (user_id, credential_id, public_key, + * counter, transports, device_type, backed_up, name, aaguid, last_used_at) + * VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)` — `last_used_at` is always + * `NULL` on insert (a fresh credential has never been used yet), same as + * the legacy statement's literal. + * + * `EntityRepository.insert()`, not `create()` + `persist().flush()`: a + * native insert that commits immediately and has no identity-map/UnitOfWork + * side effects, so it can never be part of a stale-snapshot flush the way + * a persisted-and-tracked entity could; `persist().flush()` would also + * flush the ENTIRE request's UnitOfWork, not just this row, which is + * exactly the write-discarding hazard `hasAny`'s docstring describes. + */ + async insertCredential(row: NewCredentialRow): Promise { + await this.insert({ + user: row.user_id, + credential_id: row.credential_id, + public_key: row.public_key, + counter: row.counter, + transports: row.transports, + device_type: row.device_type, + backed_up: row.backed_up, + name: row.name, + aaguid: row.aaguid, + last_used_at: null, + }); + } + + // --------------------------------------------------------------------- + // PK8 — post-registration re-select (after the transaction resolves) + // --------------------------------------------------------------------- + + /** + * `SELECT id, name, device_type, backed_up, created_at, last_used_at + * FROM webauthn_credentials WHERE credential_id = ?` — a DIFFERENT + * column set from PK9's full-row read below, so a separate method (D4: + * mirror each statement's own projection, never one full-row read the + * caller trims down). + */ + async findCreatedCredential(credentialId: string): Promise { + const row = await this.findOne({ credential_id: credentialId }, { fields: [...PANEL_FIELDS] }); + return row ? toPanelRow(row) : null; + } + + // --------------------------------------------------------------------- + // PK9 — login-verify ceremony input (full row, Buffer public_key) + // --------------------------------------------------------------------- + + /** + * `SELECT * FROM webauthn_credentials WHERE credential_id = ?`. + * `disableIdentityMap: true`, not `{ refresh: true }` — this is not a + * primary-key filter (`credential_id` is a unique column, not `id`), and + * per `hasAny`'s docstring, a plain `refresh: true` re-snapshot risks a + * stale-write-back on flush if this same entity is already (or later) + * identity-mapped under a different projection within the same request — + * PK11's `updateCounterAndLastUsed` runs a `nativeUpdate` on this exact + * row moments later, in the same request. + */ + async findByCredentialId(credentialId: string): Promise { + const credential = await this.findOne({ credential_id: credentialId }); + return credential ? (toRow(credential) as WebauthnCredentialRow) : null; + } + + // --------------------------------------------------------------------- + // PK11 — counter/last-used bookkeeping (same transaction as UsersRepository.touchLastLogin) + // --------------------------------------------------------------------- + + /** `UPDATE webauthn_credentials SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?` */ + async updateCounterAndLastUsed(id: number, counter: number): Promise { + const platform = this.getEntityManager().getPlatform(); + await this.nativeUpdate({ id }, { counter, last_used_at: currentTimestamp(platform) }); + } + + // --------------------------------------------------------------------- + // PK13 — settings-panel list + // --------------------------------------------------------------------- + + /** + * `SELECT id, name, device_type, backed_up, created_at, last_used_at + * FROM webauthn_credentials WHERE user_id = ? ORDER BY created_at DESC` + */ + async listForPanel(userId: number): Promise { + const rows = await this.find({ user: userId }, { fields: [...PANEL_FIELDS], orderBy: { created_at: 'desc' } }); + return rows.map((row) => toPanelRow(row)); + } + + // --------------------------------------------------------------------- + // PK14 — rename (ownership in the WHERE) + // --------------------------------------------------------------------- + + /** + * `UPDATE webauthn_credentials SET name = ? WHERE id = ? AND user_id = ?` + * — ownership enforced in the WHERE, not a separate check; returns the + * affected-row count so `renamePasskey` keeps its 404-never-403 behaviour + * (a foreign or unknown id both yield 0, indistinguishable by design). + */ + async renameOwned(id: number, userId: number, name: string): Promise { + return this.nativeUpdate({ id, user: userId }, { name }); + } + + // --------------------------------------------------------------------- + // PK16 — delete (ownership in the WHERE) + // --------------------------------------------------------------------- + + /** `DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?` */ + async deleteOwned(id: number, userId: number): Promise { + return this.nativeDelete({ id, user: userId }); + } + + // --------------------------------------------------------------------- + // PK18 — admin bulk-clear + // --------------------------------------------------------------------- + + /** `DELETE FROM webauthn_credentials WHERE user_id = ?` — returns the deleted count for `adminResetPasskeys`' response. */ + async deleteAllForUser(userId: number): Promise { + return this.nativeDelete({ user: userId }); + } +} diff --git a/server/src/db/repositories/_shared/collection-role.ts b/server/src/db/repositories/_shared/collection-role.ts new file mode 100644 index 000000000..b065a6c7b --- /dev/null +++ b/server/src/db/repositories/_shared/collection-role.ts @@ -0,0 +1,87 @@ +import type { CollectionMembers } from '../../entities/CollectionMembers.entity'; +import type { Collections } from '../../entities/Collections.entity'; +import type { TrekRepository } from './trek-repository'; + +/** + * Plan 3h Task 1, R6 — the collections cluster's own membership/role model, + * as ONE shared typed predicate instead of three ad hoc re-implementations. + * + * Legacy shape (`collections.service.ts` at HEAD, pre-conversion): + * - `isVisible` (CL2) — `SELECT 1 FROM collections WHERE id=? AND + * owner_id=? UNION SELECT 1 FROM collection_members WHERE + * collection_id=? AND user_id=? AND status='accepted' LIMIT 1`. + * - `isOwner` (CL3) — `SELECT 1 FROM collections WHERE id=? AND owner_id=?`. + * - `roleOf` (CL4) — calls `isOwner` first (`'owner'` short-circuit), else + * `SELECT role FROM collection_members WHERE collection_id=? AND + * user_id=? AND status='accepted'`, normalizing an unrecognized/legacy + * role value to `'editor'` (only `'admin'`/`'viewer'` pass through as-is). + * + * `roleOf`'s own legacy body ALREADY resolved `isVisible`/`isOwner` as its + * own first branch — the three functions were never independent, only + * independently re-queried. {@link resolveCollectionRole} is the SAME + * two-step resolution (owner match, else an accepted member row's role), + * built once here: `isVisible` becomes `role !== null`, `isOwner` becomes + * `role === 'owner'`, `roleOf` returns the role directly. + * `assertAccess`/`assertCanEdit`/`assertCanDelete` stay service-level + * orchestration (CollectionsService) — they call this predicate and branch + * on their own three distinct refusal shapes (404 no-access, 403-viewer + * edit-refused, 403-non-admin delete-refused); this file has no knowledge + * of HTTP status codes. + * + * **QB-callable form** ({@link resolveCollectionRole}): takes the caller's + * own `CollectionsRepository`/`CollectionMembersRepository` (both extend + * `TrekRepository`, typed generically here so this file stays independent + * of either repository class — the same reason `_shared/owned-lookup.ts` + * types its helpers against `TrekRepository` rather than a concrete + * repository) and resolves the role via two ordinary `findOne` calls — + * MikroORM's own QueryBuilder-backed API, the "repository methods that need + * it inline" form R6 asks for. This is what `CollectionsService`'s + * `isVisible`/`isOwner`/`roleOf` call directly; Task 2's own + * `assertCanEdit`/`assertCanDelete` call sites throughout the saved-places/ + * copy-to-trip/labels/invites surface go through the SAME service methods, + * never re-deriving the predicate. + * + * A Kysely-callable boolean-`EXISTS`/`OR` twin of this predicate (for a + * caller already building a raw, multi-join statement through + * `this.kysely()`, modelled on `packing-visibility.ts`'s + * `packingVisibleToActorExpr`) was drafted alongside this QB form but had no + * consumer by Plan 3h's whole-plan review — Task 2's own dynamic OR-condition + * methods (`matchingCollectionPlaces`/`findMembership`, CL58/CL69) never + * needed it — and was deleted there (L5) rather than shipped untested. Add it + * back, with a consumer or its own parity test, if a future task needs it. + * + * Proven against five actor cases (owner, admin member, editor member, + * viewer member, an invited-but-still-pending user, a complete stranger) by + * `tests/unit/db/repositories/_shared/collection-role.parity.test.ts`, + * mutation-proved (flip one branch, confirm the matching case goes red) — + * Task 2 imports that harness rather than re-deriving it. + */ +export type CollectionRole = 'owner' | 'admin' | 'editor' | 'viewer' | null; + +/** + * The two-step role resolution CL2/CL3/CL4 all reduce to: an owner match + * short-circuits to `'owner'`; otherwise an accepted member row's own + * `role` column is read and normalized (`'admin'`/`'viewer'` pass through, + * anything else — including a future/unknown value — becomes `'editor'`, + * the legacy `roleOf`'s own fallback); no row of either kind resolves to + * `null` (no access). + * + * `fields: ['id']`/`fields: ['role']` — a narrow projection, like every + * other "rows out, existence/value only" read in this program + * (`findOwnedByUser`'s own docstring). + */ +export async function resolveCollectionRole( + collections: TrekRepository, + members: TrekRepository, + collectionId: number, + userId: number, +): Promise { + const owner = await collections.findOne({ id: collectionId, owner: userId }, { fields: ['id'] }); + if (owner) return 'owner'; + const member = await members.findOne( + { collection: collectionId, user: userId, status: 'accepted' }, + { fields: ['role'] }, + ); + if (!member) return null; + return member.role === 'admin' || member.role === 'viewer' ? (member.role as 'admin' | 'viewer') : 'editor'; +} diff --git a/server/src/db/repositories/_shared/owned-lookup.ts b/server/src/db/repositories/_shared/owned-lookup.ts new file mode 100644 index 000000000..bee215f0a --- /dev/null +++ b/server/src/db/repositories/_shared/owned-lookup.ts @@ -0,0 +1,103 @@ +import type { FilterQuery, FilterValue, OrderDefinition } from '@mikro-orm/core'; +import type { TrekRepository } from './trek-repository'; + +/** + * Shared lookup shapes for entities scoped to a user. + * + * Built for `TagsRepository` (Plan 3a Task 3): `listByUser`/`findByIdAndUser` + * are the same two query shapes every strictly-owned entity needs + * (`tags.user_id` is `NOT NULL`) — list-scoped-to-owner and find-one-by-id + * with an ownership check. `CategoriesRepository` uses neither: its legacy + * `list`/`getById` (`SELECT * FROM categories ORDER BY name ASC` / `SELECT * + * FROM categories WHERE id = ?`) have no owner filter at all, despite + * `categories.user_id` being nullable — changing that would be a behaviour + * change, not a refactor, so it stays plain `find`/`findOne`. + * + * A third helper, `findOwnedOrGlobal` (id match OR a NULL owner column — + * the shape `categories.getById` would need if it were ever scoped), was + * built alongside these two and then deleted: Task 0 re-review flagged that + * no categories or tags site actually filters "owner or global", so it had + * only test coverage and no real caller (R6 — "dead code is worse than a + * missing helper"). If a future entity needs that shape, recover it from + * git history rather than reintroducing it speculatively. + * + * Typed `repo: TrekRepository` (Task 7 review, A-L3/B-L4 — was the core + * `EntityRepository` until this task, the last two places in the + * repository tree still restating `disableIdentityMap: true` per call + * because the type couldn't see `TrekRepository`'s own validation and read + * default): at runtime the argument was always a `TrekRepository` (every + * concrete repository extends it), so the guard always fired — this closed + * a typing gap, not a safety gap. The two per-call `disableIdentityMap: + * true` literals below are gone now that the base class's `find`/`findOne` + * overrides supply the same default. + * + * These helpers return live MikroORM entities, not rows — unlike every other + * repository method in this codebase (D4: "repositories return rows, never a + * live entity"). They are an internal building block for `TagsRepository`, + * which stays the actual public API and must run the result through `toRow` + * before it leaves the repository. + * + * Call-site convention (Task 0 re-review): type arguments are ALWAYS explicit — + * inference from the repository argument alone resolves `T` to the + * `{ id: unknown }` constraint and fails to compile (TS2345) — and the owner + * field is the RELATION property (`'user'`), never the `persist(false)` twin + * (`'user_id'`): both compile and emit the same SQL, but only the relation + * name survives a column rename and reads as the entity's API. `ownerId` is the + * raw FK number. Canonical calls: + * + * listForOwner(tags, 'user', userId, 'name'); + * findOwnedByUser(tags, id, 'user', userId); + * + * Both helpers get `disableIdentityMap: true` from `TrekRepository`'s own + * `find`/`findOne` default now (Plan 3b Task 1 fix round, supersedes Plan + * 3a's I1 "`refresh: true` on every PK-only `findOne`" — see + * `Users.repository.ts`'s class-level docstring and + * `.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md` B1): both are + * "rows out" reads whose entity is converted via `toRow` and discarded, never + * assigned to nor flushed, so a throwaway forked context is exactly right. + * + * Typing (Task 0 review, I2): `T extends { id: unknown }` and `K extends + * keyof T` tie `ownerField` to a real property of `T` and `ownerId` to + * `FilterValue` — the same value shape MikroORM's own `FilterObject` + * accepts for that property (`T[K]` directly is too strict for a relation + * property: `Tags['user']` is `Ref`, but every call site here filters + * it with the raw foreign-key id, which `FilterValue` allows and a bare + * `T[K]` would not). `id`/`ownerId` are no longer `unknown` — a call for an + * entity without an `id` column, or with an `ownerId` of the wrong shape, now + * fails `tsc` instead of only failing at runtime; the `as FilterQuery`/ + * `as OrderDefinition` casts are the one place the shape is still + * asserted rather than inferred, exactly at the computed-property-key + * boundary TypeScript cannot check on its own. + */ + +/** + * List every row owned by `ownerId`, ordered by `orderField` ascending: + * `SELECT * FROM
WHERE = ? ORDER BY ASC`. + */ +export async function listForOwner( + repo: TrekRepository, + ownerField: K, + ownerId: FilterValue, + orderField: OF, +): Promise { + return repo.find({ [ownerField]: ownerId } as FilterQuery, { + orderBy: { [orderField]: 'asc' } as OrderDefinition, + }); +} + +/** + * Find one row by id, strictly scoped to an owner: + * `SELECT * FROM
WHERE id = ? AND = ?`. + * + * `disableIdentityMap: true` per the module-level docstring above — this is + * always a "rows out" read here, never a caller's staging read for + * `assign`+`flush`. + */ +export async function findOwnedByUser( + repo: TrekRepository, + id: T['id'], + ownerField: K, + ownerId: FilterValue, +): Promise { + return repo.findOne({ id, [ownerField]: ownerId } as FilterQuery); +} diff --git a/server/src/db/repositories/_shared/packing-visibility.ts b/server/src/db/repositories/_shared/packing-visibility.ts new file mode 100644 index 000000000..6bcf9ac98 --- /dev/null +++ b/server/src/db/repositories/_shared/packing-visibility.ts @@ -0,0 +1,149 @@ +import type { Expression, ExpressionBuilder, ExpressionWrapper, SqlBool } from 'kysely'; + +/** + * The per-actor visibility predicate packing's three-tier sharing model + * (#858) runs every restricted read/write through (Plan 3e Task 0, R1) — ONE + * source, typed, for `packing_items`. + * + * Derived byte-for-byte from `PackingService.VISIBLE_TO_ACTOR` + * (`server/src/nest/packing/packing.service.ts:347-351`, quoted here as the + * oracle this file's own parity harness proves against without importing + * it, the same "hold the STRING side fixed and independent of the + * production code it is proving" reasoning + * `reservation-visibility.test.ts`'s docstring gives): + * + * ``` + * ( + * is_private = 0 + * OR owner_id = ? + * OR EXISTS (SELECT 1 FROM packing_item_recipients r WHERE r.item_id = packing_items.id AND r.user_id = ?) + * ) + * ``` + * + * An item is visible to an actor when it is Common (`is_private = 0`, + * belongs to the whole trip), or the actor is its owner (the "bringer"), or + * the actor is a named recipient of a Shared-with-people item. This is + * `getItemInTrip`'s (PK11) entire security model — six call sites + * (`updateItem`, `setItemSharing`, `addContributor`, `removeContributor`, + * `cloneItem`, `deleteItem`) and `listItems`' viewer-filtered branch (PK5) + * all route through it. Per the inventory's §17 surprise 2: "the single + * highest-blast-radius conversion in the whole plan" — a mistranslation + * makes every restricted packing item on the instance readable/writable by + * any trip member. **No consumer converts in this task** (the brief, item + * 1) — Task 3 (packing) is the only consumer, and reuses this file's own + * parity harness (`packing-visibility.parity.test.ts`) rather than + * re-deriving it. + * + * **Not a template for `PackingItemsRepository.bagWeightTotals` + * (PK34/§17 surprise 10).** That aggregate deliberately sums + * `weight_grams` across every item in a trip regardless of privacy — it + * must NOT be built on top of either predicate below; building it that way + * would silently ADD a privacy filter the legacy statement never had, + * breaking the shared-bag weight-limit warning (the opposite failure mode + * from the one this file exists to prevent). + * + * **No actor collapses to "Common items only", never to "unfiltered".** + * `getItemInTrip` denies outright when `actorId == null` (its own doc + * comment: "A missing actor denies too, rather than falling through + * unfiltered") — that JS-level guard runs before any SQL, so the legacy + * fragment itself is never actually evaluated with a null actor in + * production. Both predicates below are proven safe anyway if that guard is + * ever bypassed (the harness's fourth actor cell): they accept + * `actorId: number | undefined` and, when it is `undefined`, omit the + * `owner_id`/recipient clauses from the `OR` entirely rather than binding a + * literal `null` into them. That is semantically identical to what the + * legacy fragment does when `?` is bound `NULL` — `owner_id = NULL` and + * `r.user_id = NULL` are SQL-NULL comparisons, always false, never true — + * so the `OR` reduces to `is_private = 0` either way. Binding a literal + * `null` through MikroORM's filter-object DSL was deliberately avoided + * instead: `{ owner_id: null }` there means `owner_id IS NULL` (a + * DIFFERENT, wrong predicate — it would make an unowned legacy item visible + * to a null actor), not `owner_id = NULL`, so the two are not + * interchangeable and only the "omit the clause" form is safe. + * + * **Two shapes, not three** (unlike `reservation-visibility.ts`'s RV1/RV2 + * split): packing's fragment is ONE OR-chain of three arms, always evaluated + * against the same, always-unaliased `packing_items` table (`PK5`'s + * `listItems` viewer branch and `PK11`'s `getItemInTrip` both select + * directly from `packing_items`, no alias) — there is no second alias shape + * to support the way reservations' `r`/`vr` split needed, so neither + * predicate below takes an `alias` parameter. + * + * - `packingVisibleToActorCondition` — a typed filter-object condition + * (inferred, not annotated `FilterQuery` — that broader + * union type includes the bare-primary-key shorthand MikroORM's filter + * DSL also accepts, which is not itself a spreadable object type, so + * annotating the return type with it breaks the `{ trip_id, ...cond }` + * spread every call site needs; the concrete inferred object literal type + * has no such member and spreads cleanly, the same reason + * `reservation-visibility.ts`'s `publicReservationCondition` leaves its + * own return type inferred too), spreadable into any + * `em.createQueryBuilder(PackingItems, ...).where({...})` call. Unlike RV2 + * (`reservation-visibility.ts`'s `publicStayExists`), the + * `EXISTS` arm here does NOT need Kysely to express as a QueryBuilder + * condition: `PackingItems.packing_item_recipients` is a genuine + * `p.manyToMany(Users)` relation over the `packing_item_recipients` pivot + * (`PackingItems.entity.ts`), unlike `reservations.accommodation_id` + * (RV2's blocker — a plain TEXT column with no FK/relation at all). Filtering + * that relation property with a scalar user id inside an `$or` renders as a + * `LEFT JOIN packing_item_recipients ... OR p1.user_id = ?` (verified + * directly against the compiled query, not assumed — `qb.getFormattedQuery()` + * on a seeded fixture) — safe against duplication because `(item_id, + * user_id)` is the pivot's own composite `PRIMARY KEY` + * (`Migration20200101022900_three_tier_packing_sharing.ts:17-21`), so at + * most one matching pivot row can join per item per actor, never fanning a + * row out. This is genuinely a `LEFT JOIN`, not an `EXISTS` subquery — + * proven row-identical to the legacy fragment by this file's own parity + * harness rather than assumed equivalent from the SQL shape alone. + * - `packingVisibleToActorExpr` — the Kysely-expression twin, for a caller + * building a raw, multi-join statement through `this.kysely()` rather than + * the MikroORM QueryBuilder. Spelled as a genuine correlated `EXISTS` + * (`eb.exists`), matching the legacy fragment's own shape token-for-token, + * rather than a join — Kysely has no ORM relation to lean on the way the QB + * form does, so this is built the same way `publicStayExists` is. + * + * Both forms are proven identical to the legacy fragment AND to each other + * on the same 12-cell fixture matrix (owner/recipient/stranger/no-actor × + * common/personal/shared) by `packing-visibility.parity.test.ts` in this + * directory. + */ + +export function packingVisibleToActorCondition(actorId: number | undefined) { + return { + $or: [ + { is_private: 0 }, + ...(actorId != null ? [{ owner_id: actorId }, { packing_item_recipients: actorId }] : []), + ], + }; +} + +/** + * `packing_items`/`packing_item_recipients`'s REAL table names — the shape + * `packingVisibleToActorExpr` needs. A consumer's own local Kysely `DB` + * interface satisfies this by structural extension, the same + * `ReservationVisibilityKyselyDB` precedent. + */ +export interface PackingVisibilityKyselyDB { + packing_items: { id: number; is_private: number; owner_id: number | null }; + packing_item_recipients: { item_id: number; user_id: number }; +} + +export function packingVisibleToActorExpr( + eb: ExpressionBuilder, + actorId: number | undefined, +): ExpressionWrapper { + const conditions: Expression[] = [eb('packing_items.is_private', '=', 0)]; + if (actorId != null) { + conditions.push(eb('packing_items.owner_id', '=', actorId)); + conditions.push( + eb.exists( + eb + .selectFrom('packing_item_recipients as r') + .select('r.item_id') + .whereRef('r.item_id', '=', 'packing_items.id') + .where('r.user_id', '=', actorId), + ), + ); + } + return eb.or(conditions); +} diff --git a/server/src/db/repositories/_shared/presence-set.ts b/server/src/db/repositories/_shared/presence-set.ts new file mode 100644 index 000000000..cb9cab1d1 --- /dev/null +++ b/server/src/db/repositories/_shared/presence-set.ts @@ -0,0 +1,35 @@ +/** + * Plan 3e Task 2 (budget), R11 — the presence-sentinel helper pattern. + * + * The legacy multi-column `UPDATE` statements this plan repeats five times + * (budget's 8-column item update / 6-column settlement update, packing's + * 8-column item update, todo's 7-column item update, collab's 6-column note + * update) all follow the same idiom: `col = CASE WHEN ? THEN ? ELSE col + * END`, bound with a presence flag and a value — "the caller didn't mention + * this field, leave the column alone" vs. "the caller set it, including to + * NULL, write that". + * + * Re-expressed for Kysely's `updateTable(...).set(data)`, no SQL-level CASE + * WHEN is needed: Kysely only emits a `SET` clause for a key that is + * PRESENT in `data` — an absent key is untouched, which IS the "ELSE col" + * branch, and a present key (even `null`) is written, which IS the "THEN ?" + * branch. `presenceSet` builds that object: pass each column a `[present, + * value]` pair (`value` already resolved to its final bound shape by the + * caller — e.g. `data.currency || null` — exactly like `ReservationsRepository + * .updateReservation`'s docstring: "the SERVICE resolves the field to its + * FINAL value first, this writes exactly what it is handed"), and only the + * present ones survive into the returned partial object. + * + * Land here first (Task 2); Tasks 3 (packing), 4 (todo) and 5 (collab) copy + * this shape rather than re-deriving their own CASE WHEN translation. + */ +export function presenceSet>( + fields: { [K in keyof T]?: readonly [present: boolean, value: T[K]] }, +): Partial { + const out: Partial = {}; + for (const key of Object.keys(fields) as (keyof T)[]) { + const entry = fields[key]; + if (entry && entry[0]) out[key] = entry[1]; + } + return out; +} diff --git a/server/src/db/repositories/_shared/reservation-travelers-owns.ts b/server/src/db/repositories/_shared/reservation-travelers-owns.ts new file mode 100644 index 000000000..06d85ec1b --- /dev/null +++ b/server/src/db/repositories/_shared/reservation-travelers-owns.ts @@ -0,0 +1,138 @@ +import type { ExpressionBuilder, ExpressionWrapper, SqlBool } from 'kysely'; + +/** + * The `TRAVELER_OWNS` predicate atlas's `AtlasService` inlines as a + * `private static readonly` string fragment (`atlas.service.ts:1173-1175`, + * quoted here as this file's own oracle, the same "hold the STRING side + * fixed and independent of the production code it is proving" reasoning + * `reservation-visibility.ts`/`packing-visibility.ts` document) — ONE + * shared, typed predicate for the three call sites (AT6/AT45/AT46) that all + * inline it today (Plan 3f Task 0, R7): + * + * ``` + * ( + * NOT EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id) + * OR EXISTS (SELECT 1 FROM reservation_travelers rt WHERE rt.reservation_id = r.id AND rt.user_id = ?) + * ) + * ``` + * + * Binds exactly one parameter (the caller's own user id) and expects the + * reservation aliased `r` — every one of AT6/AT45/AT46 already uses that + * alias for `reservations` in its `FROM`/`JOIN` clause, so both typed forms + * below fix it too rather than taking an arbitrary alias parameter (the + * `ReservationVisibilityKyselyDB & { a: { id: number } }` precedent + * `publicStayExists` sets for a single, real, already-fixed alias — not + * `reservation-visibility.ts`'s RV1 shape, which genuinely serves more than + * one alias in production, `r` AND `vr`). + * + * **Backward-compat critical (#1966).** A reservation with ZERO + * `reservation_travelers` rows — every booking made before 4.0, before + * per-traveler assignment existed at all — must still count for the WHOLE + * trip (the `NOT EXISTS` arm). Only once at least one assignment row exists + * does the second arm narrow the reservation to "assigned to me". Getting + * this backwards either double-counts a shared trip's flights per member + * (if the `NOT EXISTS` arm is dropped or inverted) or zeroes every pre-4.0 + * install's flown distance overnight (if the `EXISTS` arm is applied + * without the `NOT EXISTS` fallback) — the single highest-blast-radius + * predicate in this plan, per the plan's own risk list. **No consumer + * converts in this task** (the brief, item 5) — Task 1 (atlas) is the only + * consumer, at AT6/AT45/AT46, and reuses this file's own parity harness + * (`reservation-travelers-owns.parity.test.ts`) rather than re-deriving it. + * + * **Two shapes, not three** (unlike `reservation-visibility.ts`'s RV1/RV2 + * split) — `TRAVELER_OWNS` is ONE OR-chain of two arms, always evaluated + * against the same, always-aliased-`r` `reservations` row, so there is no + * second alias shape or second predicate to support the way RV1/RV2 needed. + * + * - `travelerOwnsCondition` — a typed MikroORM QueryBuilder filter-object + * condition, spreadable into any `.where({...})` call against `Reservations` + * (or any query whose base/joined entity is `Reservations`, referenced by + * its `reservation_travelers_collection` relation property — a genuine + * `p.oneToMany(ReservationTravelers).mappedBy('reservation')` relation, + * `Reservations.entity.ts:103`, unlike RV2's blocker where + * `reservations.accommodation_id` has no FK/relation at all to join + * through). Built with MikroORM's own collection operators (`$none`/`$some` + * — "collection operators, sql only", `node_modules/@mikro-orm/core/enums.d.ts`'s + * `QueryOperator` — verified directly, not assumed: `$exists` is a + * DIFFERENT operator that renders `IS NOT NULL` for an embeddable/JSON + * path, not a correlated subquery, which is exactly why RV2 has no QB + * form at all), which MikroORM's SQL query compiler + * (`node_modules/@mikro-orm/sql/query/ObjectCriteriaNode.js`) renders as + * ` IN (SELECT FROM reservations INNER JOIN reservation_travelers + * rt ON … WHERE rt.user_id = ?)` for `$some` and ` NOT IN (SELECT + * FROM reservations INNER JOIN reservation_travelers rt ON …)` for + * `$none` — a semicolon-for-semicolon DIFFERENT SQL shape from the legacy + * correlated `NOT EXISTS`/`EXISTS` text, but row-identical on every input + * (an `IN`/`NOT IN` against a `INNER JOIN`-then-`SELECT pk` subquery + * selects exactly the same primary-key set a correlated `EXISTS` would), + * proven directly by this file's own parity harness rather than assumed + * equivalent from the operator names alone. + * - `travelerOwnsExpr` — the Kysely-expression twin, for a caller building a + * raw, multi-join statement through `this.kysely()` rather than the + * MikroORM QueryBuilder (AT6/AT45/AT46 are exactly this shape — large, + * multi-table `JOIN` aggregates with `CASE WHEN` projections, never a + * plain `em.createQueryBuilder(Reservations, 'r')` a QB filter object could + * spread into on its own). Spelled as a genuine correlated `EXISTS`/`NOT + * EXISTS` pair, matching the legacy fragment's own shape token-for-token — + * the same "no ORM relation to join those rows the QueryBuilder could + * filter through" reasoning `publicStayExists` documents, even though a + * relation DOES exist here (Kysely never uses MikroORM's entity relations + * at all — it is a separate, relation-unaware SQL builder). + * + * Both forms are proven identical to the legacy fragment on the two cases + * that matter (a reservation with zero `reservation_travelers` rows; a + * reservation with assignments, narrowed to the caller's own) by + * `reservation-travelers-owns.parity.test.ts` in this directory. + */ + +export function travelerOwnsCondition(userId: number) { + return { + $or: [ + { reservation_travelers_collection: { $none: {} } }, + { reservation_travelers_collection: { $some: { user: userId } } }, + ], + }; +} + +/** + * `reservation_travelers`/`reservations`' REAL table names, the shape + * `travelerOwnsExpr` needs (fixed alias `r` for `reservations` — see this + * file's module docstring for why, unlike RV1's genuinely-multi-alias + * shape). A consumer's own local Kysely `DB` interface satisfies this by + * structural extension, the same `ReservationVisibilityKyselyDB`/ + * `PackingVisibilityKyselyDB` precedent. + */ +export interface ReservationTravelersOwnsKyselyDB { + reservation_travelers: { reservation_id: number; user_id: number }; + reservations: { id: number }; +} + +/** + * `TRAVELER_OWNS` as a Kysely predicate: `(NOT EXISTS (…) OR EXISTS (… AND + * rt.user_id = ?))`, correlated on `rt.reservation_id = r.id`. Call inside a + * `.where((eb) => travelerOwnsExpr(eb, userId))` on a query already carrying + * an `r` alias for `reservations` (a base `.selectFrom('reservations as + * r')`, or `reservations` joined in as `r` — both produce the same aliased + * `r: { id: number }` member Kysely's type inference needs) against a + * `Kysely` whose `DB` satisfies `ReservationTravelersOwnsKyselyDB`. The + * parameter type is fixed to exactly that shape rather than generic over an + * arbitrary caller `DB`/`TB`, the same `publicStayExists` precedent and for + * the same reason (`Trips.repository.ts`'s `tripSelectQuery` docstring: a + * shared Kysely helper generic across differently-shaped outer queries + * doesn't compose), proven end-to-end by this file's own parity-harness + * test rather than assumed from the type signature alone. + */ +export function travelerOwnsExpr( + eb: ExpressionBuilder, + userId: number, +): ExpressionWrapper { + const forThisReservation = eb + .selectFrom('reservation_travelers as rt') + .select('rt.reservation_id') + .whereRef('rt.reservation_id', '=', 'r.id'); + + return eb.or([ + eb.not(eb.exists(forThisReservation)), + eb.exists(forThisReservation.where('rt.user_id', '=', userId)), + ]); +} diff --git a/server/src/db/repositories/_shared/reservation-visibility.ts b/server/src/db/repositories/_shared/reservation-visibility.ts new file mode 100644 index 000000000..b2428de03 --- /dev/null +++ b/server/src/db/repositories/_shared/reservation-visibility.ts @@ -0,0 +1,166 @@ +import type { Platform } from '@mikro-orm/core'; +import type { ExpressionBuilder, ExpressionWrapper, ReferenceExpression, SqlBool } from 'kysely'; +import { coalesceParam } from '../../dialect/sql-functions'; + +/** + * The visibility predicate an anonymous viewer's reads are filtered + * through — ONE source, typed, for `reservations`/`day_accommodations` + * (Plan 3d Task 0, R3). + * + * Derived from the two legacy SQL-fragment builders this file superseded + * (`src/nest/reservations/reservation-visibility.ts`'s `publicReservationSql` + * and `publicStaySql` — DELETED by Plan 3d Task 4, once calendar and + * `share` were the string form's only remaining consumers and both were + * converted in the same commit: no two live implementations of an + * anonymous-surface security predicate, §18.2): + * + * - **RV1** `publicReservationSql(alias)` — `` COALESCE(${alias}.ingest_state, + * 'live') <> 'staged' ``. A reservation is public unless an automated + * ingest staged it for review. + * - **RV2** `publicStaySql(alias)` — a stay is public when nothing points at + * it (added by hand), or when a LIVE booking does: + * `(NOT EXISTS (…) OR EXISTS (… AND ))`, correlated on + * `CAST(vr.accommodation_id AS INTEGER) = ${alias}.id` (`accommodation_id` + * is TEXT with no FK, §18.1 — some rows read back as `"14.0"`). + * + * THREE shapes are exported, because RV1 alone needs two different query + * APIs and RV2 needs a third that calls back into one of them. RV1 has no + * correlated subquery, so where a caller already holds a MikroORM + * QueryBuilder it renders as a typed filter OBJECT + * (`publicReservationCondition`) — spreadable into any `.where({...})` + * call at any table alias, exactly like the legacy fragment builder's own + * runtime-alias shape, no compile-time table binding needed (rule 23: a + * typed condition, not a string). Where a caller is instead building a raw, + * multi-join statement through `this.kysely()` (CL2/CL7/`share`'s reads — + * no ORM relation exists to join those rows through the QueryBuilder at + * all), RV1 is `publicReservationExpr`, a Kysely expression-builder + * predicate. RV2's NOT EXISTS/EXISTS pair has no MikroORM QueryBuilder + * expression either (no relation exists from `day_accommodations` to + * `reservations` via `accommodation_id` — R10 keeps it that way; QB's + * `$exists` operator checks embeddable/JSON path existence, not a + * correlated SQL subquery), so it is built with Kysely's own typed + * `ExpressionBuilder` (`publicStayExists`) — `eb.exists`/`eb.not` (Kysely's + * `unary('exists'|'not', …)` shortcuts), `eb.cast`, and `publicReservationExpr` + * itself for its own inner RV1 check, all fully typed, never a `raw()` call + * or a `sql` tagged template (both banned under `src/db/repositories/**` by + * ESLint's `no-restricted-syntax`, checked directly against + * `server/eslint.config.mjs`). + * + * **`publicStayExists` is deliberately narrow, not maximally generic** — + * `ReservationVisibilityKyselyDB` fixes the two aliases every known + * caller already uses (`a` for the outer stay row, `vr` for the + * correlated reservations lookup — "Alias interpolation is a + * caller-supplied constant ('r', 'vr', 'a'); never user input", inventory + * §2d). `Trips.repository.ts`'s `tripSelectQuery` docstring records why a + * shared Kysely helper across differently-joined outer queries doesn't + * compose well ("a shared private helper can't be typed across these four + * callbacks either… `leftJoin`'s alias widens each callback's own + * `ExpressionBuilder` table-set in a way a separate generic method + * signature can't match structurally") — the same limitation would bite a + * predicate meant to serve arbitrarily-different outer queries. This + * function instead requires exactly one contract: the caller's outer query + * is built with `.selectFrom('day_accommodations as a')` (or any + * `.selectFrom`/`.leftJoin` chain that puts an `a` alias with an `id` + * column into scope) and a `reservations` table exists in the same `DB` + * interface. A consumer whose own local Kysely `DB` interface satisfies + * `ReservationVisibilityKyselyDB` structurally can pass its `eb` straight + * through, extending its interface with `& ReservationVisibilityKyselyDB` + * if needed (the same additive-intersection shape + * `AssignmentTimeSortKyselyDB`/`RoadtripVisitsKyselyDB` already use for + * their own narrow, per-query interfaces) — proven end-to-end (not just + * typechecked) by this file's own test and by the string-vs-predicate + * parity harness (`tests/unit/db/repositories/_shared/reservation-visibility.test.ts`), + * which Tasks 2/4/5 reuse rather than re-derive. + * + * `ingest_state` visibility is deliberately NOT filtered for authenticated + * reads (`reservation-visibility.ts`'s own docstring: "the staging inbox + * has to see its own rows, or nobody can confirm them") — these two + * exports exist ONLY for the two anonymous surfaces (the ICS feeds, + * `share`) that need it; nothing in this file changes an authenticated + * list. + */ + +/** + * RV1 as a typed QB filter-object condition: + * `COALESCE(.ingest_state, 'live') <> 'staged'`. Spread into a + * `.where({...})` call — `{ ...publicReservationCondition(platform, 'r'), + * trip_id: tripId }` — at any table alias the caller's own query already + * established; this needs no compile-time table binding, the same way + * `coalesceParam` itself needs none (its `ref` argument is validated only + * by `column()`'s identifier-shape regex, not against a live entity's + * declared columns). + */ +export function publicReservationCondition(platform: Platform, alias: string) { + return { [coalesceParam(platform, `${alias}.ingest_state`, 'live')]: { $ne: 'staged' } }; +} + +/** + * RV1 as a Kysely predicate (L2, Plan 3d Task 0 review): the SAME + * `COALESCE(, 'live') <> 'staged'` boolean, spelled through the + * expression builder instead of a QB filter object — for the raw-SQL-shaped + * statements CL2/CL7/`share.service.ts`'s reads convert onto (no ORM + * relation joins those rows the QueryBuilder could filter through, so they + * are built with `this.kysely()`, same as `publicStayExists` below). + * `ref` is the caller's own already-aliased `ingest_state` column + * (`'r.ingest_state'`, `'vr.ingest_state'`) — generic over `` + * (unlike `publicStayExists`, this needs no correlated subquery and no + * table-set beyond whatever the caller already has in scope, so nothing + * forces a narrow, non-generic signature the way `publicStayExists`'s + * `ExpressionBuilder` does). + * `publicStayExists` below calls this rather than re-spelling the + * `COALESCE`/`<>` pair a second time — ONE implementation of RV1, not two + * (the Task 0 review's own finding, L2). + */ +export function publicReservationExpr( + eb: ExpressionBuilder, + ref: ReferenceExpression, +): ExpressionWrapper { + return eb(eb.fn.coalesce(ref, eb.val('live')), '<>', eb.val('staged')); +} + +/** + * `reservations`/`day_accommodations`'s REAL table names, the shape + * `publicStayExists` needs. A consumer's own local Kysely `DB` interface + * (the `AssignmentTimeSortKyselyDB`/`RoadtripVisitsKyselyDB`/ + * `TripSelectKyselyDB` per-query-interface precedent) satisfies this by + * structural extension — declaring `day_accommodations`/`reservations` + * with at least these columns is enough; `Kysely` typed against it and + * queried via `.selectFrom('day_accommodations as a')` (the fixed alias + * every known legacy caller uses — see this file's module docstring) is + * what actually produces the aliased `a` member `publicStayExists` + * requires; Kysely computes that from THIS interface's `day_accommodations` + * entry, not from a hand-declared `a` key (a table's alias is a property of + * the QUERY, never of the `DB` interface itself). + */ +export interface ReservationVisibilityKyselyDB { + day_accommodations: { id: number }; + reservations: { id: number; accommodation_id: number | string | null; ingest_state: string | null }; +} + +/** + * RV2 as a Kysely predicate: `(NOT EXISTS (…) OR EXISTS (… AND ))`, + * correlated on `CAST(vr.accommodation_id AS INTEGER) = a.id`. Call inside + * a `.where((eb) => publicStayExists(eb))` on a query already + * `.selectFrom('day_accommodations as a')` against a `Kysely` whose + * `DB` satisfies `ReservationVisibilityKyselyDB`. The parameter type is + * fixed to exactly the shape `.selectFrom('day_accommodations as a')` + * itself produces (`ReservationVisibilityKyselyDB` plus the derived `a` + * alias member) rather than generic over an arbitrary caller `DB`/`TB` — + * `Trips.repository.ts`'s `tripSelectQuery` docstring records why a shared + * Kysely helper generic across differently-shaped outer queries doesn't + * compose ("a shared private helper can't be typed across these four + * callbacks either… a separate generic method signature can't match + * structurally"); this function accepts exactly one contract instead, + * proven end-to-end by this file's own parity-harness test. + */ +export function publicStayExists(eb: ExpressionBuilder) { + const linkedTo = eb + .selectFrom('reservations as vr') + .select('vr.id') + .where((eb2) => eb2(eb2.cast(eb2.ref('vr.accommodation_id'), 'integer'), '=', eb2.ref('a.id'))); + + return eb.or([ + eb.not(eb.exists(linkedTo)), + eb.exists(linkedTo.where((eb2) => publicReservationExpr(eb2, 'vr.ingest_state'))), + ]); +} diff --git a/server/src/db/repositories/_shared/rows.ts b/server/src/db/repositories/_shared/rows.ts new file mode 100644 index 000000000..9da0a7597 --- /dev/null +++ b/server/src/db/repositories/_shared/rows.ts @@ -0,0 +1,40 @@ +import { wrap, type Collection, type EntityDTO, type Reference } from '@mikro-orm/core'; + +/** + * The plain row a service (and, through it, the API) sees. + * + * `toObject()` drops the ORM machinery — `Ref` wrappers, hidden relations, + * collections that were never loaded — and leaves the column-named scalars, + * which is exactly the `SELECT *` row the legacy code returned. Repositories + * return this, never a live entity, so a service cannot lazy-load or mutate + * state behind the repository's back. + */ +export function toRow(entity: T): EntityDTO { + return wrap(entity).toObject() as EntityDTO; +} + +/** + * The scalar (column-backed) keys of an entity class: everything that is not + * a relation or a collection. `Exclude` drops MikroORM's + * internal meta-properties (`[PrimaryKeyProp]`, `[OptionalProps]`, …) — + * `unique symbol`-keyed, declared on an entity whose primary key isn't a + * plain autoincrement `id` (e.g. `AppSettings`'s `[PrimaryKeyProp]?: 'key'`) + * — which are not columns and have no place in a hand-written row interface. + */ +export type ScalarKeys = { + [K in Exclude]-?: NonNullable extends Collection | Reference ? never : K; +}[Exclude]; + +/** + * Compile-time parity between a hand-written row interface and the entity's + * scalar keys: a column added to or removed from the entity fails `tsc` here + * instead of leaking silently through `toRow`'s cast. Types (nullability) + * stay hand-maintained in the interface on purpose — that is where the API + * shape is documented. + */ +export type AssertRowKeys = [ + Exclude>, + Exclude, keyof Row>, +] extends [never, never] + ? true + : never; diff --git a/server/src/db/repositories/_shared/trek-repository.ts b/server/src/db/repositories/_shared/trek-repository.ts new file mode 100644 index 000000000..6caba40a4 --- /dev/null +++ b/server/src/db/repositories/_shared/trek-repository.ts @@ -0,0 +1,270 @@ +import { + EntityRepository, + type CountByOptions, + type CountOptions, + type Cursor, + type DeleteOptions, + type Dictionary, + type EntityData, + type EntityKey, + type FilterQuery, + type FindAllOptions, + type FindByCursorOptions, + type FindOneOptions, + type FindOneOrFailOptions, + type FindOptions, + type GetKyselyOptions, + type Loaded, + type NativeInsertUpdateOptions, + type Primary, + type QueryBuilder, + type RequiredEntityData, + type SqlEntityManager, + type StreamOptions, + type UpdateOptions, + type UpsertManyOptions, + type UpsertOptions, + type WithUsingOptions, +} from '@mikro-orm/sql'; + +/** + * The one base class every repository in `src/db/repositories/*.ts` extends, + * instead of `@mikro-orm/sql`'s `EntityRepository` directly. + * + * **Why it exists.** Plan 3b Task 1's `disableIdentityMap` ruling + * (`Users.repository.ts`'s class docstring, task-1-review.md B1) made every + * row-out read pass `disableIdentityMap: true` to stop a stale, partially + * hydrated identity-map entity from being written back over an intervening + * `nativeUpdate`. The re-review (`task-1-rereview.md` F-R1) found the + * side effect: `disableIdentityMap: true` resolves its `EntityManager` with + * `getContext(false)` (`node_modules/@mikro-orm/core/EntityManager.js`, + * the `findOne`/`find` `disableIdentityMap` branch), and `false` is the one + * argument that SKIPS the `allowGlobalContext` check — + * `getContext(validate = true)` is the only place `ValidationError + * .cannotUseGlobalContext()` is thrown, gated on `validate` + * (`node_modules/@mikro-orm/core/EntityManager.js:1912-1925`). So every + * converted read stopped failing closed outside a request context: it now + * succeeds silently against the global `EntityManager` instead of throwing. + * Separately, the *write* paths (`nativeUpdate`/`nativeDelete`/`count`/ + * `insert`/`upsert`) never validated in the first place — they all resolve + * with `getContext(false)` too (task-1-review.md F7 INFO) — so an unwrapped + * entrypoint that only ever writes was never caught at all. + * + * This class restores the program's D6 ratchet ("an unwrapped entrypoint + * fails loudly") for every path a repository exposes, in one place: each + * override below calls {@link TrekRepository.validateRequestContext} FIRST — + * MikroORM's own, VALIDATING `getContext()` (`validate` defaults to `true`) + * — which throws `ValidationError.cannotUseGlobalContext()` outside + * `withRequestContext`/`RequestContext.create`/an open transaction, exactly + * as a managed (identity-map) read did before the `disableIdentityMap` + * ruling — and only then delegates to `super`. Every overridden method keeps + * MikroORM's own signature and does nothing else: this class does not change + * what a repository method returns or which SQL it emits, only whether it + * runs at all outside a request context. + * + * **Read-path default.** `find`/`findOne`/`findAll` additionally merge + * `{ disableIdentityMap: true }` into the caller's options, unless the + * caller passes `disableIdentityMap: false` explicitly — the class-level + * ruling every repository used to restate on its own `Users.repository.ts`, + * `McpTokens.repository.ts`, etc. — so no repository needs to pass the + * option itself any more. See + * https://mikro-orm.io/docs/entity-manager#disableidentitymap. + * + * **Kysely.** `em.getKysely()` (used for the one or two statements per + * repository the QueryBuilder cannot express — a recursive CTE, a + * `DELETE … RETURNING`) resolves its `EntityManager` with `getContext(false)` + * too (`node_modules/@mikro-orm/sql/SqlEntityManager.js`), so it has the + * exact same gap as the write paths above. {@link TrekRepository.kysely} is + * the one place a repository reaches Kysely from now on, instead of calling + * `this.getEntityManager().getKysely()` directly. + * + * See https://mikro-orm.io/docs/repositories (custom repositories) and + * https://mikro-orm.io/docs/identity-map (`allowGlobalContext`). + */ +export abstract class TrekRepository extends EntityRepository { + /** + * MikroORM's own validating `getContext()` — throws + * `ValidationError.cannotUseGlobalContext()` outside a request context + * (`allowGlobalContext: false`, the production default) or an open + * transaction. `super.getEntityManager()`, not `this.getEntityManager()`: + * the latter is itself overridden below to call this method, and calling + * through `this` here would recurse. + */ + protected validateRequestContext(): void { + super.getEntityManager().getContext(); + } + + /** + * `em.getKysely()`, validated first. The QueryBuilder has no recursive-CTE + * support and its `DELETE … RETURNING` is silently dropped + * (`WebauthnChallenges.repository.ts::claimChallenge`'s docstring), so a + * handful of methods across the program need this escape hatch — always + * through here now, never `this.getEntityManager().getKysely()` directly. + * No explicit return-type annotation: `SqlEntityManager.getKysely`'s + * return type is expressed in terms of `this` (the entity list the calling + * `EntityManager` was constructed with), which does not survive being + * spelled out via `ReturnType<...>` on its own — inference from the + * `return` statement below keeps it intact. + */ + protected kysely(options?: TOptions) { + this.validateRequestContext(); + return this.getEntityManager().getKysely(options); + } + + override getEntityManager(): SqlEntityManager { + this.validateRequestContext(); + return super.getEntityManager(); + } + + /** + * `find`/`findOne`/`findAll` below drop the base signature's `Using` + * type parameter and its `IndexFilterQuery`/`using` machinery (MikroORM's + * named-index query hint) rather than reproduce it: no repository in this + * program passes `using`, and TypeScript's override-compatibility check + * on that parameter's conditional type — `[Using] extends [never] ? ... : + * ...` compared against the base class's own instantiation of it — does + * not hold even for a byte-identical copy of the declared signature (a + * generic-defaults quirk in how the checker instantiates the two sides + * for comparison, not a real behavioural difference). Every other generic + * (`Hint`/`Fields`/`Excludes`, the ones every repository's `fields`/ + * `populate` options actually use) is preserved exactly. + */ + override async findOne( + where: FilterQuery, + options?: FindOneOptions, + ): Promise | null> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findOne(where, { ...options, disableIdentityMap }); + } + + override async find( + where: FilterQuery, + options?: FindOptions, + ): Promise[]> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.find(where, { ...options, disableIdentityMap }); + } + + override async findAll< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + Using extends string = never, + >( + options?: WithUsingOptions, Entity, Using>, + ): Promise[]> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findAll({ ...options, disableIdentityMap }); + } + + override async count(where?: FilterQuery, options?: CountOptions): Promise { + this.validateRequestContext(); + return super.count(where, options); + } + + /** + * Task 7 review, M4: `TrekRepository` left seven DB-reaching base-class + * methods unoverridden (none used anywhere in this program today — + * `grep -rnE "\.(countBy|findAndCount|findByCursor|findOneOrFail| + * insertMany|upsertMany|stream)\(" src/db/repositories/` was 0 hits — but + * the FIRST one used would silently lose both guarantees: no + * `validateRequestContext()`, and — for the read-shaped ones — no + * `disableIdentityMap: true` default). Same shape as `findOne`/`find`/ + * `findAll` above: `Using` dropped for the same override-compatibility + * reason documented on `findOne`. + */ + override async findOneOrFail( + where: FilterQuery, + options?: FindOneOrFailOptions, + ): Promise> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findOneOrFail(where, { ...options, disableIdentityMap }); + } + + override async findAndCount( + where: FilterQuery, + options?: FindOptions, + ): Promise<[Loaded[], number]> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findAndCount(where, { ...options, disableIdentityMap }); + } + + override async findByCursor< + Hint extends string = never, + Fields extends string = never, + Excludes extends string = never, + IncludeCount extends boolean = true, + Using extends string = never, + >( + options: WithUsingOptions, Entity, Using>, + ): Promise> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.findByCursor({ ...options, disableIdentityMap }); + } + + /** Grouping counts don't hydrate an entity — no identity map to isolate — so this only needs the fail-closed guard, matching `count` above. */ + override async countBy(groupBy: EntityKey | readonly EntityKey[], options?: CountByOptions): Promise> { + this.validateRequestContext(); + return super.countBy(groupBy, options); + } + + override stream( + options?: WithUsingOptions, Entity, Using>, + ): AsyncIterableIterator> { + this.validateRequestContext(); + const disableIdentityMap = options?.disableIdentityMap ?? true; + return super.stream({ ...options, disableIdentityMap }); + } + + override async insertMany(data: Entity[] | RequiredEntityData[], options?: NativeInsertUpdateOptions): Promise[]> { + this.validateRequestContext(); + return super.insertMany(data, options); + } + + override async upsertMany( + entitiesOrData?: EntityData[] | Entity[], + options?: UpsertManyOptions, + ): Promise { + this.validateRequestContext(); + return super.upsertMany(entitiesOrData, options); + } + + override async nativeUpdate(where: FilterQuery, data: EntityData, options?: UpdateOptions): Promise { + this.validateRequestContext(); + return super.nativeUpdate(where, data, options); + } + + override async nativeDelete(where: FilterQuery, options?: DeleteOptions): Promise { + this.validateRequestContext(); + return super.nativeDelete(where, options); + } + + override async insert(data: Entity | RequiredEntityData, options?: NativeInsertUpdateOptions): Promise> { + this.validateRequestContext(); + return super.insert(data, options); + } + + override async upsert( + entityOrData?: EntityData | Entity, + options?: UpsertOptions, + ): Promise { + this.validateRequestContext(); + return super.upsert(entityOrData, options); + } + + override createQueryBuilder(alias?: RootAlias): QueryBuilder { + this.validateRequestContext(); + return super.createQueryBuilder(alias); + } + + override qb(alias?: RootAlias): QueryBuilder { + this.validateRequestContext(); + return super.qb(alias); + } +} diff --git a/server/src/db/repositories/_shared/trip-access.ts b/server/src/db/repositories/_shared/trip-access.ts new file mode 100644 index 000000000..7dcd661e0 --- /dev/null +++ b/server/src/db/repositories/_shared/trip-access.ts @@ -0,0 +1,41 @@ +import type { ExpressionBuilder, ExpressionWrapper, ReferenceExpression, SqlBool } from 'kysely'; + +/** + * The trip-access predicate — "can `userId` see this trip?" — as ONE Kysely + * boolean expression, generic over the caller's own `` (the same + * fully-generic shape `reservation-visibility.ts`'s `publicReservationExpr` + * uses, for the identical reason: this predicate has no correlated + * subquery, so nothing forces the narrower fixed-alias shape + * `publicStayExists` needs). + * + * Byte-for-byte the SAME boolean `Trips.repository.ts`'s MikroORM + * QueryBuilder helper `accessibleTripsQuery` renders (`findAccessible`'s own + * canonical shape, per Plan 4 Task 8a): `t.user_id = ? OR m.user_id IS NOT + * NULL`, where `m` is a `LEFT JOIN trip_members ON m.trip_id = t.id AND + * m.user_id = ?` the caller has already built. This file does not (and + * cannot, per `tripSelectQuery`'s own docstring on why a shared join helper + * can't be typed across differently-joined outer queries) unify the JOIN + * itself — only the WHERE boolean that follows it, which is the part that + * was actually duplicated three times by hand in this program (Task 7 + * security review M1 / TRIPREPO-041): `TripsRepository.findForViewer`, + * `.listForUser` and `.activeTrip` each re-typed the identical + * `eb.or([eb('t.user_id', '=', user_id), eb('m.user_id', 'is not', null)])` + * independently. Proven identical to the MikroORM form and across all three + * Kysely call sites by `TRIPREPO-041`'s cross-method access-parity test, + * which stays green as this refactor's own regression guard (its own + * comment already said a future unification pass should). + * + * `tripUserIdRef`/`memberUserIdRef` are the caller's own already-aliased + * column references (`'t.user_id'`, `'m.user_id'` at every known call site + * today) — passed in, not hard-coded, so this helper stays usable if a + * future caller joins under different aliases, the same reasoning + * `publicReservationExpr`'s own `ref` parameter gives. + */ +export function tripAccessExpr( + eb: ExpressionBuilder, + tripUserIdRef: ReferenceExpression, + memberUserIdRef: ReferenceExpression, + userId: number, +): ExpressionWrapper { + return eb.or([eb(tripUserIdRef, '=', userId), eb(memberUserIdRef, 'is not', null)]); +} diff --git a/server/src/db/reseat-booked-nights.ts b/server/src/db/reseat-booked-nights.ts index ab4189cb3..a5a29b7f9 100644 --- a/server/src/db/reseat-booked-nights.ts +++ b/server/src/db/reseat-booked-nights.ts @@ -1,5 +1,5 @@ import Database from 'better-sqlite3'; -import { carryVias, dayStops, locatedIds, seatAmong } from '../nest/accommodations/night-seat'; +import { locatedIds, planViaCarry, sameOrder, seatAmong, type PinnedVia, type SeatRow } from '../nest/accommodations/night-seat'; /** * Seat every booked night where its check-in says, the way a night booked today is @@ -15,14 +15,16 @@ import { carryVias, dayStops, locatedIds, seatAmong } from '../nest/accommodatio * Only the stops a booking owns move (accommodation_id). A stop the traveller placed * themselves and then booked a night on is theirs, wherever it sits. The drawn roads * (roadtrip_vias) are pinned to positions among the day's located stops and are - * carried along by the rules the service applies (carryVias): a via follows the stop - * it was drawn after, a via behind a stop that is last stays with it, and one left - * without a leg goes. + * carried along by the rules the service applies (planViaCarry): a via follows the + * stop it was drawn after, a via behind a stop that is last stays with it, and one + * left without a leg goes. * - * Runs inside the migration's transaction. Re-runnable: the nights of a day are taken - * in the order the rule seats them (a night without a check-in first, then by - * check-in, ties by booking), so one pass leaves every night in its seat and a second - * pass moves nothing. Returns how many nights moved. + * Runs inside the migration's transaction, over the raw connection: the rule is + * night-seat.ts's, the statements are this file's, the way the services' are their + * repositories'. Re-runnable: the nights of a day are taken in the order the rule + * seats them (a night without a check-in first, then by check-in, ties by booking), + * so one pass leaves every night in its seat and a second pass moves nothing. Returns + * how many nights moved. */ export function reseatBookedNights(db: Database.Database): number { const nights = db.prepare(` @@ -31,12 +33,27 @@ export function reseatBookedNights(db: Database.Database): number { JOIN day_accommodations a ON a.id = da.accommodation_id ORDER BY da.day_id, a.check_in IS NOT NULL, a.check_in, a.id `).all() as Array<{ id: number; day_id: number; accommodation_id: number; check_in: string | null }>; + // A day's stops as the rule sees them (SeatRow): the same statement + // DayAssignmentsRepository.listSeatRows (AC7) runs for the services. + const dayStops = db.prepare(` + SELECT da.id, da.order_index, + CASE WHEN other.id IS NULL THEN COALESCE(da.assignment_time, p.place_time) ELSE other.check_in END AS at, + other.id AS night_id, (p.lat IS NOT NULL AND p.lng IS NOT NULL) AS located + FROM day_assignments da JOIN places p ON p.id = da.place_id + LEFT JOIN day_accommodations other ON other.id = da.accommodation_id + WHERE da.day_id = ? + ORDER BY da.order_index ASC, da.created_at ASC, da.id ASC + `); const setIndex = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ?'); + const viasOf = db.prepare('SELECT id, after_order_index, sequence FROM roadtrip_vias WHERE day_id = ?'); + const dropVia = db.prepare('DELETE FROM roadtrip_vias WHERE id = ? AND day_id = ?'); + const moveVia = db.prepare('UPDATE roadtrip_vias SET after_order_index = ? WHERE id = ? AND day_id = ?'); + const setSequence = db.prepare('UPDATE roadtrip_vias SET sequence = ? WHERE id = ? AND day_id = ?'); let seated = 0; const roads = { moved: 0, removed: 0 }; for (const night of nights) { - const rows = dayStops(db, night.day_id); + const rows = dayStops.all(night.day_id) as SeatRow[]; const own = rows.findIndex((row) => row.id === night.id); if (own < 0) continue; const others = rows.filter((row) => row.id !== night.id); @@ -46,11 +63,17 @@ export function reseatBookedNights(db: Database.Database): number { next.forEach((row, i) => setIndex.run(i, row.id)); seated += 1; - const carried = carryVias(db, night.day_id, locatedIds(rows), locatedIds(next)); - if (carried) { - roads.moved += carried.moved; - roads.removed += carried.removed; - } + + const before = locatedIds(rows); + const after = locatedIds(next); + if (sameOrder(before, after)) continue; + const plan = planViaCarry(viasOf.all(night.day_id) as PinnedVia[], before, after); + if (!plan) continue; + for (const viaId of plan.remove) dropVia.run(viaId, night.day_id); + for (const via of plan.moved) moveVia.run(via.after_order_index, via.id, night.day_id); + for (const via of plan.resequence) setSequence.run(via.sequence, via.id, night.day_id); + roads.moved += plan.moved.length; + roads.removed += plan.remove.length; } if (roads.moved || roads.removed) { console.log(`[DB] Re-pinned ${roads.moved} drawn road(s) behind the reseated nights, dropped ${roads.removed} left without a leg`); diff --git a/server/src/db/schema-snapshot.ts b/server/src/db/schema-snapshot.ts new file mode 100644 index 000000000..ea2720e83 --- /dev/null +++ b/server/src/db/schema-snapshot.ts @@ -0,0 +1,47 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +/** + * A migrated-but-empty database, serialised once per test run. + * + * Migrations are async and `database.ts::initDb()` is not — it runs at module + * load, long before anything can await. Most suites do not care (they mock the + * module or build their own database), but a handful use the singleton directly + * and need a schema to be there the moment they import it. + * + * So the vitest global setup migrates one database for the whole run and writes + * it here; every worker opens a copy. `better-sqlite3` deserialises a 1.5 MiB + * snapshot in well under a millisecond, against ~800ms to replay 242 migrations, + * so this is also what keeps the suite fast. + * + * Production never reads this — there `buildApp()` provides the await point. + */ + +/** Keyed by the migration set, so a snapshot from another branch is never reused. */ +function snapshotKey(): string { + const dir = path.join(__dirname, 'migrations'); + // A missing directory (e.g. a partial dist) degrades to a constant key; the + // global setup rewrites the file either way. + const names = fs.existsSync(dir) ? fs.readdirSync(dir).sort() : []; + return crypto.createHash('sha256').update(names.join('\n')).digest('hex').slice(0, 16); +} + +/** Outside the repo on purpose: nothing here belongs in a working tree or an image. */ +export function schemaSnapshotPath(): string { + return path.join(os.tmpdir(), `trek-schema-${snapshotKey()}.db`); +} + +export function writeSchemaSnapshot(snapshot: Buffer): void { + fs.writeFileSync(schemaSnapshotPath(), snapshot); +} + +/** The snapshot bytes, or null when no run has produced one. */ +export function readSchemaSnapshot(): Buffer | null { + try { + return fs.readFileSync(schemaSnapshotPath()); + } catch { + return null; + } +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts deleted file mode 100644 index 1df32e9ae..000000000 --- a/server/src/db/schema.ts +++ /dev/null @@ -1,619 +0,0 @@ -import Database from 'better-sqlite3'; - -function createTables(db: Database.Database): void { - db.exec(` - CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'user', - maps_api_key TEXT, - unsplash_api_key TEXT, - amap_api_key TEXT, - openweather_api_key TEXT, - avatar TEXT, - oidc_sub TEXT, - oidc_issuer TEXT, - last_login DATETIME, - mfa_enabled INTEGER DEFAULT 0, - mfa_secret TEXT, - mfa_backup_codes TEXT, - immich_url TEXT, - immich_access_token TEXT, - synology_url TEXT, - synology_username TEXT, - synology_password TEXT, - synology_sid TEXT, - must_change_password INTEGER DEFAULT 0, - password_version INTEGER NOT NULL DEFAULT 0, - feed_token TEXT, - is_guest INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS password_reset_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL UNIQUE, - expires_at DATETIME NOT NULL, - consumed_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_ip TEXT - ); - CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id); - CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash); - - CREATE TABLE IF NOT EXISTS webauthn_credentials ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - credential_id TEXT NOT NULL UNIQUE, - public_key BLOB NOT NULL, - counter INTEGER NOT NULL DEFAULT 0, - transports TEXT, - device_type TEXT, - backed_up INTEGER NOT NULL DEFAULT 0, - name TEXT, - aaguid TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - last_used_at DATETIME - ); - CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id); - - CREATE TABLE IF NOT EXISTS webauthn_challenges ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - challenge TEXT NOT NULL UNIQUE, - user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, - type TEXT NOT NULL, - expires_at INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at); - - CREATE TABLE IF NOT EXISTS settings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - key TEXT NOT NULL, - value TEXT, - UNIQUE(user_id, key) - ); - - CREATE TABLE IF NOT EXISTS trips ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title TEXT NOT NULL, - description TEXT, - start_date TEXT, - end_date TEXT, - currency TEXT DEFAULT 'EUR', - cover_image TEXT, - is_archived INTEGER DEFAULT 0, - reminder_days INTEGER DEFAULT 3, - feed_token TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS days ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - day_number INTEGER NOT NULL, - date TEXT, - notes TEXT, - title TEXT, - UNIQUE(trip_id, day_number) - ); - - CREATE TABLE IF NOT EXISTS categories ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - color TEXT DEFAULT '#6366f1', - icon TEXT DEFAULT '📍', - user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - color TEXT DEFAULT '#10b981', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS places ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - lat REAL, - lng REAL, - address TEXT, - category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, - price REAL, - currency TEXT, - reservation_status TEXT DEFAULT 'none', - reservation_notes TEXT, - reservation_datetime TEXT, - place_time TEXT, - end_time TEXT, - duration_minutes INTEGER DEFAULT 60, - notes TEXT, - image_url TEXT, - google_place_id TEXT, - google_ftid TEXT, - website TEXT, - phone TEXT, - transport_mode TEXT DEFAULT 'walking', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS place_tags ( - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (place_id, tag_id) - ); - - CREATE TABLE IF NOT EXISTS place_ratings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - rating INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(place_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS day_assignments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, - order_index INTEGER DEFAULT 0, - notes TEXT, - reservation_status TEXT DEFAULT 'none', - reservation_notes TEXT, - reservation_datetime TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS packing_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - name TEXT NOT NULL, - checked INTEGER DEFAULT 0, - category TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS photos ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - filename TEXT NOT NULL, - original_name TEXT NOT NULL, - file_size INTEGER, - mime_type TEXT, - caption TEXT, - taken_at TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS trip_files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL, - filename TEXT NOT NULL, - original_name TEXT NOT NULL, - file_size INTEGER, - mime_type TEXT, - description TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS reservations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, - end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL, - title TEXT NOT NULL, - accommodation_id TEXT, - reservation_time TEXT, - reservation_end_time TEXT, - location TEXT, - confirmation_number TEXT, - notes TEXT, - status TEXT DEFAULT 'pending', - type TEXT DEFAULT 'other', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS trip_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - invited_by INTEGER REFERENCES users(id), - added_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(trip_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS day_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - text TEXT NOT NULL, - time TEXT, - icon TEXT DEFAULT '📝', - sort_order REAL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS app_settings ( - key TEXT PRIMARY KEY, - value TEXT - ); - - CREATE TABLE IF NOT EXISTS budget_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - category TEXT NOT NULL DEFAULT 'Other', - name TEXT NOT NULL, - total_price REAL NOT NULL DEFAULT 0, - persons INTEGER DEFAULT NULL, - days INTEGER DEFAULT NULL, - note TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - -- Addon system - CREATE TABLE IF NOT EXISTS addons ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - type TEXT NOT NULL DEFAULT 'global', - icon TEXT DEFAULT 'Puzzle', - enabled INTEGER DEFAULT 0, - config TEXT DEFAULT '{}', - sort_order INTEGER DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS photo_providers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - icon TEXT DEFAULT 'Image', - enabled INTEGER DEFAULT 0, - sort_order INTEGER DEFAULT 0 - ); - - CREATE TABLE IF NOT EXISTS photo_provider_fields ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE, - field_key TEXT NOT NULL, - label TEXT NOT NULL, - input_type TEXT NOT NULL DEFAULT 'text', - placeholder TEXT, - hint TEXT, - required INTEGER DEFAULT 0, - secret INTEGER DEFAULT 0, - settings_key TEXT, - payload_key TEXT, - sort_order INTEGER DEFAULT 0, - UNIQUE(provider_id, field_key) - ); - - -- Vacay addon tables - CREATE TABLE IF NOT EXISTS vacay_plans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - block_weekends INTEGER DEFAULT 1, - holidays_enabled INTEGER DEFAULT 0, - holidays_region TEXT DEFAULT '', - school_holidays_enabled INTEGER DEFAULT 0, - company_holidays_enabled INTEGER DEFAULT 1, - carry_over_enabled INTEGER DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(owner_id) - ); - - CREATE TABLE IF NOT EXISTS vacay_plan_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - status TEXT DEFAULT 'pending', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(plan_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS vacay_user_colors ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - color TEXT DEFAULT '#6366f1', - UNIQUE(user_id, plan_id) - ); - - CREATE TABLE IF NOT EXISTS vacay_years ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - year INTEGER NOT NULL, - UNIQUE(plan_id, year) - ); - - CREATE TABLE IF NOT EXISTS vacay_user_years ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - year INTEGER NOT NULL, - vacation_days INTEGER DEFAULT 30, - carried_over INTEGER DEFAULT 0, - UNIQUE(user_id, plan_id, year) - ); - - CREATE TABLE IF NOT EXISTS vacay_entries ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - date TEXT NOT NULL, - note TEXT DEFAULT '', - UNIQUE(user_id, plan_id, date) - ); - - CREATE TABLE IF NOT EXISTS vacay_company_holidays ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - date TEXT NOT NULL, - note TEXT DEFAULT '', - UNIQUE(plan_id, date) - ); - - CREATE TABLE IF NOT EXISTS vacay_holiday_calendars ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, - type TEXT NOT NULL DEFAULT 'public_holiday', - region TEXT NOT NULL, - label TEXT, - color TEXT NOT NULL DEFAULT '#fecaca', - sort_order INTEGER NOT NULL DEFAULT 0 - ); - - -- Collections addon tables - CREATE TABLE IF NOT EXISTS collections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - description TEXT, - color TEXT DEFAULT '#6366f1', - icon TEXT DEFAULT 'Bookmark', - cover_image TEXT, - links TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collection_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - status TEXT NOT NULL DEFAULT 'pending', - role TEXT NOT NULL DEFAULT 'editor', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(collection_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS collection_places ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - name TEXT NOT NULL, - description TEXT, - lat REAL, - lng REAL, - address TEXT, - category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, - price REAL, - currency TEXT, - notes TEXT, - image_url TEXT, - google_place_id TEXT, - google_ftid TEXT, - osm_id TEXT, - website TEXT, - phone TEXT, - status TEXT NOT NULL DEFAULT 'idea', - source_trip_id INTEGER, - source_place_id INTEGER, - links TEXT, - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collection_place_ratings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - rating INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(collection_place_id, user_id) - ); - - CREATE TABLE IF NOT EXISTS collection_place_tags ( - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (collection_place_id, tag_id) - ); - - CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id); - CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id); - - -- Per-collection custom labels (distinct from the instance-wide tags table): - -- each list defines its own labels and every place can carry several. - CREATE TABLE IF NOT EXISTS collection_labels ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - name TEXT NOT NULL, - color TEXT DEFAULT '#6366f1', - sort_order INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collection_place_labels ( - collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, - label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE, - PRIMARY KEY (collection_place_id, label_id) - ); - - CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id); - CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id); - - CREATE TABLE IF NOT EXISTS day_accommodations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, - start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, - check_in TEXT, - check_in_end TEXT, - check_out TEXT, - confirmation TEXT, - notes TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - -- Collab addon tables - CREATE TABLE IF NOT EXISTS collab_notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - category TEXT DEFAULT 'General', - title TEXT NOT NULL, - content TEXT, - color TEXT DEFAULT '#6366f1', - pinned INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collab_polls ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - question TEXT NOT NULL, - options TEXT NOT NULL, - multiple INTEGER DEFAULT 0, - closed INTEGER DEFAULT 0, - deadline TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE TABLE IF NOT EXISTS collab_poll_votes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - option_index INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(poll_id, user_id, option_index) - ); - - CREATE TABLE IF NOT EXISTS collab_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - text TEXT NOT NULL, - reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id); - CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id); - CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id); - `); - - db.exec(` - CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id); - CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id); - CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id); - CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id); - CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id); - CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id); - CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id); - CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id); - CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id); - CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id); - CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id); - CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id); - CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id); - CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id); - CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id); - CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); - CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id); - - CREATE TABLE IF NOT EXISTS assignment_participants ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(assignment_id, user_id) - ); - CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id); - - CREATE TABLE IF NOT EXISTS audit_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - action TEXT NOT NULL, - resource TEXT, - details TEXT, - ip TEXT - ); - CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC); - - CREATE TABLE IF NOT EXISTS notifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), - scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), - target INTEGER NOT NULL, - sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title_key TEXT NOT NULL, - title_params TEXT DEFAULT '{}', - text_key TEXT NOT NULL, - text_params TEXT DEFAULT '{}', - positive_text_key TEXT, - negative_text_key TEXT, - positive_callback TEXT, - negative_callback TEXT, - response TEXT CHECK(response IN ('positive', 'negative')), - navigate_text_key TEXT, - navigate_target TEXT, - is_read INTEGER DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC); - - CREATE TABLE IF NOT EXISTS notification_channel_preferences ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - event_type TEXT NOT NULL, - channel TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - PRIMARY KEY (user_id, event_type, channel) - ); - CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id); - - CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL); - `); -} - -export { createTables }; diff --git a/server/src/db/seeders/AddonSeeder.ts b/server/src/db/seeders/AddonSeeder.ts new file mode 100644 index 000000000..5f35c0673 --- /dev/null +++ b/server/src/db/seeders/AddonSeeder.ts @@ -0,0 +1,172 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +interface AddonRow { + id: string; + name: string; + description: string; + type: string; + icon: string; + enabled: 0 | 1; + sort_order: number; +} + +const DEFAULT_ADDONS: AddonRow[] = [ + { + id: 'packing', + name: 'Lists', + description: 'Packing lists and to-do tasks for your trips', + type: 'trip', + icon: 'ListChecks', + enabled: 1, + sort_order: 0, + }, + { + id: 'budget', + name: 'Costs', + description: 'Track and split trip expenses', + type: 'trip', + icon: 'Wallet', + enabled: 1, + sort_order: 1, + }, + { + id: 'documents', + name: 'Documents', + description: 'Store and manage travel documents', + type: 'trip', + icon: 'FileText', + enabled: 1, + sort_order: 2, + }, + { + id: 'vacay', + name: 'Vacay', + description: 'Personal vacation day planner with calendar view', + type: 'global', + icon: 'CalendarDays', + enabled: 1, + sort_order: 10, + }, + { + id: 'atlas', + name: 'Atlas', + description: 'World map of your visited countries with travel stats', + type: 'global', + icon: 'Globe', + enabled: 1, + sort_order: 11, + }, + { + id: 'mcp', + name: 'MCP', + description: 'Model Context Protocol for AI assistant integration', + type: 'integration', + icon: 'Terminal', + enabled: 0, + sort_order: 12, + }, + { + id: 'naver_list_import', + name: 'Naver List Import', + description: 'Import places from a shared Naver Maps list', + type: 'integration', + icon: 'Link2', + enabled: 1, + sort_order: 13, + }, + { + id: 'collab', + name: 'Collab', + description: 'Notes, polls, and live chat for trip collaboration', + type: 'trip', + icon: 'Users', + enabled: 1, + sort_order: 6, + }, + { + id: 'roadtrip', + name: 'Road trip', + description: 'Drives with stops along the route, driving times, and arrival times that update themselves', + type: 'trip', + icon: 'Route', + enabled: 0, + sort_order: 7, + }, + { + id: 'tours', + name: 'Tours', + description: 'Hikes and other single-day tours that follow a route', + type: 'trip', + icon: 'Mountain', + enabled: 0, + sort_order: 8, + }, + { + id: 'journey', + name: 'Journey', + description: 'Trip tracking & travel journal — check-ins, photos, daily stories', + type: 'global', + icon: 'Compass', + enabled: 0, + sort_order: 35, + }, + { + id: 'airtrail', + name: 'AirTrail', + description: 'Sync flights from your AirTrail instance', + type: 'integration', + icon: 'Plane', + enabled: 0, + sort_order: 14, + }, + { + id: 'dawarich', + name: 'Dawarich', + description: + 'Read visits and recorded routes from your Dawarich instance — suggested journal entries, places and countries you confirm yourself', + type: 'integration', + icon: 'Dawarich', + enabled: 0, + sort_order: 17, + }, + { + id: 'llm_parsing', + name: 'AI Parsing', + description: 'LLM fallback for booking imports kitinerary cannot read', + type: 'integration', + icon: 'Sparkles', + enabled: 0, + sort_order: 15, + }, + { + id: 'collections', + name: 'Collections', + description: + 'Personal place library — save places across trips into named lists, copy into any trip, share with others', + type: 'global', + icon: 'Bookmark', + enabled: 0, + sort_order: 16, + }, +]; + +/** + * Seeds the addon shelf. + * + * `INSERT OR IGNORE` per row, not a table-level emptiness check: an operator who + * has toggled an addon keeps their choice, and a release that adds a new addon + * still gets its tile on an existing install. + */ +export class AddonSeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + for (const addon of DEFAULT_ADDONS) { + await connection.execute( + 'INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', + [addon.id, addon.name, addon.description, addon.type, addon.icon, addon.enabled, addon.sort_order], + ); + } + console.log('Default addons seeded'); + } +} diff --git a/server/src/db/seeders/AdminSeeder.ts b/server/src/db/seeders/AdminSeeder.ts new file mode 100644 index 000000000..61be9b4c2 --- /dev/null +++ b/server/src/db/seeders/AdminSeeder.ts @@ -0,0 +1,104 @@ +import { readEnv } from '../../app-config'; +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; + +/** bcrypt cost factor for the seeded admin password — kept in sync with authService. */ +const BCRYPT_COST = 12; + +/** + * Seeds run before the DB admin panel can be used, so only env vars are checked + * here. The granular password_login/password_registration DB toggles are only + * relevant after the first user exists; by then this seeder has already skipped + * via the userCount guard. + */ +function isOidcOnlyConfigured(): boolean { + const oidc = readEnv().oidc; + if (!oidc.only) return false; + return !!(oidc.issuer && oidc.clientId); +} + +/** + * Creates the first-run admin account. + * + * Only ever on an empty `users` table — `ADMIN_EMAIL`/`ADMIN_PASSWORD` are a + * first-run bootstrap, not an ongoing setting, and a later run says so out loud + * rather than silently ignoring them. + */ +export class AdminSeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + try { + const env_admin_email = readEnv().adminBootstrap.email; + const env_admin_pw = readEnv().adminBootstrap.password; + const adminEnvProvided = !!(env_admin_email || env_admin_pw); + + const rows = (await connection.execute('SELECT COUNT(*) as count FROM users')) as { count: number }[]; + if (rows[0].count > 0) { + // A common trip-up: people add the vars after the fact, restart, nothing + // changes, and there is no hint why. Say so instead of staying silent. + if (adminEnvProvided) { + console.warn( + '[admin] ADMIN_EMAIL/ADMIN_PASSWORD are set, but users already exist — these only apply on first run (empty database) and are being ignored.', + ); + console.warn( + '[admin] Change an existing password from Settings after signing in, reset the admin (see the Troubleshooting wiki), or start with an empty data volume to re-run setup.', + ); + } + return; + } + + // Demo mode seeds its own admin (admin@trek.app, username 'admin') right + // after this. Creating a first-run admin here would grab username 'admin' + // first and make the demo seeder fail on the UNIQUE(username) constraint. + if (readEnv().demo.enabled) return; + + if (isOidcOnlyConfigured()) { + console.log(''); + console.log('╔══════════════════════════════════════════════╗'); + console.log('║ TREK — OIDC-Only Mode ║'); + console.log('║ First SSO login will become admin. ║'); + console.log('╚══════════════════════════════════════════════╝'); + console.log(''); + return; + } + + let password: string; + let email: string; + if (env_admin_email && env_admin_pw) { + password = env_admin_pw; + email = env_admin_email; + } else { + // A partial config (only one of the two) is an easy mistake: neither + // value is used and a generated password is created instead. Flag it so + // the chosen credentials silently not working isn't a surprise. + if (adminEnvProvided) { + console.warn( + '[admin] Only one of ADMIN_EMAIL/ADMIN_PASSWORD is set — both are required for a custom admin. Falling back to admin@trek.local with a generated password (shown below).', + ); + } + password = crypto.randomBytes(12).toString('base64url'); + email = 'admin@trek.local'; + } + + const hash = bcrypt.hashSync(password, BCRYPT_COST); + await connection.execute( + 'INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)', + ['admin', email, hash, 'admin'], + ); + + console.log(''); + console.log('╔══════════════════════════════════════════════╗'); + console.log('║ TREK — First Run: Admin Account Created ║'); + console.log('╠══════════════════════════════════════════════╣'); + console.log(`║ Email: ${email.padEnd(33)}║`); + console.log(`║ Password: ${password.padEnd(33)}║`); + console.log('╚══════════════════════════════════════════════╝'); + console.log(''); + } catch (err: unknown) { + console.error('[ERROR] Error seeding admin account:', err instanceof Error ? err.message : err); + } + } +} diff --git a/server/src/db/seeders/CategorySeeder.ts b/server/src/db/seeders/CategorySeeder.ts new file mode 100644 index 000000000..527f89c11 --- /dev/null +++ b/server/src/db/seeders/CategorySeeder.ts @@ -0,0 +1,40 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +/** The starter categories a fresh install gets, in display order. */ +const DEFAULT_CATEGORIES: { name: string; color: string; icon: string }[] = [ + { name: 'Hotel', color: '#3b82f6', icon: '🏨' }, + { name: 'Restaurant', color: '#ef4444', icon: '🍽️' }, + { name: 'Attraction', color: '#8b5cf6', icon: '🏛️' }, + { name: 'Shopping', color: '#f59e0b', icon: '🛍️' }, + { name: 'Transport', color: '#6b7280', icon: '🚌' }, + { name: 'Activity', color: '#10b981', icon: '🎯' }, + { name: 'Bar/Cafe', color: '#f97316', icon: '☕' }, + { name: 'Beach', color: '#06b6d4', icon: '🏖️' }, + { name: 'Nature', color: '#84cc16', icon: '🌿' }, + { name: 'Other', color: '#6366f1', icon: '📍' }, +]; + +/** + * Seeds the default place categories. + * + * All-or-nothing on an empty table, exactly as `seeds.ts` did: once anything + * exists the operator owns the list, and re-adding a category they deleted would + * be the seeder overruling them. + */ +export class CategorySeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + const existing = (await connection.execute('SELECT COUNT(*) AS count FROM categories')) as { count: number }[]; + if (existing[0].count > 0) return; + + for (const category of DEFAULT_CATEGORIES) { + await connection.execute('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)', [ + category.name, + category.color, + category.icon, + ]); + } + console.log('Default categories seeded'); + } +} diff --git a/server/src/db/seeders/DatabaseSeeder.ts b/server/src/db/seeders/DatabaseSeeder.ts new file mode 100644 index 000000000..b54d68ad2 --- /dev/null +++ b/server/src/db/seeders/DatabaseSeeder.ts @@ -0,0 +1,21 @@ +import { AddonSeeder } from './AddonSeeder'; +import { AdminSeeder } from './AdminSeeder'; +import { CategorySeeder } from './CategorySeeder'; +import { DocumentProviderSeeder } from './DocumentProviderSeeder'; +import { PhotoProviderSeeder } from './PhotoProviderSeeder'; +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; +import { SchemaVersionSeeder } from './SchemaVersionSeeder'; + +/** + * The default seeder, replacing `db/seeds.ts::runSeeds()`. + * + * Order matters in one place only: `AdminSeeder` runs first because it is the + * one that keys off an empty `users` table, and the provider seeders run after + * `AddonSeeder` so the shelf exists before anything hangs off it. + */ +export class DatabaseSeeder extends Seeder { + async run(em: EntityManager): Promise { + await this.call(em, [AdminSeeder, CategorySeeder, AddonSeeder, PhotoProviderSeeder, DocumentProviderSeeder, SchemaVersionSeeder]); + } +} diff --git a/server/src/db/seeders/DocumentProviderSeeder.ts b/server/src/db/seeders/DocumentProviderSeeder.ts new file mode 100644 index 000000000..f49aeeb7b --- /dev/null +++ b/server/src/db/seeders/DocumentProviderSeeder.ts @@ -0,0 +1,32 @@ +import { DOCUMENT_PROVIDERS, DOCUMENT_PROVIDER_FIELDS } from '../document-provider-seed'; +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +/** + * Seeds the document providers. + * + * The rows come from `document-provider-seed.ts`, the same constants the + * migration that introduces the tables uses, so a fresh install and an upgraded + * one end up with identical rows. + */ +export class DocumentProviderSeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + + for (const p of DOCUMENT_PROVIDERS) { + await connection.execute( + 'INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)', + [p.id, p.name, p.description, p.icon, p.sort_order], + ); + } + + for (const f of DOCUMENT_PROVIDER_FIELDS) { + await connection.execute( + `INSERT OR IGNORE INTO document_provider_fields + (provider_id, field_key, label, input_type, placeholder, hint, required, secret, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.sort_order], + ); + } + } +} diff --git a/server/src/db/seeders/PhotoProviderSeeder.ts b/server/src/db/seeders/PhotoProviderSeeder.ts new file mode 100644 index 000000000..1006d8e11 --- /dev/null +++ b/server/src/db/seeders/PhotoProviderSeeder.ts @@ -0,0 +1,193 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +interface PhotoProviderRow { + id: string; + name: string; + description: string; + icon: string; + enabled: 0 | 1; + sort_order: number; +} + +interface PhotoProviderFieldRow { + provider_id: string; + field_key: string; + label: string; + input_type: string; + placeholder: string | null; + hint: string | null; + required: 0 | 1; + secret: 0 | 1; + settings_key: string | null; + payload_key: string; + sort_order: number; +} + +const PROVIDERS: PhotoProviderRow[] = [ + { id: 'immich', name: 'Immich', description: 'Immich photo provider', icon: 'Image', enabled: 0, sort_order: 0 }, + { + id: 'synologyphotos', + name: 'Synology Photos', + description: 'Synology Photos integration with separate account settings', + icon: 'Image', + enabled: 0, + sort_order: 1, + }, +]; + +/** `label` and `hint` are i18n key suffixes the client resolves as `memories.