mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge dev into feat/tours and port Tours onto MikroORM
dev now runs on MikroORM, so the positional migrations, seeds.ts and DatabaseService the Tours server side was built on are gone. Behaviour is unchanged; persistence moves onto dev's architecture: - one MikroORM migration for tour_types, tours and tour_waypoints, idempotent so instances that ran the old positional steps keep their data; the hike tour type is seeded inside it, the addon row in the AddonSeeder - entities and repositories for the three tables, tours domain on repositories and UnitOfWork - tour facet joins in places, assignments and days re-applied on the repository code Fixes found while porting: - GPX import checks addon, trip access and permission in the handler instead of guards, so a refused upload gets a 403/404 instead of a connection reset - copying a trip copies its tours and waypoints - a tour cannot be moved onto a day that already has it - updateTour checks existence inside its transaction Adds tours e2e tests and rewrites the tours unit tests onto the migrated test database.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -62,7 +62,7 @@ Nest owns everything: every domain is a DI module under `server/src/nest/<domain
|
||||
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
|
||||
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` 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 `<domain>.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.
|
||||
|
||||
+37
-37
@@ -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"
|
||||
}
|
||||
|
||||
@@ -290,8 +290,8 @@ describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => {
|
||||
const { baseElement } = render(<BackgroundTasksWidget />)
|
||||
|
||||
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(<BackgroundTasksWidget />)
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ describe('PackingListPanel', () => {
|
||||
const { container } = render(<PackingListPanel tripId={1} items={[item1, item2]} />);
|
||||
|
||||
// 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(<PackingListPanel tripId={1} items={[item1, item2]} />);
|
||||
|
||||
// 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(<PackingListPanel tripId={1} items={[item]} />);
|
||||
|
||||
// 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(<PackingListPanel tripId={1} items={[item1, item2]} />);
|
||||
|
||||
// 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'));
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ function setup(overrides: Partial<Props> = {}) {
|
||||
|
||||
/** 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)')
|
||||
|
||||
@@ -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 }) }),
|
||||
|
||||
@@ -14,9 +14,12 @@ import { renderIconMarkup } from './iconMarkup'
|
||||
|
||||
type IconComponent = Parameters<typeof createElement>[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')
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
// Capture native AbortController/AbortSignal BEFORE jsdom patches them
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('RtStopRow', () => {
|
||||
const { container } = render(<RtStopRow row={stopRow({ warning })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
|
||||
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(<RtStopRow row={stopRow({ warning: { index: 1, code: 'missedLeave', minutes: 20 } })} chrome={chrome} onOpen={vi.fn()} />)
|
||||
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)]')
|
||||
})
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Generated
+2565
-1283
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -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.",
|
||||
|
||||
Generated
+19
-19
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T>(value: T | symbol): T {
|
||||
export function orCancel<T>(value: T): Exclude<T, symbol> {
|
||||
if (isCancel(value)) {
|
||||
clackCancel('Cancelled.', { output: OUT });
|
||||
process.exit(0);
|
||||
}
|
||||
return value as T;
|
||||
return value as Exclude<T, symbol>;
|
||||
}
|
||||
|
||||
// ── prompts (cancel-checked, stderr-rendered) ────────────────────────────────
|
||||
|
||||
+5
-4
@@ -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/<domain>/` (`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<string, unknown>` + 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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
// Second fixture project, identical but with strictNullChecks ON. The server
|
||||
// compiles with `strict: false`, where `Promise<T> | null` collapses to
|
||||
// `Promise<T>` 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"]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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/<domain>/ (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/<domain>/ (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
|
||||
|
||||
+61
-40
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T>(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
|
||||
}
|
||||
}
|
||||
@@ -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<number> {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export class Sample {
|
||||
private dep: Dep = new Dep();
|
||||
|
||||
async loadOne(): Promise<number> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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`).');
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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'];
|
||||
}
|
||||
}
|
||||
+52
-24
@@ -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<INestApplication> {
|
||||
// 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<INestApplication> {
|
||||
// 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<INestApplication> {
|
||||
// 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<INestApplication> {
|
||||
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<INestApplication> {
|
||||
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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
+108
-111
@@ -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: <mock handle>, … }` 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<void> {
|
||||
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<void>) | null = null;
|
||||
function registerReinitializeHook(hook: () => Promise<void>): 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<void> {
|
||||
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 };
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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: `<col> + n` / `<col> - 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')]: <value-side> }` produces
|
||||
* `WHERE LOWER(email) = <value-side>`.
|
||||
*
|
||||
* **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(<column>) = 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(<col>)`, 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(<col>))` 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(<col>, <fallbackCol>)` — 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(<col>, ?)` — 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(?, <col>)` — 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(<col> - ?)`, 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 <alias>`, 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(<col>) as <alias>`. */
|
||||
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(<col>) as <alias>`. */
|
||||
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 <col> = ? 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `<col> 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(<col>, <start>[, <length>])`, 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 };
|
||||
|
||||
/**
|
||||
* `<part> || <part> || …` — 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(<col> 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(<col>) - 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(<col> - ?)` 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<DB, TB>` (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 `<DB, TB>`, 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}: `<ref> 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<SqlBool>`, 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<DB, TB extends keyof DB>(
|
||||
platform: Platform,
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
ref: ReferenceExpression<DB, TB>,
|
||||
): ExpressionWrapper<DB, TB, SqlBool> {
|
||||
if (platform instanceof SqlitePlatform) {
|
||||
return eb.fn<SqlBool>('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(<ref>,
|
||||
* <start>[, <length>])`, 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<DB, TB extends keyof DB>(
|
||||
platform: Platform,
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
ref: ReferenceExpression<DB, TB>,
|
||||
start: number,
|
||||
length?: number,
|
||||
): ExpressionWrapper<DB, TB, string> {
|
||||
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<DB, TB>[] = length === undefined
|
||||
? [ref, eb.val(start)]
|
||||
: [ref, eb.val(start), eb.val(length)];
|
||||
return eb.fn<string>('substr', args);
|
||||
}
|
||||
return unsupported(platform);
|
||||
}
|
||||
|
||||
/** A part of a `concatKysely()` expression — a column reference, a bound value, or a nested Kysely `Expression<string>` (composes freely, unlike `concat()`'s MikroORM `RawQueryFragment` form). */
|
||||
export type KyselyConcatPart<DB, TB extends keyof DB> =
|
||||
| { column: StringReference<DB, TB> }
|
||||
| { value: string }
|
||||
| { expression: Expression<string> };
|
||||
|
||||
/**
|
||||
* The Kysely-expression twin of {@link concat}: `<part> || <part> || …`,
|
||||
* 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<DB, TB extends keyof DB>(
|
||||
platform: Platform,
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
...parts: readonly KyselyConcatPart<DB, TB>[]
|
||||
): ExpressionWrapper<DB, TB, string> {
|
||||
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<DB, TB>): Expression<string> =>
|
||||
'column' in part ? eb.ref(part.column).$castTo<string>() : 'value' in part ? eb.val(part.value) : part.expression;
|
||||
let acc: Expression<string> = 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<DB, TB, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Kysely-expression twin of {@link castInteger}: `CAST(<ref> 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<DB, TB extends keyof DB>(
|
||||
platform: Platform,
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
ref: ReferenceExpression<DB, TB>,
|
||||
): ExpressionWrapper<DB, TB, number> {
|
||||
if (platform instanceof SqlitePlatform) return eb.cast<number>(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 = <value-side>`. 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(<column>)) = 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, ''), <correlated MIN+concat subquery>,
|
||||
// <this>)` 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', <ref> / 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.
|
||||
* `<ref>` 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<DB, TB extends keyof DB>(
|
||||
platform: Platform,
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
ref: ReferenceExpression<DB, TB>,
|
||||
): ExpressionWrapper<DB, TB, string> {
|
||||
if (platform instanceof SqlitePlatform) {
|
||||
const seconds = eb(ref, '/', eb.val(1000));
|
||||
return eb.fn<string>('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', '<sign>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<DB, TB extends keyof DB>(
|
||||
platform: Platform,
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
seconds: number,
|
||||
): ExpressionWrapper<DB, TB, string> {
|
||||
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<string>('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 <stateRef> = 'remote_missing' AND <fileIdRef> IS NOT NULL THEN
|
||||
* 'synced' ELSE <stateRef> 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<string> {
|
||||
if (platform instanceof SqlitePlatform) return sql<string>`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 <keyRef> IS ? THEN COALESCE(?, <ref>) 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 `<keyRef> 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 <testRef> IS NOT NULL THEN <thenRef> 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<AddonConfig>().nullable().default('{}'),
|
||||
sort_order: p.integer().nullable(),
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
@@ -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<DayAssignments>;
|
||||
assignment_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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),
|
||||
},
|
||||
});
|
||||
@@ -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<Users> | 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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
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<DawarichVisitSuggestions>(this);
|
||||
dawarich_visit_suggestions_collection1 = new Collection<DawarichVisitSuggestions>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
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),
|
||||
},
|
||||
});
|
||||
@@ -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<BudgetItems>;
|
||||
budget_item_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<BudgetItems>;
|
||||
budget_item_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
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<Users> | null;
|
||||
paid_by_user_id?: number | null;
|
||||
expense_date?: string | null;
|
||||
reservation?: Ref<Reservations> | null;
|
||||
reservation_id?: number | null;
|
||||
currency?: string | null;
|
||||
exchange_rate!: number & Opt;
|
||||
ticket_json?: string | null;
|
||||
place?: Ref<Places> | null;
|
||||
place_id?: number | null;
|
||||
budget_item_members_collection = new Collection<BudgetItemMembers>(this);
|
||||
budget_item_payers_collection = new Collection<BudgetItemPayers>(this);
|
||||
file_links_collection = new Collection<FileLinks>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
trip_id!: number;
|
||||
fromUser!: Ref<Users>;
|
||||
from_user_id!: number;
|
||||
toUser!: Ref<Users>;
|
||||
to_user_id!: number;
|
||||
amount!: number & Opt;
|
||||
created_at?: string | null;
|
||||
createdByUser?: Ref<Users> | 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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Users> | null;
|
||||
user_id?: number | null;
|
||||
created_at?: string | null;
|
||||
collection_places_collection = new Collection<CollectionPlaces>(this);
|
||||
places_collection = new Collection<Places>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
trip_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<CollabMessages>;
|
||||
message_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
trip_id!: number;
|
||||
user!: Ref<Users>;
|
||||
user_id!: number;
|
||||
text!: string;
|
||||
reply_to?: number | null;
|
||||
created_at?: string | null;
|
||||
deleted?: number | null = 0;
|
||||
replyToRef?: Ref<CollabMessages> | null;
|
||||
collab_message_reactions_collection = new Collection<CollabMessageReactions>(this);
|
||||
collab_messages_collection = new Collection<CollabMessages>(this);
|
||||
trip_files_collection = new Collection<TripFiles>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
trip_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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<TripFiles>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<CollabPolls>;
|
||||
poll_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
trip_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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<CollabPollVotes>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Collections>;
|
||||
collection_id!: number;
|
||||
name!: string;
|
||||
color?: string | null = '#6366f1';
|
||||
sort_order?: number | null = 0;
|
||||
created_at?: string | null;
|
||||
collection_place_labels_inverse = new Collection<CollectionPlaces>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Collections>;
|
||||
collection_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<CollectionPlaces>;
|
||||
collection_place_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Collections>;
|
||||
collection_id!: number;
|
||||
owner!: Ref<Users>;
|
||||
owner_id!: number;
|
||||
saved_by?: number | null;
|
||||
name!: string;
|
||||
description?: string | null;
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
address?: string | null;
|
||||
category?: Ref<Categories> | 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<Users> | null;
|
||||
collection_place_labels = new Collection<CollectionLabels>(this);
|
||||
collection_place_tags = new Collection<Tags>(this);
|
||||
collection_place_ratings_collection = new Collection<CollectionPlaceRatings>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
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<CollectionLabels>(this);
|
||||
collection_members_collection = new Collection<CollectionMembers>(this);
|
||||
collection_places_collection = new Collection<CollectionPlaces>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Users> | 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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
user_id!: number;
|
||||
source_visit_id!: string;
|
||||
trip?: Ref<Trips> | 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<Places> | null;
|
||||
accepted_place_id?: number | null;
|
||||
accepted_journal_entry_id?: number | null;
|
||||
acceptedBucketListItem?: Ref<BucketList> | null;
|
||||
accepted_bucket_list_item_id?: number | null;
|
||||
matchedBucketListItem?: Ref<BucketList> | 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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
trip_id!: number;
|
||||
place?: Ref<Places> | null;
|
||||
place_id?: number | null;
|
||||
startDay!: Ref<Days>;
|
||||
start_day_id!: number;
|
||||
endDay!: Ref<Days>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Days>;
|
||||
day_id!: number;
|
||||
place!: Ref<Places>;
|
||||
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<AssignmentParticipants>(this);
|
||||
file_links_collection = new Collection<FileLinks>(this);
|
||||
reservations_collection = new Collection<Reservations>(this);
|
||||
roadtrip_day_boundaries_collection = new Collection<RoadtripDayBoundaries>(this);
|
||||
roadtrip_day_boundaries_collection1 = new Collection<RoadtripDayBoundaries>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Days>;
|
||||
day_id!: number;
|
||||
trip!: Ref<Trips>;
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
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<DayAccommodations>(this);
|
||||
day_accommodations_collection1 = new Collection<DayAccommodations>(this);
|
||||
day_assignments_collection = new Collection<DayAssignments>(this);
|
||||
day_notes_collection = new Collection<DayNotes>(this);
|
||||
photos_collection = new Collection<Photos>(this);
|
||||
reservation_day_positions_collection = new Collection<ReservationDayPositions>(this);
|
||||
reservations_collection = new Collection<Reservations>(this);
|
||||
reservations_collection1 = new Collection<Reservations>(this);
|
||||
roadtrip_day_tracks: Ref<RoadtripDayTracks> | null = null;
|
||||
roadtrip_vias_collection = new Collection<RoadtripVias>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
trip_id!: number;
|
||||
provider!: Ref<DocumentProviders>;
|
||||
provider_id!: string;
|
||||
ownerUser!: Ref<Users>;
|
||||
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<TripDocumentLinks>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<DocumentProviders>;
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<DocumentConnections>(this);
|
||||
document_provider_fields_collection = new Collection<DocumentProviderFields>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<TripDocumentLinks>;
|
||||
link_id!: number;
|
||||
trip!: Ref<Trips>;
|
||||
trip_id!: number;
|
||||
file?: Ref<TripFiles> | 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(),
|
||||
},
|
||||
});
|
||||
@@ -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<TripFiles>;
|
||||
file_id!: number;
|
||||
reservation?: Ref<Reservations> | null;
|
||||
reservation_id?: number | null;
|
||||
assignment?: Ref<DayAssignments> | null;
|
||||
assignment_id?: number | null;
|
||||
place?: Ref<Places> | null;
|
||||
place_id?: number | null;
|
||||
created_at?: string | null;
|
||||
budgetItem?: Ref<BudgetItems> | 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'),
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
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'),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips> | null;
|
||||
trip_id?: number | null;
|
||||
createdByRef!: Ref<Users>;
|
||||
}
|
||||
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Journeys>;
|
||||
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<Users> | null;
|
||||
createdByRef?: Ref<Users> | 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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Journeys>;
|
||||
journey_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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),
|
||||
},
|
||||
});
|
||||
@@ -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<Journeys>;
|
||||
journey_id!: number;
|
||||
sourceTrip?: Ref<Trips> | null;
|
||||
source_trip_id?: number | null;
|
||||
sourcePlace?: Ref<Places> | null;
|
||||
source_place_id?: number | null;
|
||||
author!: Ref<Users>;
|
||||
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<JourneyEntryPhotos>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<JourneyEntries>;
|
||||
entry_id!: number;
|
||||
journeyPhoto!: Ref<JourneyPhotos>;
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Journeys>;
|
||||
journey_id!: number;
|
||||
photo!: Ref<TrekPhotos>;
|
||||
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<JourneyEntryPhotos>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Journeys>;
|
||||
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<Users>;
|
||||
}
|
||||
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Journeys>;
|
||||
journey_id!: number;
|
||||
trip!: Ref<Trips>;
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
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<JourneyBooks>(this);
|
||||
journey_contributors_collection = new Collection<JourneyContributors>(this);
|
||||
journey_entries_collection = new Collection<JourneyEntries>(this);
|
||||
journey_photos_collection = new Collection<JourneyPhotos>(this);
|
||||
journey_share_tokens: Ref<JourneyShareTokens> | null = null;
|
||||
journey_trips_collection = new Collection<JourneyTrips>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
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),
|
||||
},
|
||||
});
|
||||
@@ -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<Users> | null;
|
||||
sender_id?: number | null;
|
||||
recipient!: Ref<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Users> | 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<OauthConsents>(this);
|
||||
oauth_tokens_collection = new Collection<OauthTokens>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<OauthClients>;
|
||||
client_id!: string;
|
||||
user!: Ref<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<OauthClients>;
|
||||
client_id!: string;
|
||||
user!: Ref<Users>;
|
||||
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<OauthTokens> | null;
|
||||
parent_token_id?: number | null;
|
||||
audience?: string | null;
|
||||
oauth_tokens_collection = new Collection<OauthTokens>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
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<Users> | null;
|
||||
user_id?: number | null;
|
||||
packing_bag_members = new Collection<Users>(this);
|
||||
packing_items_collection = new Collection<PackingItems>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
trip_id!: number;
|
||||
category_name!: string;
|
||||
user!: Ref<Users>;
|
||||
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),
|
||||
},
|
||||
});
|
||||
@@ -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<PackingItems>;
|
||||
item_id!: number;
|
||||
user!: Ref<Users>;
|
||||
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`),
|
||||
},
|
||||
});
|
||||
@@ -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<Trips>;
|
||||
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<PackingBags> | null;
|
||||
bag_id?: number | null;
|
||||
quantity: number & Opt = 1;
|
||||
updated_at?: string | null;
|
||||
is_private: number & Opt = 0;
|
||||
owner?: Ref<Users> | null;
|
||||
owner_id?: number | null;
|
||||
packed_quantity?: number | null;
|
||||
packing_item_contributors = new Collection<Users>(this);
|
||||
packing_item_recipients = new Collection<Users>(this);
|
||||
packing_item_contributors_collection = new Collection<PackingItemContributors>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<PackingTemplates>;
|
||||
template_id!: number;
|
||||
name!: string;
|
||||
sort_order: number & Opt = 0;
|
||||
packing_template_items_collection = new Collection<PackingTemplateItems>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<PackingTemplateCategories>;
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
packing_template_categories_collection = new Collection<PackingTemplateCategories>(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(),
|
||||
},
|
||||
});
|
||||
@@ -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<Users>;
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<PhotoProviders>;
|
||||
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(),
|
||||
},
|
||||
});
|
||||
@@ -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<PhotoProviderFields>(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(),
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user