migrations + seeders

This commit is contained in:
jubnl
2026-09-20 21:38:32 +02:00
parent 55ad55e90b
commit 70e970453e
274 changed files with 10126 additions and 191 deletions
+1 -1
View File
@@ -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. Schema, migrations and seeding are **MikroORM's** (`db/migrations/`, `db/seeders/`), applied at boot from `buildApp()`; the ORM shares the one better-sqlite3 connection rather than opening its own. Queries are still raw SQL through `DatabaseService`. `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`.
+170 -2
View File
@@ -4806,6 +4806,137 @@
"integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==",
"license": "MIT"
},
"node_modules/@mikro-orm/cli": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@mikro-orm/cli/-/cli-7.2.1.tgz",
"integrity": "sha512-YNt8HKsb90awZqcionp1oZgL2ZLRyGl76x0lYdcfVkYkYY0zrNQs3ZZ76+6AS/FXSX5oEp6q94LCIiYwnr04jA==",
"license": "MIT",
"dependencies": {
"@mikro-orm/core": "7.2.1",
"mikro-orm": "7.2.1",
"yargs": "17.7.2"
},
"bin": {
"mikro-orm": "cli.js"
},
"engines": {
"node": ">= 22.17.0"
}
},
"node_modules/@mikro-orm/cli/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@mikro-orm/cli/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@mikro-orm/cli/node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@mikro-orm/cli/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/@mikro-orm/cli/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@mikro-orm/cli/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@mikro-orm/cli/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@mikro-orm/cli/node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@mikro-orm/cli/node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/@mikro-orm/core": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@mikro-orm/core/-/core-7.2.1.tgz",
@@ -4842,6 +4973,21 @@
"@mikro-orm/core": "7.2.1"
}
},
"node_modules/@mikro-orm/migrations": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@mikro-orm/migrations/-/migrations-7.2.1.tgz",
"integrity": "sha512-sxGcB7na0AkcREsD5U3xP+Psb/nhQFkx7/eY1Nsqq9MBs67O5RoCL97bn3lmgq6GwKZFbktMuuOGozq42+GaNg==",
"license": "MIT",
"dependencies": {
"@mikro-orm/sql": "7.2.1"
},
"engines": {
"node": ">= 22.17.0"
},
"peerDependencies": {
"@mikro-orm/core": "7.2.1"
}
},
"node_modules/@mikro-orm/nestjs": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@mikro-orm/nestjs/-/nestjs-7.1.0.tgz",
@@ -4878,6 +5024,18 @@
"@mikro-orm/core": "7.2.1"
}
},
"node_modules/@mikro-orm/seeder": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@mikro-orm/seeder/-/seeder-7.2.1.tgz",
"integrity": "sha512-aZtpFXNW8MnP4/e351SMLuVuarHK3g713puMjRcHmLN38j+VLVL/uSoDAk+/KTP2neuNOe+5I8ioC3fTJI/qDQ==",
"license": "MIT",
"engines": {
"node": ">= 22.17.0"
},
"peerDependencies": {
"@mikro-orm/core": "7.2.1"
}
},
"node_modules/@mikro-orm/sql": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/@mikro-orm/sql/-/sql-7.2.1.tgz",
@@ -11465,7 +11623,6 @@
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -16002,6 +16159,15 @@
"node": ">=8.6"
}
},
"node_modules/mikro-orm": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/mikro-orm/-/mikro-orm-7.2.1.tgz",
"integrity": "sha512-IUUYgO5T+S67sw1Xp5r3vL0JeYnkampUIRJaHG6AzTcjujRbyRUOEc6NVAIsojKdXq9WbWRU0hI6ivUv9qSDLA==",
"license": "MIT",
"engines": {
"node": ">= 22.17.0"
}
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
@@ -22279,7 +22445,6 @@
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
@@ -22482,8 +22647,11 @@
"dependencies": {
"@aws-lite/client": "^0.23.7",
"@aws-lite/s3": "^0.2.7",
"@mikro-orm/cli": "^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",
"@modelcontextprotocol/sdk": "^1.30.0",
"@nestjs/common": "^11.2.5",
"@nestjs/config": "^4.0.4",
+2 -2
View File
@@ -32,14 +32,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 now; the schema comes from the **MikroORM** migrations in `db/migrations/` and the seeders in `db/seeders/`, applied by `db/orm.ts` from `buildApp()` before anything reads. `db/orm-driver.ts` hands the ORM that same connection instead of opening a second one — with `:memory:` under test, a second connection is a second, empty database. `schema.ts`/`migrations.ts`/`seeds.ts` still exist for the test suite only, guarded by `tests/unit/db/schema-parity.test.ts`. `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.
- **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 is gone; `db/migrations.ts` is retained for tests and is not appended to.)
- **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.
+9
View File
@@ -24,11 +24,20 @@
"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/migrations": "^7.2.1",
"@mikro-orm/nestjs": "^7.1.0",
"@mikro-orm/postgresql": "^7.2.1",
"@mikro-orm/seeder": "^7.2.1",
"@modelcontextprotocol/sdk": "^1.30.0",
"@nestjs/common": "^11.2.5",
"@nestjs/config": "^4.0.4",
+32 -1
View File
@@ -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`).');
}
+26 -20
View File
@@ -1,25 +1,28 @@
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 { 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 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
@@ -71,6 +74,13 @@ export async function buildApp(): Promise<INestApplication> {
// 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 });
// 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(),
@@ -95,10 +105,7 @@ export async function buildApp(): Promise<INestApplication> {
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 —
@@ -159,8 +166,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);
+113 -53
View File
@@ -1,45 +1,41 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { readEnv } from '../app-config';
import { applyDurabilityPragmas } from './durability';
import { createTables } from './schema';
import { runMigrations } from './migrations';
import { runSeeds } from './seeds';
import { Place, Tag } from '../types';
import { resolveDbPath } from './db-path';
import { applyDurabilityPragmas } from './durability';
import { readSchemaSnapshot } from './schema-snapshot';
import 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 = new Database(dbPath);
const snapshot = isTest ? readSchemaSnapshot() : null;
_db = snapshot ? new Database(snapshot) : new Database(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');
@@ -50,15 +46,22 @@ 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;
}
const db = new Proxy({} as Database.Database, {
get(_, prop: string | symbol) {
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
@@ -71,7 +74,16 @@ 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.
*/
function runDemoSeed(): void {
if (!readEnv().demo.enabled) return;
try {
const { seedDemoData } = require('../demo/demo-seed');
seedDemoData(_db);
@@ -80,19 +92,40 @@ if (readEnv().demo.enabled) {
}
}
/**
* 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');
}
@@ -111,37 +144,51 @@ interface PlaceWithTags extends Place {
}
function getPlaceWithTags(placeId: number | string): PlaceWithTags | null {
const place = db.prepare(`
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;
`,
)
.get(placeId) as PlaceWithCategory | undefined;
if (!place) return null;
const tags = db.prepare(`
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[];
`,
)
.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(`
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 }[];
`,
)
.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,
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,
@@ -156,17 +203,30 @@ interface TripAccess {
}
function canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined {
return db.prepare(`
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;
`,
)
.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 {
db,
closeDb,
reinitialize,
getPlaceWithTags,
canAccessTrip,
isOwner,
getRawConnection,
registerReinitializeHook,
runDemoSeed,
};
export type { TripAccess, PlaceWithTags };
+34
View File
@@ -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');
}
+260 -30
View File
@@ -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);
@@ -1,6 +1,6 @@
import { Collection, type Ref, defineEntity, p, EntityRepository } from '@mikro-orm/core';
import { CollabMessageReactions } from './CollabMessageReactions.entity';
import { TripFiles } from './TripFiles.entity.entity';
import { TripFiles } from './TripFiles.entity';
import { Trips } from './Trips.entity';
import { Users } from './Users.entity';
@@ -1,6 +1,22 @@
import { PrimaryKeyProp, type Ref, defineEntity, p, EntityRepository } from '@mikro-orm/core';
import { DayAssignments } from './DayAssignments.entity';
import { Trips } from './Trips.entity';
import { PrimaryKeyProp, type Ref, defineEntity, p, EntityRepository } from '@mikro-orm/core';
export class RoadtripDayBoundaries {
[PrimaryKeyProp]?: ['trip', 'dayNumber'];
@@ -8,7 +24,7 @@ export class RoadtripDayBoundaries {
dayNumber!: number;
fromAssignment!: Ref<DayAssignments>;
toAssignment?: Ref<DayAssignments> | null;
fraction!: unknown;
fraction!: number; // was `unknown`
}
export class RoadtripDayBoundariesRepository extends EntityRepository<RoadtripDayBoundaries> {}
@@ -19,7 +35,7 @@ export const RoadtripDayBoundariesSchema = defineEntity({
checks: [
{
name: 'roadtrip_day_boundaries_fraction_check',
expression: 'fraction BETWEEN 0 AND 1), PRIMARY KEY (trip_id, day_number',
expression: 'fraction BETWEEN 0 AND 1',
},
{
name: 'roadtrip_day_boundaries_day_number_check',
+145
View File
@@ -0,0 +1,145 @@
import type { Migration } from '@mikro-orm/migrations';
/**
* Helpers shared by the migrations that replay the pre-MikroORM history.
*
* They live outside `db/migrations/` on purpose: the migrator globs that
* directory with `!(*.d).{js,ts,cjs}` and would try to load a helper module as
* a migration class.
*/
/** A single `pragma_table_info` row — only the column name is ever read. */
interface ColumnRow {
name: string;
}
/**
* Adds a column only when the table does not already have it.
*
* The legacy bootstrap ran `schema.ts::createTables()` before the numbered
* migrations, and that file was a *moving* snapshot of the schema rather than a
* frozen v0 — so a database created late already had columns that early steps
* add here. The old runner absorbed the clash by catching "duplicate column
* name"; this checks first instead, so a genuine failure still propagates.
*
* `definition` is the full column spec, e.g. `status TEXT DEFAULT 'pending'`.
*/
export async function addColumnIfMissing(
migration: Migration,
table: string,
column: string,
definition: string,
): Promise<void> {
const existing = (await migration.execute('select name from pragma_table_info(?) where name = ?', [
table,
column,
])) as ColumnRow[];
if (existing.length > 0) return;
await migration.execute(`alter table \`${table}\` add column ${definition}`);
}
/** True when `table` exists in the current database. */
export async function tableExists(migration: Migration, table: string): Promise<boolean> {
const rows = await migration.execute(`select name from sqlite_master where type = 'table' and name = ?`, [table]);
return rows.length > 0;
}
/** The `CREATE TABLE` DDL SQLite stored for `table`, or null when it does not exist. */
export async function tableSql(migration: Migration, table: string): Promise<string | null> {
const rows = (await migration.execute(`select sql from sqlite_master where name = ?`, [table])) as {
sql: string | null;
}[];
return rows[0]?.sql ?? null;
}
/**
* Runs a statement whose failure the legacy step logged and swallowed.
*
* Kept as its own helper so "this step is allowed to fail" stays visible in the
* generated migrations instead of hiding behind a bare try/catch.
*/
export async function execBestEffort(migration: Migration, sql: string, params?: unknown[]): Promise<void> {
try {
await migration.execute(sql, params);
} catch (err: unknown) {
console.warn('[migrations] non-fatal step failed:', err instanceof Error ? err.message : err);
}
}
/** The column names of `table`, or an empty set when the table does not exist. */
export async function columnNames(migration: Migration, table: string): Promise<Set<string>> {
const rows = (await migration.execute('select name from pragma_table_info(?)', [table])) as ColumnRow[];
return new Set(rows.map((r) => r.name));
}
/**
* Runs a statement that the legacy step allowed to fail *only* because the table
* it touches may not exist yet on that database. Any other error still throws.
*/
export async function execUnlessTableMissing(migration: Migration, sql: string, params?: unknown[]): Promise<void> {
try {
await migration.execute(sql, params);
} catch (err: unknown) {
if (!(err instanceof Error) || !err.message.includes('no such table')) throw err;
}
}
/**
* Runs a group of statements the legacy step wrapped in a single swallowing
* try/catch. A failure part-way through leaves the earlier statements applied,
* which is exactly what the original did.
*/
export async function bestEffort(run: () => Promise<void>): Promise<void> {
try {
await run();
} catch (err: unknown) {
console.warn('[migrations] non-fatal step failed:', err instanceof Error ? err.message : err);
}
}
/** A booked stay whose check-in day has no matching stop on the plan. */
interface UnplacedStay {
id: number;
place_id: number;
start_day_id: number;
}
/**
* Gives every booked stay the day stop it would get today, and returns how many
* were placed.
*
* Road-trip mode builds its drive out of `day_assignments` alone, so a hotel
* booked in Days mode was invisible there. Two legacy steps run this same sweep:
* once at the release, and once more afterwards for the bookings written in the
* seconds while the old container was still answering. The `NOT EXISTS` decides
* both times, so repeating it is safe — a stay that already has its stop is
* passed over, and a place the traveller planned by hand keeps its row unclaimed.
*/
export async function attachStayStopsToCheckInDay(migration: Migration): Promise<number> {
const stays = (await migration.execute(`
SELECT a.id, a.place_id, a.start_day_id
FROM day_accommodations a
WHERE a.place_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM day_assignments da
WHERE da.day_id = a.start_day_id AND da.place_id = a.place_id
)
ORDER BY a.id
`)) as UnplacedStay[];
for (const stay of stays) {
// Arriving somewhere is what the day was for, so the stop goes last — the
// same position a stay booked today lands in.
await migration.execute(
`INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id)
VALUES (?, ?, COALESCE((SELECT MAX(order_index) + 1 FROM day_assignments WHERE day_id = ?), 0), ?)`,
[stay.start_day_id, stay.place_id, stay.start_day_id, stay.id],
);
await migration.execute(
`UPDATE places SET stop_type = 'hotel' WHERE id = ? AND (stop_type IS NULL OR stop_type = '')`,
[stay.place_id],
);
}
return stays.length;
}
@@ -0,0 +1,774 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Baseline schema — every table `db/schema.ts::createTables()` created on boot,
* before the numbered steps in `db/migrations.ts` ran.
*
* This is not a frozen "v0": `createTables()` was edited as features landed, so it
* is a snapshot of the schema as it stood when the ORM migration took place. The
* `IF NOT EXISTS` guards are kept verbatim, and the numbered steps that follow
* replay on top of it — each one guarded so a column this file already declares
* is not added twice.
*
* The trailing `schema_version` table is the legacy runner's own bookkeeping,
* which it created before running step 1. It is left empty here: the migration
* state now lives in the migrator's table, and nothing in the app reads this
* one. It is kept because step 129 rebuilds it and it is still an entity.
*/
export class Migration20200101000000_baseline_schema extends Migration {
override name = 'Migration20200101000000_baseline_schema';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
maps_api_key TEXT,
unsplash_api_key TEXT,
amap_api_key TEXT,
openweather_api_key TEXT,
avatar TEXT,
oidc_sub TEXT,
oidc_issuer TEXT,
last_login DATETIME,
mfa_enabled INTEGER DEFAULT 0,
mfa_secret TEXT,
mfa_backup_codes TEXT,
immich_url TEXT,
immich_access_token TEXT,
synology_url TEXT,
synology_username TEXT,
synology_password TEXT,
synology_sid TEXT,
must_change_password INTEGER DEFAULT 0,
password_version INTEGER NOT NULL DEFAULT 0,
feed_token TEXT,
is_guest INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
consumed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_ip TEXT
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash)`);
this.addSql(`
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
public_key BLOB NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
transports TEXT,
device_type TEXT,
backed_up INTEGER NOT NULL DEFAULT 0,
name TEXT,
aaguid TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)`);
this.addSql(`
CREATE TABLE IF NOT EXISTS webauthn_challenges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
challenge TEXT NOT NULL UNIQUE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)`);
this.addSql(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT,
UNIQUE(user_id, key)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
start_date TEXT,
end_date TEXT,
currency TEXT DEFAULT 'EUR',
cover_image TEXT,
is_archived INTEGER DEFAULT 0,
reminder_days INTEGER DEFAULT 3,
feed_token TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_number INTEGER NOT NULL,
date TEXT,
notes TEXT,
title TEXT,
UNIQUE(trip_id, day_number)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT '📍',
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#10b981',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS places (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
lat REAL,
lng REAL,
address TEXT,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
price REAL,
currency TEXT,
reservation_status TEXT DEFAULT 'none',
reservation_notes TEXT,
reservation_datetime TEXT,
place_time TEXT,
end_time TEXT,
duration_minutes INTEGER DEFAULT 60,
notes TEXT,
image_url TEXT,
google_place_id TEXT,
google_ftid TEXT,
website TEXT,
phone TEXT,
transport_mode TEXT DEFAULT 'walking',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS place_tags (
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (place_id, tag_id)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS place_ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(place_id, user_id)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS day_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE,
order_index INTEGER DEFAULT 0,
notes TEXT,
reservation_status TEXT DEFAULT 'none',
reservation_notes TEXT,
reservation_datetime TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS packing_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
checked INTEGER DEFAULT 0,
category TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
caption TEXT,
taken_at TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS trip_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS reservations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
title TEXT NOT NULL,
accommodation_id TEXT,
reservation_time TEXT,
reservation_end_time TEXT,
location TEXT,
confirmation_number TEXT,
notes TEXT,
status TEXT DEFAULT 'pending',
type TEXT DEFAULT 'other',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS trip_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
invited_by INTEGER REFERENCES users(id),
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS day_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
text TEXT NOT NULL,
time TEXT,
icon TEXT DEFAULT '📝',
sort_order REAL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS budget_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'Other',
name TEXT NOT NULL,
total_price REAL NOT NULL DEFAULT 0,
persons INTEGER DEFAULT NULL,
days INTEGER DEFAULT NULL,
note TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
-- Addon system
CREATE TABLE IF NOT EXISTS addons (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
type TEXT NOT NULL DEFAULT 'global',
icon TEXT DEFAULT 'Puzzle',
enabled INTEGER DEFAULT 0,
config TEXT DEFAULT '{}',
sort_order INTEGER DEFAULT 0
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS photo_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
icon TEXT DEFAULT 'Image',
enabled INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS photo_provider_fields (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE,
field_key TEXT NOT NULL,
label TEXT NOT NULL,
input_type TEXT NOT NULL DEFAULT 'text',
placeholder TEXT,
hint TEXT,
required INTEGER DEFAULT 0,
secret INTEGER DEFAULT 0,
settings_key TEXT,
payload_key TEXT,
sort_order INTEGER DEFAULT 0,
UNIQUE(provider_id, field_key)
)
`);
this.addSql(`
-- Vacay addon tables
CREATE TABLE IF NOT EXISTS vacay_plans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
block_weekends INTEGER DEFAULT 1,
holidays_enabled INTEGER DEFAULT 0,
holidays_region TEXT DEFAULT '',
school_holidays_enabled INTEGER DEFAULT 0,
company_holidays_enabled INTEGER DEFAULT 1,
carry_over_enabled INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner_id)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS vacay_plan_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(plan_id, user_id)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS vacay_user_colors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
UNIQUE(user_id, plan_id)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS vacay_years (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
UNIQUE(plan_id, year)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS vacay_user_years (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
year INTEGER NOT NULL,
vacation_days INTEGER DEFAULT 30,
carried_over INTEGER DEFAULT 0,
UNIQUE(user_id, plan_id, year)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS vacay_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
date TEXT NOT NULL,
note TEXT DEFAULT '',
UNIQUE(user_id, plan_id, date)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS vacay_company_holidays (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
date TEXT NOT NULL,
note TEXT DEFAULT '',
UNIQUE(plan_id, date)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS vacay_holiday_calendars (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE,
type TEXT NOT NULL DEFAULT 'public_holiday',
region TEXT NOT NULL,
label TEXT,
color TEXT NOT NULL DEFAULT '#fecaca',
sort_order INTEGER NOT NULL DEFAULT 0
)
`);
this.addSql(`
-- Collections addon tables
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
color TEXT DEFAULT '#6366f1',
icon TEXT DEFAULT 'Bookmark',
cover_image TEXT,
links TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS collection_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
role TEXT NOT NULL DEFAULT 'editor',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, user_id)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS collection_places (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
name TEXT NOT NULL,
description TEXT,
lat REAL,
lng REAL,
address TEXT,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
price REAL,
currency TEXT,
notes TEXT,
image_url TEXT,
google_place_id TEXT,
google_ftid TEXT,
osm_id TEXT,
website TEXT,
phone TEXT,
status TEXT NOT NULL DEFAULT 'idea',
source_trip_id INTEGER,
source_place_id INTEGER,
links TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS collection_place_ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_place_id, user_id)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS collection_place_tags (
collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (collection_place_id, tag_id)
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id)`);
this.addSql(
`CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id)`,
);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id)`);
this.addSql(`
-- Per-collection custom labels (distinct from the instance-wide tags table):
-- each list defines its own labels and every place can carry several.
CREATE TABLE IF NOT EXISTS collection_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT DEFAULT '#6366f1',
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS collection_place_labels (
collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE,
PRIMARY KEY (collection_place_id, label_id)
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id)`);
this.addSql(
`CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id)`,
);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id)`);
this.addSql(`
CREATE TABLE IF NOT EXISTS day_accommodations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
check_in TEXT,
check_in_end TEXT,
check_out TEXT,
confirmation TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
-- Collab addon tables
CREATE TABLE IF NOT EXISTS collab_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category TEXT DEFAULT 'General',
title TEXT NOT NULL,
content TEXT,
color TEXT DEFAULT '#6366f1',
pinned INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS collab_polls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question TEXT NOT NULL,
options TEXT NOT NULL,
multiple INTEGER DEFAULT 0,
closed INTEGER DEFAULT 0,
deadline TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS collab_poll_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
option_index INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(poll_id, user_id, option_index)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS collab_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id)`);
this.addSql(`
CREATE TABLE IF NOT EXISTS assignment_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(assignment_id, user_id)
)
`);
this.addSql(
`CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id)`,
);
this.addSql(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details TEXT,
ip TEXT
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`);
this.addSql(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')),
scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')),
target INTEGER NOT NULL,
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title_key TEXT NOT NULL,
title_params TEXT DEFAULT '{}',
text_key TEXT NOT NULL,
text_params TEXT DEFAULT '{}',
positive_text_key TEXT,
negative_text_key TEXT,
positive_callback TEXT,
negative_callback TEXT,
response TEXT CHECK(response IN ('positive', 'negative')),
navigate_text_key TEXT,
navigate_target TEXT,
is_read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(
`CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC)`,
);
this.addSql(
`CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC)`,
);
this.addSql(`
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
channel TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, event_type, channel)
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id)`);
this.addSql(
`CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL)`,
);
this.addSql(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 1 (`db/migrations.ts`).
*/
export class Migration20200101000100_users_add_unsplash_api_key extends Migration {
override name = 'Migration20200101000100_users_add_unsplash_api_key';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'unsplash_api_key', `unsplash_api_key TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 2 (`db/migrations.ts`).
*/
export class Migration20200101000200_users_add_openweather_api_key extends Migration {
override name = 'Migration20200101000200_users_add_openweather_api_key';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'openweather_api_key', `openweather_api_key TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 3 (`db/migrations.ts`).
*/
export class Migration20200101000300_places_add_duration_minutes extends Migration {
override name = 'Migration20200101000300_places_add_duration_minutes';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'places', 'duration_minutes', `duration_minutes INTEGER DEFAULT 60`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 4 (`db/migrations.ts`).
*/
export class Migration20200101000400_places_add_notes extends Migration {
override name = 'Migration20200101000400_places_add_notes';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'places', 'notes', `notes TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 5 (`db/migrations.ts`).
*/
export class Migration20200101000500_places_add_image_url extends Migration {
override name = 'Migration20200101000500_places_add_image_url';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'places', 'image_url', `image_url TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 6 (`db/migrations.ts`).
*/
export class Migration20200101000600_places_add_transport_mode extends Migration {
override name = 'Migration20200101000600_places_add_transport_mode';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'places', 'transport_mode', `transport_mode TEXT DEFAULT 'walking'`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 7 (`db/migrations.ts`).
*/
export class Migration20200101000700_days_add_title extends Migration {
override name = 'Migration20200101000700_days_add_title';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'days', 'title', `title TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 8 (`db/migrations.ts`).
*/
export class Migration20200101000800_reservations_add_status extends Migration {
override name = 'Migration20200101000800_reservations_add_status';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'reservations', 'status', `status TEXT DEFAULT 'pending'`);
}
}
@@ -0,0 +1,18 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 9 (`db/migrations.ts`).
*/
export class Migration20200101000900_trip_files_add_reservation_id extends Migration {
override name = 'Migration20200101000900_trip_files_add_reservation_id';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'trip_files',
'reservation_id',
`reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL`,
);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 10 (`db/migrations.ts`).
*/
export class Migration20200101001000_reservations_add_type extends Migration {
override name = 'Migration20200101001000_reservations_add_type';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'reservations', 'type', `type TEXT DEFAULT 'other'`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 11 (`db/migrations.ts`).
*/
export class Migration20200101001100_trips_add_cover_image extends Migration {
override name = 'Migration20200101001100_trips_add_cover_image';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'trips', 'cover_image', `cover_image TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 12 (`db/migrations.ts`).
*/
export class Migration20200101001200_day_notes_add_icon extends Migration {
override name = 'Migration20200101001200_day_notes_add_icon';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'day_notes', 'icon', `icon TEXT DEFAULT '📝'`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 13 (`db/migrations.ts`).
*/
export class Migration20200101001300_trips_add_is_archived extends Migration {
override name = 'Migration20200101001300_trips_add_is_archived';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'trips', 'is_archived', `is_archived INTEGER DEFAULT 0`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 14 (`db/migrations.ts`).
*/
export class Migration20200101001400_categories_add_user_id extends Migration {
override name = 'Migration20200101001400_categories_add_user_id';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'categories', 'user_id', `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 15 (`db/migrations.ts`).
*/
export class Migration20200101001500_users_add_avatar extends Migration {
override name = 'Migration20200101001500_users_add_avatar';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'avatar', `avatar TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 16 (`db/migrations.ts`).
*/
export class Migration20200101001600_users_add_oidc_sub extends Migration {
override name = 'Migration20200101001600_users_add_oidc_sub';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'oidc_sub', `oidc_sub TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 17 (`db/migrations.ts`).
*/
export class Migration20200101001700_users_add_oidc_issuer extends Migration {
override name = 'Migration20200101001700_users_add_oidc_issuer';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'oidc_issuer', `oidc_issuer TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 18 (`db/migrations.ts`).
*/
export class Migration20200101001800_users_add_last_login extends Migration {
override name = 'Migration20200101001800_users_add_last_login';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'last_login', `last_login DATETIME`);
}
}
@@ -0,0 +1,43 @@
import { tableSql } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 19 (`db/migrations.ts`).
*
* Drops the `NOT NULL DEFAULT 1` on `budget_items.persons`/`days` so an expense
* can leave them unset. SQLite cannot alter a column default, hence the rebuild;
* it only fires on databases whose stored DDL still carries the old constraint.
*
* The legacy step matched the bare string `NOT NULL DEFAULT 1` anywhere in the
* table's DDL. That is narrowed here to the two columns it is actually about:
* step 132 later adds `exchange_rate REAL NOT NULL DEFAULT 1`, so the loose test
* reports true on a fully-migrated table and the 10-column rebuild below then
* fails against its 17 columns. The legacy runner never hit that because
* `schema_version` stopped the step re-running; nothing gates it here.
*/
export class Migration20200101001900_create_budget_items_new extends Migration {
override name = 'Migration20200101001900_create_budget_items_new';
override async up(): Promise<void> {
const sql = await tableSql(this, 'budget_items');
if (!sql || !/\bpersons\s+INTEGER\s+NOT\s+NULL\s+DEFAULT\s+1/i.test(sql)) return;
await this.execute(`
CREATE TABLE budget_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'Other',
name TEXT NOT NULL,
total_price REAL NOT NULL DEFAULT 0,
persons INTEGER DEFAULT NULL,
days INTEGER DEFAULT NULL,
note TEXT,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await this.execute(`INSERT INTO budget_items_new SELECT * FROM budget_items`);
await this.execute(`DROP TABLE budget_items`);
await this.execute(`ALTER TABLE budget_items_new RENAME TO budget_items`);
}
}
@@ -0,0 +1,17 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 20 (`db/migrations.ts`).
*/
export class Migration20200101002000_day_accommodations_add_check_in extends Migration {
override name = 'Migration20200101002000_day_accommodations_add_check_in';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'day_accommodations', 'check_in', `check_in TEXT`);
await addColumnIfMissing(this, 'day_accommodations', 'check_out', `check_out TEXT`);
await addColumnIfMissing(this, 'day_accommodations', 'confirmation', `confirmation TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 21 (`db/migrations.ts`).
*/
export class Migration20200101002100_places_add_end_time extends Migration {
override name = 'Migration20200101002100_places_add_end_time';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'places', 'end_time', `end_time TEXT`);
}
}
@@ -0,0 +1,31 @@
import { addColumnIfMissing, execBestEffort } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 22 (`db/migrations.ts`).
*
* Moves reservation state off the place and onto the day assignment, so the same
* place booked on two days keeps two independent bookings. The copy is
* best-effort: it reads `places` columns that a later step removes, and the
* original step logged and swallowed a failure rather than blocking the boot.
*/
export class Migration20200101002200_day_assignments_add_reservation_status extends Migration {
override name = 'Migration20200101002200_day_assignments_add_reservation_status';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'day_assignments', 'reservation_status', `reservation_status TEXT DEFAULT 'none'`);
await addColumnIfMissing(this, 'day_assignments', 'reservation_notes', `reservation_notes TEXT`);
await addColumnIfMissing(this, 'day_assignments', 'reservation_datetime', `reservation_datetime TEXT`);
await execBestEffort(
this,
`
UPDATE day_assignments SET
reservation_status = (SELECT reservation_status FROM places WHERE places.id = day_assignments.place_id),
reservation_notes = (SELECT reservation_notes FROM places WHERE places.id = day_assignments.place_id),
reservation_datetime = (SELECT reservation_datetime FROM places WHERE places.id = day_assignments.place_id)
WHERE place_id IN (SELECT id FROM places WHERE reservation_status IS NOT NULL AND reservation_status != 'none')
`,
);
}
}
@@ -0,0 +1,18 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 23 (`db/migrations.ts`).
*/
export class Migration20200101002300_reservations_add_assignment_id extends Migration {
override name = 'Migration20200101002300_reservations_add_assignment_id';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'reservations',
'assignment_id',
`assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL`,
);
}
}
@@ -0,0 +1,19 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 24 (`db/migrations.ts`).
*/
export class Migration20200101002400_create_assignment_participants extends Migration {
override name = 'Migration20200101002400_create_assignment_participants';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS assignment_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(assignment_id, user_id)
)
`);
}
}
@@ -0,0 +1,69 @@
import { execBestEffort } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 25 (`db/migrations.ts`).
*
* The Collab addon: shared notes, polls and trip chat.
*/
export class Migration20200101002500_create_collab_notes extends Migration {
override name = 'Migration20200101002500_create_collab_notes';
override async up(): Promise<void> {
await this.execute(`
CREATE TABLE IF NOT EXISTS collab_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category TEXT DEFAULT 'General',
title TEXT NOT NULL,
content TEXT,
color TEXT DEFAULT '#6366f1',
pinned INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await this.execute(`
CREATE TABLE IF NOT EXISTS collab_polls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
question TEXT NOT NULL,
options TEXT NOT NULL,
multiple INTEGER DEFAULT 0,
closed INTEGER DEFAULT 0,
deadline TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await this.execute(`
CREATE TABLE IF NOT EXISTS collab_poll_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
option_index INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(poll_id, user_id, option_index)
)
`);
await this.execute(`
CREATE TABLE IF NOT EXISTS collab_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id)`);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id)`);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id)`);
await execBestEffort(
this,
`INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)`,
);
}
}
@@ -0,0 +1,24 @@
import { addColumnIfMissing, execBestEffort } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 26 (`db/migrations.ts`).
*/
export class Migration20200101002600_day_assignments_add_assignment_time extends Migration {
override name = 'Migration20200101002600_day_assignments_add_assignment_time';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'day_assignments', 'assignment_time', `assignment_time TEXT`);
await addColumnIfMissing(this, 'day_assignments', 'assignment_end_time', `assignment_end_time TEXT`);
await execBestEffort(
this,
`
UPDATE day_assignments SET
assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id),
assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id)
`,
);
}
}
@@ -0,0 +1,24 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 27 (`db/migrations.ts`).
*/
export class Migration20200101002700_create_budget_item_members extends Migration {
override name = 'Migration20200101002700_create_budget_item_members';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS budget_item_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
paid INTEGER NOT NULL DEFAULT 0,
UNIQUE(budget_item_id, user_id)
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_item_members_item ON budget_item_members(budget_item_id)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id)`);
}
}
@@ -0,0 +1,23 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 28 (`db/migrations.ts`).
*/
export class Migration20200101002800_create_collab_message_reactions extends Migration {
override name = 'Migration20200101002800_create_collab_message_reactions';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS collab_message_reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES collab_messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(message_id, user_id, emoji)
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_reactions_msg ON collab_message_reactions(message_id)`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 29 (`db/migrations.ts`).
*/
export class Migration20200101002900_collab_messages_add_deleted extends Migration {
override name = 'Migration20200101002900_collab_messages_add_deleted';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'collab_messages', 'deleted', `deleted INTEGER DEFAULT 0`);
}
}
@@ -0,0 +1,20 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 30 (`db/migrations.ts`).
*/
export class Migration20200101003000_trip_files_add_note_id extends Migration {
override name = 'Migration20200101003000_trip_files_add_note_id';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'trip_files',
'note_id',
`note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL`,
);
await addColumnIfMissing(this, 'collab_notes', 'website', `website TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 31 (`db/migrations.ts`).
*/
export class Migration20200101003100_reservations_add_reservation_end_time extends Migration {
override name = 'Migration20200101003100_reservations_add_reservation_end_time';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'reservations', 'reservation_end_time', `reservation_end_time TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 32 (`db/migrations.ts`).
*/
export class Migration20200101003200_places_add_osm_id extends Migration {
override name = 'Migration20200101003200_places_add_osm_id';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'places', 'osm_id', `osm_id TEXT`);
}
}
@@ -0,0 +1,22 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 33 (`db/migrations.ts`).
*/
export class Migration20200101003300_trip_files_add_uploaded_by extends Migration {
override name = 'Migration20200101003300_trip_files_add_uploaded_by';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'trip_files',
'uploaded_by',
`uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL`,
);
await addColumnIfMissing(this, 'trip_files', 'starred', `starred INTEGER DEFAULT 0`);
await addColumnIfMissing(this, 'trip_files', 'deleted_at', `deleted_at TEXT`);
}
}
@@ -0,0 +1,20 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 34 (`db/migrations.ts`).
*/
export class Migration20200101003400_reservations_add_accommodation_id extends Migration {
override name = 'Migration20200101003400_reservations_add_accommodation_id';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'reservations',
'accommodation_id',
`accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL`,
);
await addColumnIfMissing(this, 'reservations', 'metadata', `metadata TEXT`);
}
}
@@ -0,0 +1,22 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 35 (`db/migrations.ts`).
*/
export class Migration20200101003500_create_invite_tokens extends Migration {
override name = 'Migration20200101003500_create_invite_tokens';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS invite_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT UNIQUE NOT NULL,
max_uses INTEGER NOT NULL DEFAULT 1,
used_count INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
}
@@ -0,0 +1,15 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 36 (`db/migrations.ts`).
*/
export class Migration20200101003600_users_add_mfa_enabled extends Migration {
override name = 'Migration20200101003600_users_add_mfa_enabled';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'mfa_enabled', `mfa_enabled INTEGER DEFAULT 0`);
await addColumnIfMissing(this, 'users', 'mfa_secret', `mfa_secret TEXT`);
}
}
@@ -0,0 +1,20 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 37 (`db/migrations.ts`).
*/
export class Migration20200101003700_create_packing_category_assignees extends Migration {
override name = 'Migration20200101003700_create_packing_category_assignees';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS packing_category_assignees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category_name TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(trip_id, category_name, user_id)
)
`);
}
}
@@ -0,0 +1,40 @@
import { execBestEffort } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 38 (`db/migrations.ts`).
*/
export class Migration20200101003800_create_packing_templates extends Migration {
override name = 'Migration20200101003800_create_packing_templates';
override async up(): Promise<void> {
await this.execute(`
CREATE TABLE IF NOT EXISTS packing_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await this.execute(`
CREATE TABLE IF NOT EXISTS packing_template_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL REFERENCES packing_templates(id) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
)
`);
await execBestEffort(this, `DROP TABLE IF EXISTS packing_template_items`);
await this.execute(`
CREATE TABLE packing_template_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
)
`);
}
}
@@ -0,0 +1,32 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 39 (`db/migrations.ts`).
*/
export class Migration20200101003900_create_packing_bags extends Migration {
override name = 'Migration20200101003900_create_packing_bags';
override async up(): Promise<void> {
await this.execute(`
CREATE TABLE IF NOT EXISTS packing_bags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6366f1',
weight_limit_grams INTEGER,
sort_order INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
await addColumnIfMissing(this, 'packing_items', 'weight_grams', `weight_grams INTEGER`);
await addColumnIfMissing(
this,
'packing_items',
'bag_id',
`bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL`,
);
}
}
@@ -0,0 +1,20 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 40 (`db/migrations.ts`).
*/
export class Migration20200101004000_create_visited_countries extends Migration {
override name = 'Migration20200101004000_create_visited_countries';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS visited_countries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
country_code TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, country_code)
)
`);
}
}
@@ -0,0 +1,23 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 41 (`db/migrations.ts`).
*/
export class Migration20200101004100_create_bucket_list extends Migration {
override name = 'Migration20200101004100_create_bucket_list';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS bucket_list (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
lat REAL,
lng REAL,
country_code TEXT,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 42 (`db/migrations.ts`).
*/
export class Migration20200101004200_vacay_plans_add_weekend_days extends Migration {
override name = 'Migration20200101004200_vacay_plans_add_weekend_days';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'vacay_plans', 'weekend_days', `weekend_days TEXT DEFAULT '0,6'`);
}
}
@@ -0,0 +1,35 @@
import { addColumnIfMissing, execBestEffort } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 43 (`db/migrations.ts`).
*
* Immich integration: per-user credentials, the linked-asset table, and the
* Photos addon tile (disabled by default).
*/
export class Migration20200101004300_users_add_immich_url extends Migration {
override name = 'Migration20200101004300_users_add_immich_url';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'immich_url', `immich_url TEXT`);
await addColumnIfMissing(this, 'users', 'immich_api_key', `immich_api_key TEXT`);
await this.execute(`
CREATE TABLE IF NOT EXISTS trip_photos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
immich_asset_id TEXT NOT NULL,
shared INTEGER NOT NULL DEFAULT 1,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, immich_asset_id)
)
`);
await execBestEffort(
this,
`INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)`,
['memories', 'Photos', 'trip', 'Image', 0, 7],
);
}
}
@@ -0,0 +1,24 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 44 (`db/migrations.ts`).
*/
export class Migration20200101004400_create_file_links extends Migration {
override name = 'Migration20200101004400_create_file_links';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS file_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE,
reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE,
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE,
place_id INTEGER REFERENCES places(id) ON DELETE CASCADE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(file_id, reservation_id),
UNIQUE(file_id, assignment_id),
UNIQUE(file_id, place_id)
)
`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 45 (`db/migrations.ts`).
*/
export class Migration20200101004500_reservations_add_day_plan_position extends Migration {
override name = 'Migration20200101004500_reservations_add_day_plan_position';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'reservations', 'day_plan_position', `day_plan_position REAL DEFAULT NULL`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 46 (`db/migrations.ts`).
*/
export class Migration20200101004600_budget_items_add_paid_by_user_id extends Migration {
override name = 'Migration20200101004600_budget_items_add_paid_by_user_id';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'budget_items', 'paid_by_user_id', `paid_by_user_id INTEGER REFERENCES users(id)`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 47 (`db/migrations.ts`).
*/
export class Migration20200101004700_bucket_list_add_target_date extends Migration {
override name = 'Migration20200101004700_bucket_list_add_target_date';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'bucket_list', 'target_date', `target_date TEXT DEFAULT NULL`);
}
}
@@ -0,0 +1,26 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 48 (`db/migrations.ts`).
*/
export class Migration20200101004800_create_notification_preferences extends Migration {
override name = 'Migration20200101004800_create_notification_preferences';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS notification_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notify_trip_invite INTEGER DEFAULT 1,
notify_booking_change INTEGER DEFAULT 1,
notify_trip_reminder INTEGER DEFAULT 1,
notify_vacay_invite INTEGER DEFAULT 1,
notify_photos_shared INTEGER DEFAULT 1,
notify_collab_message INTEGER DEFAULT 1,
notify_packing_tagged INTEGER DEFAULT 1,
notify_webhook INTEGER DEFAULT 0,
UNIQUE(user_id)
)
`);
}
}
@@ -0,0 +1,39 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 49 (`db/migrations.ts`).
*/
export class Migration20200101004900_notification_preferences_add_notify_vacay_invite extends Migration {
override name = 'Migration20200101004900_notification_preferences_add_notify_vacay_invite';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'notification_preferences',
'notify_vacay_invite',
`notify_vacay_invite INTEGER DEFAULT 1`,
);
await addColumnIfMissing(
this,
'notification_preferences',
'notify_photos_shared',
`notify_photos_shared INTEGER DEFAULT 1`,
);
await addColumnIfMissing(
this,
'notification_preferences',
'notify_collab_message',
`notify_collab_message INTEGER DEFAULT 1`,
);
await addColumnIfMissing(
this,
'notification_preferences',
'notify_packing_tagged',
`notify_packing_tagged INTEGER DEFAULT 1`,
);
}
}
@@ -0,0 +1,25 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 50 (`db/migrations.ts`).
*/
export class Migration20200101005000_create_share_tokens extends Migration {
override name = 'Migration20200101005000_create_share_tokens';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS share_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by INTEGER NOT NULL REFERENCES users(id),
share_map INTEGER DEFAULT 1,
share_bookings INTEGER DEFAULT 1,
share_packing INTEGER DEFAULT 0,
share_budget INTEGER DEFAULT 0,
share_collab INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
}
}
@@ -0,0 +1,21 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 51 (`db/migrations.ts`).
*/
export class Migration20200101005100_share_tokens_add_share_map extends Migration {
override name = 'Migration20200101005100_share_tokens_add_share_map';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'share_tokens', 'share_map', `share_map INTEGER DEFAULT 1`);
await addColumnIfMissing(this, 'share_tokens', 'share_bookings', `share_bookings INTEGER DEFAULT 1`);
await addColumnIfMissing(this, 'share_tokens', 'share_packing', `share_packing INTEGER DEFAULT 0`);
await addColumnIfMissing(this, 'share_tokens', 'share_budget', `share_budget INTEGER DEFAULT 0`);
await addColumnIfMissing(this, 'share_tokens', 'share_collab', `share_collab INTEGER DEFAULT 0`);
}
}
@@ -0,0 +1,24 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 52 (`db/migrations.ts`).
*/
export class Migration20200101005200_create_audit_log extends Migration {
override name = 'Migration20200101005200_create_audit_log';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
resource TEXT,
details TEXT,
ip TEXT
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 53 (`db/migrations.ts`).
*/
export class Migration20200101005300_users_add_mfa_backup_codes extends Migration {
override name = 'Migration20200101005300_users_add_mfa_backup_codes';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'mfa_backup_codes', `mfa_backup_codes TEXT`);
}
}
@@ -0,0 +1,24 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 54 (`db/migrations.ts`).
*
* MCP long-lived API tokens
*/
export class Migration20200101005400_mcp_long_lived_api_tokens extends Migration {
override name = 'Migration20200101005400_mcp_long_lived_api_tokens';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS mcp_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME
)
`);
}
}
@@ -0,0 +1,19 @@
import { execBestEffort } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 55 (`db/migrations.ts`).
*
* MCP addon entry.
*/
export class Migration20200101005500_mcp_addon_entry extends Migration {
override name = 'Migration20200101005500_mcp_addon_entry';
override async up(): Promise<void> {
await execBestEffort(
this,
`INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`,
['mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12],
);
}
}
@@ -0,0 +1,14 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 56 (`db/migrations.ts`).
*
* Index on mcp_tokens.token_hash
*/
export class Migration20200101005600_index_on_mcp_tokens extends Migration {
override name = 'Migration20200101005600_index_on_mcp_tokens';
override up(): void {
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash)`);
}
}
@@ -0,0 +1,15 @@
import { execBestEffort } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 57 (`db/migrations.ts`).
*
* Ensure MCP addon type is 'integration'.
*/
export class Migration20200101005700_ensure_mcp_addon_type_is_integration extends Migration {
override name = 'Migration20200101005700_ensure_mcp_addon_type_is_integration';
override async up(): Promise<void> {
await execBestEffort(this, `UPDATE addons SET type = 'integration' WHERE id = 'mcp'`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 58 (`db/migrations.ts`).
*/
export class Migration20200101005800_places_add_route_geometry extends Migration {
override name = 'Migration20200101005800_places_add_route_geometry';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'places', 'route_geometry', `route_geometry TEXT`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 59 (`db/migrations.ts`).
*/
export class Migration20200101005900_users_add_must_change_password extends Migration {
override name = 'Migration20200101005900_users_add_must_change_password';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'must_change_password', `must_change_password INTEGER DEFAULT 0`);
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 60 (`db/migrations.ts`).
*/
export class Migration20200101010000_trips_add_reminder_days extends Migration {
override name = 'Migration20200101010000_trips_add_reminder_days';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'trips', 'reminder_days', `reminder_days INTEGER DEFAULT 3`);
}
}
@@ -0,0 +1,20 @@
import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 61 (`db/migrations.ts`).
*
* Encrypt any plaintext oidc_client_secret left in app_settings.
*/
export class Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left extends Migration {
override name = 'Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left';
override async up(): Promise<void> {
const rows = (await this.execute(`SELECT value FROM app_settings WHERE key = 'oidc_client_secret'`)) as {
value: string | null;
}[];
const value = rows[0]?.value;
if (!value || value.startsWith('enc:v1:')) return;
await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'oidc_client_secret'`, [encrypt_api_key(value)]);
}
}
@@ -0,0 +1,20 @@
import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 62 (`db/migrations.ts`).
*
* Encrypt any plaintext smtp_pass left in app_settings.
*/
export class Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in extends Migration {
override name = 'Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in';
override async up(): Promise<void> {
const rows = (await this.execute(`SELECT value FROM app_settings WHERE key = 'smtp_pass'`)) as {
value: string | null;
}[];
const value = rows[0]?.value;
if (!value || value.startsWith('enc:v1:')) return;
await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'smtp_pass'`, [encrypt_api_key(value)]);
}
}
@@ -0,0 +1,23 @@
import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 63 (`db/migrations.ts`).
*
* Encrypt any plaintext immich_api_key values in the users table.
*/
export class Migration20200101010300_encrypt_any_plaintext_immich_api_key_values extends Migration {
override name = 'Migration20200101010300_encrypt_any_plaintext_immich_api_key_values';
override async up(): Promise<void> {
const rows = (await this.execute(
`SELECT id, immich_api_key FROM users WHERE immich_api_key IS NOT NULL AND immich_api_key != '' AND immich_api_key NOT LIKE 'enc:v1:%'`,
)) as { id: number; immich_api_key: string }[];
for (const row of rows) {
await this.execute(`UPDATE users SET immich_api_key = ? WHERE id = ?`, [
encrypt_api_key(row.immich_api_key),
row.id,
]);
}
}
}
@@ -0,0 +1,13 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 64 (`db/migrations.ts`).
*/
export class Migration20200101010400_budget_items_add_expense_date extends Migration {
override name = 'Migration20200101010400_budget_items_add_expense_date';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'budget_items', 'expense_date', `expense_date TEXT DEFAULT NULL`);
}
}
@@ -0,0 +1,26 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 65 (`db/migrations.ts`).
*/
export class Migration20200101010500_create_trip_album_links extends Migration {
override name = 'Migration20200101010500_create_trip_album_links';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS trip_album_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
immich_album_id TEXT NOT NULL,
album_name TEXT NOT NULL DEFAULT '',
sync_enabled INTEGER NOT NULL DEFAULT 1,
last_synced_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, immich_album_id)
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`);
}
}
@@ -0,0 +1,42 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 66 (`db/migrations.ts`).
*/
export class Migration20200101010600_create_notifications extends Migration {
override name = 'Migration20200101010600_create_notifications';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')),
scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')),
target INTEGER NOT NULL,
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title_key TEXT NOT NULL,
title_params TEXT DEFAULT '{}',
text_key TEXT NOT NULL,
text_params TEXT DEFAULT '{}',
positive_text_key TEXT,
negative_text_key TEXT,
positive_callback TEXT,
negative_callback TEXT,
response TEXT CHECK(response IN ('positive', 'negative')),
navigate_text_key TEXT,
navigate_target TEXT,
is_read INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(
`CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC)`,
);
this.addSql(
`CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC)`,
);
}
}
@@ -0,0 +1,54 @@
import { columnNames, tableExists } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
const TRIP_PHOTOS_DDL = `
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
asset_id TEXT NOT NULL,
provider TEXT NOT NULL DEFAULT 'immich',
shared INTEGER NOT NULL DEFAULT 1,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, asset_id, provider)
`;
/**
* Legacy migration step 67 (`db/migrations.ts`).
*
* Normalize trip_photos to the provider-based schema used by current routes.
* The table arrived in two shapes — Immich-only (`immich_asset_id`) and the
* provider-aware one — so the rebuild reads whichever columns are actually
* present and fills the rest with the Immich defaults.
*/
export class Migration20200101010700_create_trip_photos extends Migration {
override name = 'Migration20200101010700_create_trip_photos';
override async up(): Promise<void> {
if (!(await tableExists(this, 'trip_photos'))) {
await this.execute(`CREATE TABLE trip_photos (${TRIP_PHOTOS_DDL})`);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`);
return;
}
const names = await columnNames(this, 'trip_photos');
const assetSource = names.has('asset_id') ? 'asset_id' : names.has('immich_asset_id') ? 'immich_asset_id' : null;
if (!assetSource) return;
const providerExpr = names.has('provider')
? `CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END`
: `'immich'`;
const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1';
const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
await this.execute(`CREATE TABLE trip_photos_new (${TRIP_PHOTOS_DDL})`);
await this.execute(`
INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at)
SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr}
FROM trip_photos
WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != ''
`);
await this.execute(`DROP TABLE trip_photos`);
await this.execute(`ALTER TABLE trip_photos_new RENAME TO trip_photos`);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`);
}
}
@@ -0,0 +1,56 @@
import { columnNames, tableExists } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
const ALBUM_LINKS_DDL = `
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
album_id TEXT NOT NULL,
album_name TEXT NOT NULL DEFAULT '',
sync_enabled INTEGER NOT NULL DEFAULT 1,
last_synced_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(trip_id, user_id, provider, album_id)
`;
/**
* Legacy migration step 68 (`db/migrations.ts`).
*
* Normalize trip_album_links to the provider + album_id schema used by current
* routes, reading whichever of the two historical shapes the database has.
*/
export class Migration20200101010800_create_trip_album_links extends Migration {
override name = 'Migration20200101010800_create_trip_album_links';
override async up(): Promise<void> {
if (!(await tableExists(this, 'trip_album_links'))) {
await this.execute(`CREATE TABLE trip_album_links (${ALBUM_LINKS_DDL})`);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`);
return;
}
const names = await columnNames(this, 'trip_album_links');
const albumIdSource = names.has('album_id') ? 'album_id' : names.has('immich_album_id') ? 'immich_album_id' : null;
if (!albumIdSource) return;
const providerExpr = names.has('provider')
? `CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END`
: `'immich'`;
const albumNameExpr = names.has('album_name') ? `COALESCE(album_name, '')` : `''`;
const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1';
const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL';
const createdAtExpr = names.has('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP';
await this.execute(`CREATE TABLE trip_album_links_new (${ALBUM_LINKS_DDL})`);
await this.execute(`
INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at)
SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr}
FROM trip_album_links
WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != ''
`);
await this.execute(`DROP TABLE trip_album_links`);
await this.execute(`ALTER TABLE trip_album_links_new RENAME TO trip_album_links`);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`);
}
}
@@ -0,0 +1,19 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 69 (`db/migrations.ts`).
*/
export class Migration20200101010900_users_add_synology_url extends Migration {
override name = 'Migration20200101010900_users_add_synology_url';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'synology_url', `synology_url TEXT`);
await addColumnIfMissing(this, 'users', 'synology_username', `synology_username TEXT`);
await addColumnIfMissing(this, 'users', 'synology_password', `synology_password TEXT`);
await addColumnIfMissing(this, 'users', 'synology_sid', `synology_sid TEXT`);
}
}
@@ -0,0 +1,97 @@
import { execUnlessTableMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
const PROVIDER_FIELDS: [string, string, string, string, string, number, number, string | null, string, number][] = [
[
'synologyphotos',
'synology_url',
'providerUrl',
'url',
'https://synology.example.com',
1,
0,
'synology_url',
'synology_url',
0,
],
[
'synologyphotos',
'synology_username',
'providerUsername',
'text',
'Username',
1,
0,
'synology_username',
'synology_username',
1,
],
[
'synologyphotos',
'synology_password',
'providerPassword',
'password',
'Password',
1,
1,
null,
'synology_password',
2,
],
];
/**
* Legacy migration step 70 (`db/migrations.ts`).
*
* Seed the Synology Photos provider and its fields in existing databases. Both
* statements are skipped when the provider tables do not exist yet — on those
* installs the seeder creates the rows instead.
*/
export class Migration20200101011000_step_070 extends Migration {
override name = 'Migration20200101011000_step_070';
override async up(): Promise<void> {
await execUnlessTableMissing(
this,
`
INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
icon = excluded.icon,
enabled = excluded.enabled,
sort_order = excluded.sort_order
`,
[
'synologyphotos',
'Synology Photos',
'Synology Photos integration with separate account settings',
'Image',
0,
1,
],
);
for (const field of PROVIDER_FIELDS) {
await execUnlessTableMissing(
this,
`
INSERT INTO photo_provider_fields
(provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider_id, field_key) DO UPDATE SET
label = excluded.label,
input_type = excluded.input_type,
placeholder = excluded.placeholder,
required = excluded.required,
secret = excluded.secret,
settings_key = excluded.settings_key,
payload_key = excluded.payload_key,
sort_order = excluded.sort_order
`,
field,
);
}
}
}
@@ -0,0 +1,18 @@
import { columnNames } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 71 (`db/migrations.ts`).
*
* Remove the stored config column from photo_providers now that it is generated
* from the provider id.
*/
export class Migration20200101011100_step_071 extends Migration {
override name = 'Migration20200101011100_step_071';
override async up(): Promise<void> {
const names = await columnNames(this, 'photo_providers');
if (!names.has('config')) return;
await this.execute(`ALTER TABLE photo_providers DROP COLUMN config`);
}
}
@@ -0,0 +1,22 @@
import { columnNames } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 72 (`db/migrations.ts`).
*
* The Immich-only column names become provider-neutral ones. A database that
* already went through step 67/68 is left alone.
*/
export class Migration20200101011200_step_072 extends Migration {
override name = 'Migration20200101011200_step_072';
override async up(): Promise<void> {
const names = await columnNames(this, 'trip_photos');
if (names.has('asset_id') && !names.has('immich_asset_id')) return;
await this.execute('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id');
await this.execute('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"');
await this.execute('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"');
await this.execute('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id');
}
}
@@ -0,0 +1,20 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 73 (`db/migrations.ts`).
*/
export class Migration20200101011300_trip_photos_add_album_link_id extends Migration {
override name = 'Migration20200101011300_trip_photos_add_album_link_id';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'trip_photos',
'album_link_id',
`album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL`,
);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_album_link ON trip_photos(album_link_id)`);
}
}
@@ -0,0 +1,40 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 74 (`db/migrations.ts`).
*
* Migration 68: Todo items
*/
export class Migration20200101011400_todo_items extends Migration {
override name = 'Migration20200101011400_todo_items';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS todo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
name TEXT NOT NULL,
checked INTEGER DEFAULT 0,
category TEXT,
sort_order INTEGER DEFAULT 0,
due_date TEXT,
description TEXT,
assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
priority INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_todo_items_trip_id ON todo_items(trip_id)`);
this.addSql(`
CREATE TABLE IF NOT EXISTS todo_category_assignees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category_name TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(trip_id, category_name, user_id)
)
`);
}
}
@@ -0,0 +1,13 @@
import { execBestEffort } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 75 (`db/migrations.ts`).
*/
export class Migration20200101011500_backfill_addons extends Migration {
override name = 'Migration20200101011500_backfill_addons';
override async up(): Promise<void> {
await execBestEffort(this, `UPDATE addons SET enabled = 0 WHERE id = 'memories'`);
}
}
@@ -0,0 +1,25 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 76 (`db/migrations.ts`).
*
* Migration 69: Place region cache for sub-national Atlas regions
*/
export class Migration20200101011600_place_region_cache_for_sub_national_atlas extends Migration {
override name = 'Migration20200101011600_place_region_cache_for_sub_national_atlas';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS place_regions (
place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE,
country_code TEXT NOT NULL,
region_code TEXT NOT NULL,
region_name TEXT NOT NULL
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code)`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code)`);
}
}
@@ -0,0 +1,24 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 77 (`db/migrations.ts`).
*/
export class Migration20200101011700_create_visited_regions extends Migration {
override name = 'Migration20200101011700_create_visited_regions';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS visited_regions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
region_code TEXT NOT NULL,
region_name TEXT NOT NULL,
country_code TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, region_code)
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code)`);
}
}
@@ -0,0 +1,69 @@
import { tableExists } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/** Old single-row-per-user columns → the event_type they become. */
const EVENT_COLUMNS: Record<string, string> = {
trip_invite: 'notify_trip_invite',
booking_change: 'notify_booking_change',
trip_reminder: 'notify_trip_reminder',
vacay_invite: 'notify_vacay_invite',
photos_shared: 'notify_photos_shared',
collab_message: 'notify_collab_message',
packing_tagged: 'notify_packing_tagged',
};
/**
* Legacy migration step 78 (`db/migrations.ts`).
*
* Migration 71: Normalized per-user per-channel notification preferences.
*
* Only the *disabled* combinations are written: a missing row means enabled, so
* copying the defaults across would bloat the table for no change in behaviour.
*/
export class Migration20200101011800_normalized_per_user_per_channel_notification_preferences extends Migration {
override name = 'Migration20200101011800_normalized_per_user_per_channel_notification_preferences';
override async up(): Promise<void> {
await this.execute(`
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
channel TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, event_type, channel)
)
`);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id)`);
// The old table does not exist on fresh installs.
const oldPrefs = (await tableExists(this, 'notification_preferences'))
? ((await this.execute(`SELECT * FROM notification_preferences`)) as Record<string, number>[])
: [];
for (const row of oldPrefs) {
const userId = row.user_id;
const webhookEnabled = row.notify_webhook ?? 0;
for (const [eventType, col] of Object.entries(EVENT_COLUMNS)) {
const emailEnabled = row[col] ?? 1;
if (!emailEnabled) {
await this.execute(
`INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)`,
[userId, eventType, 'email', 0],
);
}
if (!webhookEnabled) {
await this.execute(
`INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)`,
[userId, eventType, 'webhook', 0],
);
}
}
}
// Copy the existing single-channel setting to the new plural key.
await this.execute(`
INSERT OR IGNORE INTO app_settings (key, value)
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel'
`);
}
}
@@ -0,0 +1,14 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 79 (`db/migrations.ts`).
*
* Migration 72: Drop the old notification_preferences table (data migrated to notification_channel_preferences in migration 71)
*/
export class Migration20200101011900_drop_the_old_notification_preferences_table extends Migration {
override name = 'Migration20200101011900_drop_the_old_notification_preferences_table';
override up(): void {
this.addSql(`DROP TABLE IF EXISTS notification_preferences`);
}
}
@@ -0,0 +1,20 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 80 (`db/migrations.ts`).
*
* Migration 73: Add reservation_id to budget_items for linking budget entries to reservations
*/
export class Migration20200101012000_add_reservation_id_to_budget_items_for extends Migration {
override name = 'Migration20200101012000_add_reservation_id_to_budget_items_for';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'budget_items',
'reservation_id',
`reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL`,
);
}
}
@@ -0,0 +1,43 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 81 (`db/migrations.ts`).
*
* Migration 74: Add quantity to packing_items + user_id to packing_bags +
* bag_members table. The single `user_id` becomes the first row of the new
* membership table; the column itself stays for the steps that still read it.
*/
export class Migration20200101012100_add_quantity_to_packing_items_user_id extends Migration {
override name = 'Migration20200101012100_add_quantity_to_packing_items_user_id';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'packing_items', 'quantity', `quantity INTEGER NOT NULL DEFAULT 1`);
await addColumnIfMissing(
this,
'packing_bags',
'user_id',
`user_id INTEGER REFERENCES users(id) ON DELETE SET NULL DEFAULT NULL`,
);
await this.execute(`
CREATE TABLE IF NOT EXISTS packing_bag_members (
bag_id INTEGER NOT NULL REFERENCES packing_bags(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (bag_id, user_id)
)
`);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_packing_bag_members_bag ON packing_bag_members(bag_id)`);
const bagsWithUser = (await this.execute(`SELECT id, user_id FROM packing_bags WHERE user_id IS NOT NULL`)) as {
id: number;
user_id: number;
}[];
for (const bag of bagsWithUser) {
await this.execute(`INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)`, [
bag.id,
bag.user_id,
]);
}
}
}
@@ -0,0 +1,55 @@
import { Migration } from '@mikro-orm/migrations';
/** A reservation that still carries the old single, trip-wide plan position. */
interface PositionedReservation {
id: number;
trip_id: number;
reservation_time: string | null;
reservation_end_time: string | null;
day_plan_position: number;
}
/**
* Legacy migration step 82 (`db/migrations.ts`).
*
* Per-day positions for multi-day reservations: one global position becomes one
* row per day the reservation spans, so a stay can sit in a different slot on
* each of its days.
*/
export class Migration20200101012200_per_day_positions_for_multi_day_reservations extends Migration {
override name = 'Migration20200101012200_per_day_positions_for_multi_day_reservations';
override async up(): Promise<void> {
await this.execute(`
CREATE TABLE IF NOT EXISTS reservation_day_positions (
reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE,
day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
position REAL NOT NULL,
PRIMARY KEY (reservation_id, day_id)
)
`);
const reservations = (await this.execute(
`SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL`,
)) as PositionedReservation[];
for (const r of reservations) {
const startDate = r.reservation_time?.split('T')[0];
if (!startDate) continue;
const endDate = r.reservation_end_time?.split('T')[0] || startDate;
const matchingDays = (await this.execute(`SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?`, [
r.trip_id,
startDate,
endDate,
])) as { id: number }[];
for (const day of matchingDays) {
await this.execute(
`INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)`,
[r.id, day.id, r.day_plan_position],
);
}
}
}
}
@@ -0,0 +1,39 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 83 (`db/migrations.ts`).
*
* Budget category ordering. Existing categories are seeded in alphabetical order
* per trip, which is the order they were displayed in before this table existed.
*/
export class Migration20200101012300_budget_category_ordering extends Migration {
override name = 'Migration20200101012300_budget_category_ordering';
override async up(): Promise<void> {
await this.execute(`
CREATE TABLE IF NOT EXISTS budget_category_order (
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
category TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (trip_id, category)
)
`);
const rows = (await this.execute(
`SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category`,
)) as { trip_id: number; category: string }[];
let lastTripId = -1;
let index = 0;
for (const row of rows) {
if (row.trip_id !== lastTripId) {
lastTripId = row.trip_id;
index = 0;
}
await this.execute(
`INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)`,
[row.trip_id, row.category, index++],
);
}
}
}
@@ -0,0 +1,19 @@
import { execBestEffort } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 84 (`db/migrations.ts`).
*
* Naver list import addon (default off).
*/
export class Migration20200101012400_naver_list_import_addon extends Migration {
override name = 'Migration20200101012400_naver_list_import_addon';
override async up(): Promise<void> {
await execBestEffort(
this,
`INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`,
['naver_list_import', 'Naver List Import', 'Import places from shared Naver Maps lists', 'trip', 'Link2', 0, 13],
);
}
}
@@ -0,0 +1,61 @@
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 85 (`db/migrations.ts`).
*
* Migration: OAuth 2.1 clients, consents, and tokens for MCP
*/
export class Migration20200101012500_oauth_2 extends Migration {
override name = 'Migration20200101012500_oauth_2';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS oauth_clients (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client_id TEXT UNIQUE NOT NULL,
client_secret_hash TEXT NOT NULL,
redirect_uris TEXT NOT NULL DEFAULT '[]',
allowed_scopes TEXT NOT NULL DEFAULT '[]',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`);
this.addSql(`
CREATE TABLE IF NOT EXISTS oauth_consents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
scopes TEXT NOT NULL DEFAULT '[]',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(client_id, user_id)
)
`);
this.addSql(`
CREATE TABLE IF NOT EXISTS oauth_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
access_token_hash TEXT UNIQUE NOT NULL,
refresh_token_hash TEXT UNIQUE NOT NULL,
scopes TEXT NOT NULL DEFAULT '[]',
access_token_expires_at DATETIME NOT NULL,
refresh_token_expires_at DATETIME NOT NULL,
revoked_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
this.addSql(`CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id)`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash)`);
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash)`);
}
}
@@ -0,0 +1,22 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 86 (`db/migrations.ts`).
*
* Migration: Refresh-token rotation chain tracking for replay detection
*/
export class Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay extends Migration {
override name = 'Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay';
override async up(): Promise<void> {
await addColumnIfMissing(
this,
'oauth_tokens',
'parent_token_id',
`parent_token_id INTEGER REFERENCES oauth_tokens(id)`,
);
await this.execute(`CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id)`);
}
}
@@ -0,0 +1,17 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
/**
* Legacy migration step 87 (`db/migrations.ts`).
*
* Migration: Public client support for browser-initiated dynamic registration (DCR)
*/
export class Migration20200101012700_public_client_support_for_browser_initiated_dynamic extends Migration {
override name = 'Migration20200101012700_public_client_support_for_browser_initiated_dynamic';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'oauth_clients', 'is_public', `is_public INTEGER NOT NULL DEFAULT 0`);
await addColumnIfMissing(this, 'oauth_clients', 'created_via', `created_via TEXT NOT NULL DEFAULT 'settings_ui'`);
}
}

Some files were not shown because too many files have changed in this diff Show More