diff --git a/CLAUDE.md b/CLAUDE.md index 49dfc82f7..712310b2b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ Nest owns everything: every domain is a DI module under `server/src/nest/')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler). -- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB. +- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. 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 `.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`. diff --git a/package-lock.json b/package-lock.json index e3da2be1c..2e250c756 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/server/CLAUDE.md b/server/CLAUDE.md index e582a1705..01d5f267f 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -32,14 +32,14 @@ Nest owns everything. Every domain is a DI module under `src/nest//` (`c - **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator** — it bypasses that gate. - **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together. - **Storage**: `src/nest/storage/` is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly. -- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on; `database.ts` runs `schema.ts` → `migrations.ts` → `seeds.ts`. `NODE_ENV=test` ⇒ isolated `:memory:` DB per vitest worker; `TREK_DB_FILE` ⇒ explicit file DB (Playwright harness). +- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on. `database.ts` only opens the connection 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` + ad-hoc checks. Also verify every referenced id exists and belongs to the same trip. - **Every outbound `fetch`** gets a timeout, a response-size cap and boundary validation — no `as`-casting provider responses, no silent `catch → null`. User-influenced URLs go through the SSRF guard. Never fall back to another user's API key. - **Fail closed.** Unrecognized flag values mean OFF; sandbox/permission opt-outs refuse in production; never pick a less-safe path based on the *absence* of a file. GET handlers must not write. diff --git a/server/package.json b/server/package.json index a40f23e83..274a7e012 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/scripts/build.mjs b/server/scripts/build.mjs index 24deb830a..0c840f5b4 100644 --- a/server/scripts/build.mjs +++ b/server/scripts/build.mjs @@ -1,8 +1,39 @@ import { execSync } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +// Resolved from this file rather than cwd, so the directory that gets removed is +// always this package's own dist no matter where the script is invoked from. +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const distDir = join(packageRoot, 'dist'); + +// tsc only ever adds to dist, so a module deleted or renamed in src leaves its +// compiled twin behind. For most code that is merely stale, but the migrator +// globs dist/db/migrations at boot: an orphan there is a live migration with no +// source, and it runs. Same for dist/db/seeders. Nothing else writes into dist — +// the client bundle goes to server/public and the runtime assets the Dockerfile +// needs are copied in separately — so it is safe to drop wholesale. +rmSync(distDir, { recursive: true, force: true }); + +let tscExitedNonZero = false; try { - execSync('tsc -p tsconfig.build.json', { stdio: 'inherit' }); + execSync('tsc -p tsconfig.build.json', { stdio: 'inherit', cwd: packageRoot }); } catch { + tscExitedNonZero = true; +} + +// `noEmitOnError` is off, so tsc exits non-zero on type errors and still writes +// dist — that case is deliberate, and gated by `npm run typecheck`. A tsc that +// never ran at all (not on PATH, crashed) is indistinguishable from here, and +// now that dist is emptied first it would leave nothing behind while still +// reporting success. Checking for the entry point tells the two apart. +if (!existsSync(join(distDir, 'index.js'))) { + console.error('[build] tsc emitted nothing — dist is empty. Run via `npm run build` so tsc is on PATH.'); + process.exit(1); +} + +if (tscExitedNonZero) { console.warn('[build] tsc reported type errors — emitting anyway (gated by `npm run typecheck`).'); } diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index ddca87bcd..49bd2cefe 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -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 { // 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 { 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 { 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); diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 6270d5ad4..20a6446aa 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -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) | null = null; +function registerReinitializeHook(hook: () => Promise): void { + onReinitialize = hook; +} + function closeDb(): void { if (_db) { - try { _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {} - try { _db.close(); } catch (e) {} + try { + _db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + } catch (e) {} + try { + _db.close(); + } catch (e) {} _db = null; console.log('[DB] Database connection closed'); } } -function reinitialize(): void { +/** + * Reopens the connection after a restore has swapped the file underneath us. + * + * Async now, because bringing a restored backup up to date means running the + * migrations, and the hook also has to rebuild the ORM's Kysely client — it + * cached the handle this function is about to replace. + */ +async function reinitialize(): Promise { console.log('[DB] Reinitializing database connection after restore...'); if (_db) closeDb(); initDb(); + if (onReinitialize) await onReinitialize(); console.log('[DB] Database reinitialized successfully'); } @@ -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 }; diff --git a/server/src/db/db-path.ts b/server/src/db/db-path.ts new file mode 100644 index 000000000..6d9eb86f9 --- /dev/null +++ b/server/src/db/db-path.ts @@ -0,0 +1,34 @@ +import { readEnv } from '../app-config'; + +import fs from 'fs'; +import path from 'path'; + +/** + * The SQLite file this process talks to. + * + * Shared by `database.ts` (which opens the connection) and `mikro-orm.config.ts` + * (which needs the same target for the CLI and for the Nest-owned ORM). Kept in + * one place because a config that names a different file than the connection is + * the kind of mistake that only shows up as an empty database. + * + * Creates the containing directory as a side effect, exactly as the original + * inline block in `database.ts` did. + */ +export function resolveDbPath(): string { + // In test mode each vitest worker gets an isolated in-memory DB so that + // parallel forks can't race on the same file or share migration state. + if (readEnv().app.isTest) return ':memory:'; + + const explicit = readEnv().db.trekDbFile; + if (explicit) { + // Explicit DB file (used by the Playwright E2E harness to run against an + // isolated, throwaway database instead of the real data/travel.db). + const dir = path.dirname(explicit); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return explicit; + } + + const dataDir = path.join(__dirname, '../../data'); + if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + return path.join(dataDir, 'travel.db'); +} diff --git a/server/src/db/document-provider-seed.ts b/server/src/db/document-provider-seed.ts index 16e2467a8..6d51e6f50 100644 --- a/server/src/db/document-provider-seed.ts +++ b/server/src/db/document-provider-seed.ts @@ -18,7 +18,7 @@ import type Database from 'better-sqlite3'; * scanning the addon shelf should find the product they actually run. */ -interface ProviderRow { +export interface ProviderRow { id: string; name: string; description: string; @@ -26,7 +26,7 @@ interface ProviderRow { sort_order: number; } -interface ProviderFieldRow { +export interface ProviderFieldRow { provider_id: string; field_key: string; label: string; @@ -38,7 +38,7 @@ interface ProviderFieldRow { sort_order: number; } -const PROVIDERS: ProviderRow[] = [ +export const DOCUMENT_PROVIDERS: ProviderRow[] = [ { id: 'paperless', name: 'Paperless-ngx', @@ -82,56 +82,286 @@ const PROVIDERS: ProviderRow[] = [ * instance with a self-signed certificate is common enough to need the switch * and not common enough to justify shipping it on. */ -const FIELDS: ProviderFieldRow[] = [ +export const DOCUMENT_PROVIDER_FIELDS: ProviderFieldRow[] = [ // Paperless-ngx: token auth, tag scope. - { provider_id: 'paperless', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://paperless.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'paperless', field_key: 'api_token', label: 'providerApiToken', input_type: 'password', placeholder: 'API token', hint: 'hintPaperlessToken', required: 1, secret: 1, sort_order: 1 }, - { provider_id: 'paperless', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 2 }, + { + provider_id: 'paperless', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://paperless.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'paperless', + field_key: 'api_token', + label: 'providerApiToken', + input_type: 'password', + placeholder: 'API token', + hint: 'hintPaperlessToken', + required: 1, + secret: 1, + sort_order: 1, + }, + { + provider_id: 'paperless', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 2, + }, // Papra: bearer key, and an organisation the key must already belong to. - { provider_id: 'papra', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://papra.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'papra', field_key: 'api_key', label: 'providerApiKey', input_type: 'password', placeholder: 'ppapi_…', hint: 'hintPapraKey', required: 1, secret: 1, sort_order: 1 }, - { provider_id: 'papra', field_key: 'organization_id', label: 'providerOrganization', input_type: 'text', placeholder: 'org_…', hint: 'hintPapraOrg', required: 1, secret: 0, sort_order: 2 }, - { provider_id: 'papra', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 3 }, + { + provider_id: 'papra', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://papra.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'papra', + field_key: 'api_key', + label: 'providerApiKey', + input_type: 'password', + placeholder: 'ppapi_…', + hint: 'hintPapraKey', + required: 1, + secret: 1, + sort_order: 1, + }, + { + provider_id: 'papra', + field_key: 'organization_id', + label: 'providerOrganization', + input_type: 'text', + placeholder: 'org_…', + hint: 'hintPapraOrg', + required: 1, + secret: 0, + sort_order: 2, + }, + { + provider_id: 'papra', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 3, + }, // Nextcloud: an app password, never the account password. The latter breaks // the moment the account turns on 2FA or logs in through OIDC. - { provider_id: 'nextcloud', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://cloud.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'nextcloud', field_key: 'login_name', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintNextcloudLogin', required: 1, secret: 0, sort_order: 1 }, - { provider_id: 'nextcloud', field_key: 'app_password', label: 'providerAppPassword', input_type: 'password', placeholder: 'app password', hint: 'hintNextcloudAppPassword', required: 1, secret: 1, sort_order: 2 }, - { provider_id: 'nextcloud', field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/TREK', hint: 'hintBasePath', required: 0, secret: 0, sort_order: 3 }, - { provider_id: 'nextcloud', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 4 }, + { + provider_id: 'nextcloud', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://cloud.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'nextcloud', + field_key: 'login_name', + label: 'providerUsername', + input_type: 'text', + placeholder: 'username', + hint: 'hintNextcloudLogin', + required: 1, + secret: 0, + sort_order: 1, + }, + { + provider_id: 'nextcloud', + field_key: 'app_password', + label: 'providerAppPassword', + input_type: 'password', + placeholder: 'app password', + hint: 'hintNextcloudAppPassword', + required: 1, + secret: 1, + sort_order: 2, + }, + { + provider_id: 'nextcloud', + field_key: 'base_path', + label: 'providerBasePath', + input_type: 'text', + placeholder: '/TREK', + hint: 'hintBasePath', + required: 0, + secret: 0, + sort_order: 3, + }, + { + provider_id: 'nextcloud', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 4, + }, // OpenCloud: auth-app token, spaces instead of folders. - { provider_id: 'opencloud', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://opencloud.example.com', hint: null, required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'opencloud', field_key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: null, required: 1, secret: 0, sort_order: 1 }, - { provider_id: 'opencloud', field_key: 'app_token', label: 'providerAppToken', input_type: 'password', placeholder: 'app token', hint: 'hintOpenCloudToken', required: 1, secret: 1, sort_order: 2 }, - { provider_id: 'opencloud', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 3 }, + { + provider_id: 'opencloud', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://opencloud.example.com', + hint: null, + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'opencloud', + field_key: 'username', + label: 'providerUsername', + input_type: 'text', + placeholder: 'username', + hint: null, + required: 1, + secret: 0, + sort_order: 1, + }, + { + provider_id: 'opencloud', + field_key: 'app_token', + label: 'providerAppToken', + input_type: 'password', + placeholder: 'app token', + hint: 'hintOpenCloudToken', + required: 1, + secret: 1, + sort_order: 2, + }, + { + provider_id: 'opencloud', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 3, + }, // Synology: DSM has no app tokens at all, so this is a real account. The OTP // is used once to obtain a device token, which is what gets stored. - { provider_id: 'synologydrive', field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://nas.example.com:5001', hint: 'hintSynologyUrl', required: 1, secret: 0, sort_order: 0 }, - { provider_id: 'synologydrive', field_key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintSynologyUser', required: 1, secret: 0, sort_order: 1 }, - { provider_id: 'synologydrive', field_key: 'password', label: 'providerPassword', input_type: 'password', placeholder: 'password', hint: null, required: 1, secret: 1, sort_order: 2 }, - { provider_id: 'synologydrive', field_key: 'otp_code', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: 'hintSynologyOtp', required: 0, secret: 1, sort_order: 3 }, - { provider_id: 'synologydrive', field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/trek', hint: 'hintBasePath', required: 0, secret: 0, sort_order: 4 }, - { provider_id: 'synologydrive', field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: 0, secret: 0, sort_order: 5 }, + { + provider_id: 'synologydrive', + field_key: 'base_url', + label: 'providerUrl', + input_type: 'url', + placeholder: 'https://nas.example.com:5001', + hint: 'hintSynologyUrl', + required: 1, + secret: 0, + sort_order: 0, + }, + { + provider_id: 'synologydrive', + field_key: 'username', + label: 'providerUsername', + input_type: 'text', + placeholder: 'username', + hint: 'hintSynologyUser', + required: 1, + secret: 0, + sort_order: 1, + }, + { + provider_id: 'synologydrive', + field_key: 'password', + label: 'providerPassword', + input_type: 'password', + placeholder: 'password', + hint: null, + required: 1, + secret: 1, + sort_order: 2, + }, + { + provider_id: 'synologydrive', + field_key: 'otp_code', + label: 'providerOTP', + input_type: 'text', + placeholder: '123456', + hint: 'hintSynologyOtp', + required: 0, + secret: 1, + sort_order: 3, + }, + { + provider_id: 'synologydrive', + field_key: 'base_path', + label: 'providerBasePath', + input_type: 'text', + placeholder: '/trek', + hint: 'hintBasePath', + required: 0, + secret: 0, + sort_order: 4, + }, + { + provider_id: 'synologydrive', + field_key: 'allow_insecure_tls', + label: 'allowInsecureTls', + input_type: 'checkbox', + placeholder: null, + hint: null, + required: 0, + secret: 0, + sort_order: 5, + }, ]; export function seedDocumentProviders(db: Database.Database): void { const insertProvider = db.prepare( 'INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)', ); - for (const p of PROVIDERS) insertProvider.run(p.id, p.name, p.description, p.icon, p.sort_order); + for (const p of DOCUMENT_PROVIDERS) insertProvider.run(p.id, p.name, p.description, p.icon, p.sort_order); const insertField = db.prepare( `INSERT OR IGNORE INTO document_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); - for (const f of FIELDS) { - insertField.run(f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.sort_order); + for (const f of DOCUMENT_PROVIDER_FIELDS) { + insertField.run( + f.provider_id, + f.field_key, + f.label, + f.input_type, + f.placeholder, + f.hint, + f.required, + f.secret, + f.sort_order, + ); } } -export const DOCUMENT_PROVIDER_SEED_IDS = PROVIDERS.map((p) => p.id); +export const DOCUMENT_PROVIDER_SEED_IDS = DOCUMENT_PROVIDERS.map((p) => p.id); diff --git a/server/src/db/entities/CollabMessages.entity.ts b/server/src/db/entities/CollabMessages.entity.ts index 79fc05637..d5a89b834 100644 --- a/server/src/db/entities/CollabMessages.entity.ts +++ b/server/src/db/entities/CollabMessages.entity.ts @@ -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'; diff --git a/server/src/db/entities/RoadtripDayBoundaries.entity.ts b/server/src/db/entities/RoadtripDayBoundaries.entity.ts index edb3ba765..b2054d6a4 100644 --- a/server/src/db/entities/RoadtripDayBoundaries.entity.ts +++ b/server/src/db/entities/RoadtripDayBoundaries.entity.ts @@ -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; toAssignment?: Ref | null; - fraction!: unknown; + fraction!: number; // was `unknown` } export class RoadtripDayBoundariesRepository extends EntityRepository {} @@ -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', diff --git a/server/src/db/migration-utils.ts b/server/src/db/migration-utils.ts new file mode 100644 index 000000000..57893f380 --- /dev/null +++ b/server/src/db/migration-utils.ts @@ -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 { + const existing = (await migration.execute('select name from pragma_table_info(?) where name = ?', [ + table, + column, + ])) as ColumnRow[]; + if (existing.length > 0) return; + await migration.execute(`alter table \`${table}\` add column ${definition}`); +} + +/** True when `table` exists in the current database. */ +export async function tableExists(migration: Migration, table: string): Promise { + const rows = await migration.execute(`select name from sqlite_master where type = 'table' and name = ?`, [table]); + return rows.length > 0; +} + +/** The `CREATE TABLE` DDL SQLite stored for `table`, or null when it does not exist. */ +export async function tableSql(migration: Migration, table: string): Promise { + const rows = (await migration.execute(`select sql from sqlite_master where name = ?`, [table])) as { + sql: string | null; + }[]; + return rows[0]?.sql ?? null; +} + +/** + * Runs a statement whose failure the legacy step logged and swallowed. + * + * Kept as its own helper so "this step is allowed to fail" stays visible in the + * generated migrations instead of hiding behind a bare try/catch. + */ +export async function execBestEffort(migration: Migration, sql: string, params?: unknown[]): Promise { + try { + await migration.execute(sql, params); + } catch (err: unknown) { + console.warn('[migrations] non-fatal step failed:', err instanceof Error ? err.message : err); + } +} + +/** The column names of `table`, or an empty set when the table does not exist. */ +export async function columnNames(migration: Migration, table: string): Promise> { + const rows = (await migration.execute('select name from pragma_table_info(?)', [table])) as ColumnRow[]; + return new Set(rows.map((r) => r.name)); +} + +/** + * Runs a statement that the legacy step allowed to fail *only* because the table + * it touches may not exist yet on that database. Any other error still throws. + */ +export async function execUnlessTableMissing(migration: Migration, sql: string, params?: unknown[]): Promise { + try { + await migration.execute(sql, params); + } catch (err: unknown) { + if (!(err instanceof Error) || !err.message.includes('no such table')) throw err; + } +} + +/** + * Runs a group of statements the legacy step wrapped in a single swallowing + * try/catch. A failure part-way through leaves the earlier statements applied, + * which is exactly what the original did. + */ +export async function bestEffort(run: () => Promise): Promise { + try { + await run(); + } catch (err: unknown) { + console.warn('[migrations] non-fatal step failed:', err instanceof Error ? err.message : err); + } +} + +/** A booked stay whose check-in day has no matching stop on the plan. */ +interface UnplacedStay { + id: number; + place_id: number; + start_day_id: number; +} + +/** + * Gives every booked stay the day stop it would get today, and returns how many + * were placed. + * + * Road-trip mode builds its drive out of `day_assignments` alone, so a hotel + * booked in Days mode was invisible there. Two legacy steps run this same sweep: + * once at the release, and once more afterwards for the bookings written in the + * seconds while the old container was still answering. The `NOT EXISTS` decides + * both times, so repeating it is safe — a stay that already has its stop is + * passed over, and a place the traveller planned by hand keeps its row unclaimed. + */ +export async function attachStayStopsToCheckInDay(migration: Migration): Promise { + const stays = (await migration.execute(` + SELECT a.id, a.place_id, a.start_day_id + FROM day_accommodations a + WHERE a.place_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM day_assignments da + WHERE da.day_id = a.start_day_id AND da.place_id = a.place_id + ) + ORDER BY a.id + `)) as UnplacedStay[]; + + for (const stay of stays) { + // Arriving somewhere is what the day was for, so the stop goes last — the + // same position a stay booked today lands in. + await migration.execute( + `INSERT INTO day_assignments (day_id, place_id, order_index, accommodation_id) + VALUES (?, ?, COALESCE((SELECT MAX(order_index) + 1 FROM day_assignments WHERE day_id = ?), 0), ?)`, + [stay.start_day_id, stay.place_id, stay.start_day_id, stay.id], + ); + await migration.execute( + `UPDATE places SET stop_type = 'hotel' WHERE id = ? AND (stop_type IS NULL OR stop_type = '')`, + [stay.place_id], + ); + } + + return stays.length; +} diff --git a/server/src/db/migrations/Migration20200101000000_baseline_schema.ts b/server/src/db/migrations/Migration20200101000000_baseline_schema.ts new file mode 100644 index 000000000..8aa7da5cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101000000_baseline_schema.ts @@ -0,0 +1,774 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Baseline schema — every table `db/schema.ts::createTables()` created on boot, + * before the numbered steps in `db/migrations.ts` ran. + * + * This is not a frozen "v0": `createTables()` was edited as features landed, so it + * is a snapshot of the schema as it stood when the ORM migration took place. The + * `IF NOT EXISTS` guards are kept verbatim, and the numbered steps that follow + * replay on top of it — each one guarded so a column this file already declares + * is not added twice. + * + * The trailing `schema_version` table is the legacy runner's own bookkeeping, + * which it created before running step 1. It is left empty here: the migration + * state now lives in the migrator's table, and nothing in the app reads this + * one. It is kept because step 129 rebuilds it and it is still an entity. + */ +export class Migration20200101000000_baseline_schema extends Migration { + override name = 'Migration20200101000000_baseline_schema'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + maps_api_key TEXT, + unsplash_api_key TEXT, + amap_api_key TEXT, + openweather_api_key TEXT, + avatar TEXT, + oidc_sub TEXT, + oidc_issuer TEXT, + last_login DATETIME, + mfa_enabled INTEGER DEFAULT 0, + mfa_secret TEXT, + mfa_backup_codes TEXT, + immich_url TEXT, + immich_access_token TEXT, + synology_url TEXT, + synology_username TEXT, + synology_password TEXT, + synology_sid TEXT, + must_change_password INTEGER DEFAULT 0, + password_version INTEGER NOT NULL DEFAULT 0, + feed_token TEXT, + is_guest INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + consumed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_ip TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + name TEXT, + aaguid TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT, + UNIQUE(user_id, key) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trips ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + start_date TEXT, + end_date TEXT, + currency TEXT DEFAULT 'EUR', + cover_image TEXT, + is_archived INTEGER DEFAULT 0, + reminder_days INTEGER DEFAULT 3, + feed_token TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS days ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL, + date TEXT, + notes TEXT, + title TEXT, + UNIQUE(trip_id, day_number) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT '📍', + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#10b981', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + reservation_status TEXT DEFAULT 'none', + reservation_notes TEXT, + reservation_datetime TEXT, + place_time TEXT, + end_time TEXT, + duration_minutes INTEGER DEFAULT 60, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + website TEXT, + phone TEXT, + transport_mode TEXT DEFAULT 'walking', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS place_tags ( + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (place_id, tag_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(place_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS day_assignments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + order_index INTEGER DEFAULT 0, + notes TEXT, + reservation_status TEXT DEFAULT 'none', + reservation_notes TEXT, + reservation_datetime TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + checked INTEGER DEFAULT 0, + category TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + file_size INTEGER, + mime_type TEXT, + caption TEXT, + taken_at TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL, + filename TEXT NOT NULL, + original_name TEXT NOT NULL, + file_size INTEGER, + mime_type TEXT, + description TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS reservations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, + end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL, + title TEXT NOT NULL, + accommodation_id TEXT, + reservation_time TEXT, + reservation_end_time TEXT, + location TEXT, + confirmation_number TEXT, + notes TEXT, + status TEXT DEFAULT 'pending', + type TEXT DEFAULT 'other', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invited_by INTEGER REFERENCES users(id), + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS day_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + text TEXT NOT NULL, + time TEXT, + icon TEXT DEFAULT '📝', + sort_order REAL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS budget_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category TEXT NOT NULL DEFAULT 'Other', + name TEXT NOT NULL, + total_price REAL NOT NULL DEFAULT 0, + persons INTEGER DEFAULT NULL, + days INTEGER DEFAULT NULL, + note TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + -- Addon system + CREATE TABLE IF NOT EXISTS addons ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'global', + icon TEXT DEFAULT 'Puzzle', + enabled INTEGER DEFAULT 0, + config TEXT DEFAULT '{}', + sort_order INTEGER DEFAULT 0 + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS photo_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT DEFAULT 'Image', + enabled INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0 + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS photo_provider_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL REFERENCES photo_providers(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + label TEXT NOT NULL, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + hint TEXT, + required INTEGER DEFAULT 0, + secret INTEGER DEFAULT 0, + settings_key TEXT, + payload_key TEXT, + sort_order INTEGER DEFAULT 0, + UNIQUE(provider_id, field_key) + ) + `); + + this.addSql(` + -- Vacay addon tables + CREATE TABLE IF NOT EXISTS vacay_plans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + block_weekends INTEGER DEFAULT 1, + holidays_enabled INTEGER DEFAULT 0, + holidays_region TEXT DEFAULT '', + school_holidays_enabled INTEGER DEFAULT 0, + company_holidays_enabled INTEGER DEFAULT 1, + carry_over_enabled INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(owner_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_plan_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(plan_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_user_colors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + color TEXT DEFAULT '#6366f1', + UNIQUE(user_id, plan_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_years ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + year INTEGER NOT NULL, + UNIQUE(plan_id, year) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_user_years ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + year INTEGER NOT NULL, + vacation_days INTEGER DEFAULT 30, + carried_over INTEGER DEFAULT 0, + UNIQUE(user_id, plan_id, year) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date TEXT NOT NULL, + note TEXT DEFAULT '', + UNIQUE(user_id, plan_id, date) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_company_holidays ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + date TEXT NOT NULL, + note TEXT DEFAULT '', + UNIQUE(plan_id, date) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_holiday_calendars ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plan_id INTEGER NOT NULL REFERENCES vacay_plans(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'public_holiday', + region TEXT NOT NULL, + label TEXT, + color TEXT NOT NULL DEFAULT '#fecaca', + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + + this.addSql(` + -- Collections addon tables + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT 'Bookmark', + cover_image TEXT, + links TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + role TEXT NOT NULL DEFAULT 'editor', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + osm_id TEXT, + website TEXT, + phone TEXT, + status TEXT NOT NULL DEFAULT 'idea', + source_trip_id INTEGER, + source_place_id INTEGER, + links TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_place_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_tags ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, tag_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id)`); + + this.addSql(` + -- Per-collection custom labels (distinct from the instance-wide tags table): + -- each list defines its own labels and every place can carry several. + CREATE TABLE IF NOT EXISTS collection_labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#6366f1', + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_labels ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, label_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS day_accommodations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + check_in TEXT, + check_in_end TEXT, + check_out TEXT, + confirmation TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + -- Collab addon tables + CREATE TABLE IF NOT EXISTS collab_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category TEXT DEFAULT 'General', + title TEXT NOT NULL, + content TEXT, + color TEXT DEFAULT '#6366f1', + pinned INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_polls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options TEXT NOT NULL, + multiple INTEGER DEFAULT 0, + closed INTEGER DEFAULT 0, + deadline TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_poll_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_index INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(poll_id, user_id, option_index) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_places_trip_id ON places(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_places_category_id ON places(category_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_days_trip_id ON days(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_day_id ON day_assignments(day_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_place_id ON day_assignments(place_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_tags_place_id ON place_tags(place_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_tags_tag_id ON place_tags(tag_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_members_trip_id ON trip_members(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_members_user_id ON trip_members(user_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_packing_items_trip_id ON packing_items(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_items_trip_id ON budget_items(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_reservations_trip_id ON reservations(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_files_trip_id ON trip_files(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_notes_day_id ON day_notes(day_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_photos_trip_id ON photos(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS assignment_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(assignment_id, user_id) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_assignment_participants_assignment ON assignment_participants(assignment_id)`, + ); + + this.addSql(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), + scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), + target INTEGER NOT NULL, + sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title_key TEXT NOT NULL, + title_params TEXT DEFAULT '{}', + text_key TEXT NOT NULL, + text_params TEXT DEFAULT '{}', + positive_text_key TEXT, + negative_text_key TEXT, + positive_callback TEXT, + negative_callback TEXT, + response TEXT CHECK(response IN ('positive', 'negative')), + navigate_text_key TEXT, + navigate_target TEXT, + is_read INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC)`, + ); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC)`, + ); + + this.addSql(` + CREATE TABLE IF NOT EXISTS notification_channel_preferences ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + channel TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (user_id, event_type, channel) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id)`); + + this.addSql( + `CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL)`, + ); + + this.addSql(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); + } +} diff --git a/server/src/db/migrations/Migration20200101000100_users_add_unsplash_api_key.ts b/server/src/db/migrations/Migration20200101000100_users_add_unsplash_api_key.ts new file mode 100644 index 000000000..6e9023343 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000100_users_add_unsplash_api_key.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 1 (`db/migrations.ts`). + */ +export class Migration20200101000100_users_add_unsplash_api_key extends Migration { + override name = 'Migration20200101000100_users_add_unsplash_api_key'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'unsplash_api_key', `unsplash_api_key TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000200_users_add_openweather_api_key.ts b/server/src/db/migrations/Migration20200101000200_users_add_openweather_api_key.ts new file mode 100644 index 000000000..8999dd1c9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000200_users_add_openweather_api_key.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 2 (`db/migrations.ts`). + */ +export class Migration20200101000200_users_add_openweather_api_key extends Migration { + override name = 'Migration20200101000200_users_add_openweather_api_key'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'openweather_api_key', `openweather_api_key TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000300_places_add_duration_minutes.ts b/server/src/db/migrations/Migration20200101000300_places_add_duration_minutes.ts new file mode 100644 index 000000000..fcd3cc89f --- /dev/null +++ b/server/src/db/migrations/Migration20200101000300_places_add_duration_minutes.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 3 (`db/migrations.ts`). + */ +export class Migration20200101000300_places_add_duration_minutes extends Migration { + override name = 'Migration20200101000300_places_add_duration_minutes'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'duration_minutes', `duration_minutes INTEGER DEFAULT 60`); + } +} diff --git a/server/src/db/migrations/Migration20200101000400_places_add_notes.ts b/server/src/db/migrations/Migration20200101000400_places_add_notes.ts new file mode 100644 index 000000000..ef2fa8c66 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000400_places_add_notes.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 4 (`db/migrations.ts`). + */ +export class Migration20200101000400_places_add_notes extends Migration { + override name = 'Migration20200101000400_places_add_notes'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'notes', `notes TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000500_places_add_image_url.ts b/server/src/db/migrations/Migration20200101000500_places_add_image_url.ts new file mode 100644 index 000000000..143d55872 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000500_places_add_image_url.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 5 (`db/migrations.ts`). + */ +export class Migration20200101000500_places_add_image_url extends Migration { + override name = 'Migration20200101000500_places_add_image_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'image_url', `image_url TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000600_places_add_transport_mode.ts b/server/src/db/migrations/Migration20200101000600_places_add_transport_mode.ts new file mode 100644 index 000000000..db8e9a7f0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000600_places_add_transport_mode.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 6 (`db/migrations.ts`). + */ +export class Migration20200101000600_places_add_transport_mode extends Migration { + override name = 'Migration20200101000600_places_add_transport_mode'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'transport_mode', `transport_mode TEXT DEFAULT 'walking'`); + } +} diff --git a/server/src/db/migrations/Migration20200101000700_days_add_title.ts b/server/src/db/migrations/Migration20200101000700_days_add_title.ts new file mode 100644 index 000000000..ce2b6c6e9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000700_days_add_title.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 7 (`db/migrations.ts`). + */ +export class Migration20200101000700_days_add_title extends Migration { + override name = 'Migration20200101000700_days_add_title'; + + override async up(): Promise { + await addColumnIfMissing(this, 'days', 'title', `title TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101000800_reservations_add_status.ts b/server/src/db/migrations/Migration20200101000800_reservations_add_status.ts new file mode 100644 index 000000000..cd81010cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101000800_reservations_add_status.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 8 (`db/migrations.ts`). + */ +export class Migration20200101000800_reservations_add_status extends Migration { + override name = 'Migration20200101000800_reservations_add_status'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'status', `status TEXT DEFAULT 'pending'`); + } +} diff --git a/server/src/db/migrations/Migration20200101000900_trip_files_add_reservation_id.ts b/server/src/db/migrations/Migration20200101000900_trip_files_add_reservation_id.ts new file mode 100644 index 000000000..7d1266588 --- /dev/null +++ b/server/src/db/migrations/Migration20200101000900_trip_files_add_reservation_id.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 9 (`db/migrations.ts`). + */ +export class Migration20200101000900_trip_files_add_reservation_id extends Migration { + override name = 'Migration20200101000900_trip_files_add_reservation_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'reservation_id', + `reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101001000_reservations_add_type.ts b/server/src/db/migrations/Migration20200101001000_reservations_add_type.ts new file mode 100644 index 000000000..93e18b291 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001000_reservations_add_type.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 10 (`db/migrations.ts`). + */ +export class Migration20200101001000_reservations_add_type extends Migration { + override name = 'Migration20200101001000_reservations_add_type'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'type', `type TEXT DEFAULT 'other'`); + } +} diff --git a/server/src/db/migrations/Migration20200101001100_trips_add_cover_image.ts b/server/src/db/migrations/Migration20200101001100_trips_add_cover_image.ts new file mode 100644 index 000000000..2f9ccb444 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001100_trips_add_cover_image.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 11 (`db/migrations.ts`). + */ +export class Migration20200101001100_trips_add_cover_image extends Migration { + override name = 'Migration20200101001100_trips_add_cover_image'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'cover_image', `cover_image TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001200_day_notes_add_icon.ts b/server/src/db/migrations/Migration20200101001200_day_notes_add_icon.ts new file mode 100644 index 000000000..e93a3ba86 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001200_day_notes_add_icon.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 12 (`db/migrations.ts`). + */ +export class Migration20200101001200_day_notes_add_icon extends Migration { + override name = 'Migration20200101001200_day_notes_add_icon'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_notes', 'icon', `icon TEXT DEFAULT '📝'`); + } +} diff --git a/server/src/db/migrations/Migration20200101001300_trips_add_is_archived.ts b/server/src/db/migrations/Migration20200101001300_trips_add_is_archived.ts new file mode 100644 index 000000000..045008552 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001300_trips_add_is_archived.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 13 (`db/migrations.ts`). + */ +export class Migration20200101001300_trips_add_is_archived extends Migration { + override name = 'Migration20200101001300_trips_add_is_archived'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'is_archived', `is_archived INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101001400_categories_add_user_id.ts b/server/src/db/migrations/Migration20200101001400_categories_add_user_id.ts new file mode 100644 index 000000000..7d043a8ed --- /dev/null +++ b/server/src/db/migrations/Migration20200101001400_categories_add_user_id.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 14 (`db/migrations.ts`). + */ +export class Migration20200101001400_categories_add_user_id extends Migration { + override name = 'Migration20200101001400_categories_add_user_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'categories', 'user_id', `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101001500_users_add_avatar.ts b/server/src/db/migrations/Migration20200101001500_users_add_avatar.ts new file mode 100644 index 000000000..34e82e319 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001500_users_add_avatar.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 15 (`db/migrations.ts`). + */ +export class Migration20200101001500_users_add_avatar extends Migration { + override name = 'Migration20200101001500_users_add_avatar'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'avatar', `avatar TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001600_users_add_oidc_sub.ts b/server/src/db/migrations/Migration20200101001600_users_add_oidc_sub.ts new file mode 100644 index 000000000..254784fab --- /dev/null +++ b/server/src/db/migrations/Migration20200101001600_users_add_oidc_sub.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 16 (`db/migrations.ts`). + */ +export class Migration20200101001600_users_add_oidc_sub extends Migration { + override name = 'Migration20200101001600_users_add_oidc_sub'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'oidc_sub', `oidc_sub TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001700_users_add_oidc_issuer.ts b/server/src/db/migrations/Migration20200101001700_users_add_oidc_issuer.ts new file mode 100644 index 000000000..025bf2716 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001700_users_add_oidc_issuer.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 17 (`db/migrations.ts`). + */ +export class Migration20200101001700_users_add_oidc_issuer extends Migration { + override name = 'Migration20200101001700_users_add_oidc_issuer'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'oidc_issuer', `oidc_issuer TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101001800_users_add_last_login.ts b/server/src/db/migrations/Migration20200101001800_users_add_last_login.ts new file mode 100644 index 000000000..0a68c40e5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101001800_users_add_last_login.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 18 (`db/migrations.ts`). + */ +export class Migration20200101001800_users_add_last_login extends Migration { + override name = 'Migration20200101001800_users_add_last_login'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'last_login', `last_login DATETIME`); + } +} diff --git a/server/src/db/migrations/Migration20200101001900_create_budget_items_new.ts b/server/src/db/migrations/Migration20200101001900_create_budget_items_new.ts new file mode 100644 index 000000000..c2078511a --- /dev/null +++ b/server/src/db/migrations/Migration20200101001900_create_budget_items_new.ts @@ -0,0 +1,43 @@ +import { tableSql } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 19 (`db/migrations.ts`). + * + * Drops the `NOT NULL DEFAULT 1` on `budget_items.persons`/`days` so an expense + * can leave them unset. SQLite cannot alter a column default, hence the rebuild; + * it only fires on databases whose stored DDL still carries the old constraint. + * + * The legacy step matched the bare string `NOT NULL DEFAULT 1` anywhere in the + * table's DDL. That is narrowed here to the two columns it is actually about: + * step 132 later adds `exchange_rate REAL NOT NULL DEFAULT 1`, so the loose test + * reports true on a fully-migrated table and the 10-column rebuild below then + * fails against its 17 columns. The legacy runner never hit that because + * `schema_version` stopped the step re-running; nothing gates it here. + */ +export class Migration20200101001900_create_budget_items_new extends Migration { + override name = 'Migration20200101001900_create_budget_items_new'; + + override async up(): Promise { + const sql = await tableSql(this, 'budget_items'); + if (!sql || !/\bpersons\s+INTEGER\s+NOT\s+NULL\s+DEFAULT\s+1/i.test(sql)) return; + + await this.execute(` + CREATE TABLE budget_items_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category TEXT NOT NULL DEFAULT 'Other', + name TEXT NOT NULL, + total_price REAL NOT NULL DEFAULT 0, + persons INTEGER DEFAULT NULL, + days INTEGER DEFAULT NULL, + note TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(`INSERT INTO budget_items_new SELECT * FROM budget_items`); + await this.execute(`DROP TABLE budget_items`); + await this.execute(`ALTER TABLE budget_items_new RENAME TO budget_items`); + } +} diff --git a/server/src/db/migrations/Migration20200101002000_day_accommodations_add_check_in.ts b/server/src/db/migrations/Migration20200101002000_day_accommodations_add_check_in.ts new file mode 100644 index 000000000..b9497dfcc --- /dev/null +++ b/server/src/db/migrations/Migration20200101002000_day_accommodations_add_check_in.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 20 (`db/migrations.ts`). + */ +export class Migration20200101002000_day_accommodations_add_check_in extends Migration { + override name = 'Migration20200101002000_day_accommodations_add_check_in'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_accommodations', 'check_in', `check_in TEXT`); + + await addColumnIfMissing(this, 'day_accommodations', 'check_out', `check_out TEXT`); + + await addColumnIfMissing(this, 'day_accommodations', 'confirmation', `confirmation TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101002100_places_add_end_time.ts b/server/src/db/migrations/Migration20200101002100_places_add_end_time.ts new file mode 100644 index 000000000..07d83e7b8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101002100_places_add_end_time.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 21 (`db/migrations.ts`). + */ +export class Migration20200101002100_places_add_end_time extends Migration { + override name = 'Migration20200101002100_places_add_end_time'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'end_time', `end_time TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101002200_day_assignments_add_reservation_status.ts b/server/src/db/migrations/Migration20200101002200_day_assignments_add_reservation_status.ts new file mode 100644 index 000000000..09ef7c4ad --- /dev/null +++ b/server/src/db/migrations/Migration20200101002200_day_assignments_add_reservation_status.ts @@ -0,0 +1,31 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 22 (`db/migrations.ts`). + * + * Moves reservation state off the place and onto the day assignment, so the same + * place booked on two days keeps two independent bookings. The copy is + * best-effort: it reads `places` columns that a later step removes, and the + * original step logged and swallowed a failure rather than blocking the boot. + */ +export class Migration20200101002200_day_assignments_add_reservation_status extends Migration { + override name = 'Migration20200101002200_day_assignments_add_reservation_status'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'reservation_status', `reservation_status TEXT DEFAULT 'none'`); + await addColumnIfMissing(this, 'day_assignments', 'reservation_notes', `reservation_notes TEXT`); + await addColumnIfMissing(this, 'day_assignments', 'reservation_datetime', `reservation_datetime TEXT`); + + await execBestEffort( + this, + ` + UPDATE day_assignments SET + reservation_status = (SELECT reservation_status FROM places WHERE places.id = day_assignments.place_id), + reservation_notes = (SELECT reservation_notes FROM places WHERE places.id = day_assignments.place_id), + reservation_datetime = (SELECT reservation_datetime FROM places WHERE places.id = day_assignments.place_id) + WHERE place_id IN (SELECT id FROM places WHERE reservation_status IS NOT NULL AND reservation_status != 'none') + `, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002300_reservations_add_assignment_id.ts b/server/src/db/migrations/Migration20200101002300_reservations_add_assignment_id.ts new file mode 100644 index 000000000..92bdda5dc --- /dev/null +++ b/server/src/db/migrations/Migration20200101002300_reservations_add_assignment_id.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 23 (`db/migrations.ts`). + */ +export class Migration20200101002300_reservations_add_assignment_id extends Migration { + override name = 'Migration20200101002300_reservations_add_assignment_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'reservations', + 'assignment_id', + `assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002400_create_assignment_participants.ts b/server/src/db/migrations/Migration20200101002400_create_assignment_participants.ts new file mode 100644 index 000000000..a9e8b0cad --- /dev/null +++ b/server/src/db/migrations/Migration20200101002400_create_assignment_participants.ts @@ -0,0 +1,19 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 24 (`db/migrations.ts`). + */ +export class Migration20200101002400_create_assignment_participants extends Migration { + override name = 'Migration20200101002400_create_assignment_participants'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS assignment_participants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(assignment_id, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101002500_create_collab_notes.ts b/server/src/db/migrations/Migration20200101002500_create_collab_notes.ts new file mode 100644 index 000000000..8c9285458 --- /dev/null +++ b/server/src/db/migrations/Migration20200101002500_create_collab_notes.ts @@ -0,0 +1,69 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 25 (`db/migrations.ts`). + * + * The Collab addon: shared notes, polls and trip chat. + */ +export class Migration20200101002500_create_collab_notes extends Migration { + override name = 'Migration20200101002500_create_collab_notes'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + category TEXT DEFAULT 'General', + title TEXT NOT NULL, + content TEXT, + color TEXT DEFAULT '#6366f1', + pinned INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_polls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question TEXT NOT NULL, + options TEXT NOT NULL, + multiple INTEGER DEFAULT 0, + closed INTEGER DEFAULT 0, + deadline TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_poll_votes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + poll_id INTEGER NOT NULL REFERENCES collab_polls(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + option_index INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(poll_id, user_id, option_index) + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS collab_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + text TEXT NOT NULL, + reply_to INTEGER REFERENCES collab_messages(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_notes_trip ON collab_notes(trip_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_polls_trip ON collab_polls(trip_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_collab_messages_trip ON collab_messages(trip_id)`); + + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('collab', 'Collab', 'Notes, polls, and live chat for trip collaboration', 'trip', 'Users', 1, 6)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002600_day_assignments_add_assignment_time.ts b/server/src/db/migrations/Migration20200101002600_day_assignments_add_assignment_time.ts new file mode 100644 index 000000000..5696d0c4f --- /dev/null +++ b/server/src/db/migrations/Migration20200101002600_day_assignments_add_assignment_time.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 26 (`db/migrations.ts`). + */ +export class Migration20200101002600_day_assignments_add_assignment_time extends Migration { + override name = 'Migration20200101002600_day_assignments_add_assignment_time'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'assignment_time', `assignment_time TEXT`); + + await addColumnIfMissing(this, 'day_assignments', 'assignment_end_time', `assignment_end_time TEXT`); + + await execBestEffort( + this, + ` + UPDATE day_assignments SET + assignment_time = (SELECT place_time FROM places WHERE places.id = day_assignments.place_id), + assignment_end_time = (SELECT end_time FROM places WHERE places.id = day_assignments.place_id) + `, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101002700_create_budget_item_members.ts b/server/src/db/migrations/Migration20200101002700_create_budget_item_members.ts new file mode 100644 index 000000000..1409c5efa --- /dev/null +++ b/server/src/db/migrations/Migration20200101002700_create_budget_item_members.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 27 (`db/migrations.ts`). + */ +export class Migration20200101002700_create_budget_item_members extends Migration { + override name = 'Migration20200101002700_create_budget_item_members'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS budget_item_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + paid INTEGER NOT NULL DEFAULT 0, + UNIQUE(budget_item_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_item_members_item ON budget_item_members(budget_item_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101002800_create_collab_message_reactions.ts b/server/src/db/migrations/Migration20200101002800_create_collab_message_reactions.ts new file mode 100644 index 000000000..c47466faf --- /dev/null +++ b/server/src/db/migrations/Migration20200101002800_create_collab_message_reactions.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 28 (`db/migrations.ts`). + */ +export class Migration20200101002800_create_collab_message_reactions extends Migration { + override name = 'Migration20200101002800_create_collab_message_reactions'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_message_reactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES collab_messages(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(message_id, user_id, emoji) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_reactions_msg ON collab_message_reactions(message_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101002900_collab_messages_add_deleted.ts b/server/src/db/migrations/Migration20200101002900_collab_messages_add_deleted.ts new file mode 100644 index 000000000..b499c3c2c --- /dev/null +++ b/server/src/db/migrations/Migration20200101002900_collab_messages_add_deleted.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 29 (`db/migrations.ts`). + */ +export class Migration20200101002900_collab_messages_add_deleted extends Migration { + override name = 'Migration20200101002900_collab_messages_add_deleted'; + + override async up(): Promise { + await addColumnIfMissing(this, 'collab_messages', 'deleted', `deleted INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101003000_trip_files_add_note_id.ts b/server/src/db/migrations/Migration20200101003000_trip_files_add_note_id.ts new file mode 100644 index 000000000..729cff68d --- /dev/null +++ b/server/src/db/migrations/Migration20200101003000_trip_files_add_note_id.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 30 (`db/migrations.ts`). + */ +export class Migration20200101003000_trip_files_add_note_id extends Migration { + override name = 'Migration20200101003000_trip_files_add_note_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'note_id', + `note_id INTEGER REFERENCES collab_notes(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'collab_notes', 'website', `website TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003100_reservations_add_reservation_end_time.ts b/server/src/db/migrations/Migration20200101003100_reservations_add_reservation_end_time.ts new file mode 100644 index 000000000..1791d5527 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003100_reservations_add_reservation_end_time.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 31 (`db/migrations.ts`). + */ +export class Migration20200101003100_reservations_add_reservation_end_time extends Migration { + override name = 'Migration20200101003100_reservations_add_reservation_end_time'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'reservation_end_time', `reservation_end_time TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003200_places_add_osm_id.ts b/server/src/db/migrations/Migration20200101003200_places_add_osm_id.ts new file mode 100644 index 000000000..264c45027 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003200_places_add_osm_id.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 32 (`db/migrations.ts`). + */ +export class Migration20200101003200_places_add_osm_id extends Migration { + override name = 'Migration20200101003200_places_add_osm_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'osm_id', `osm_id TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003300_trip_files_add_uploaded_by.ts b/server/src/db/migrations/Migration20200101003300_trip_files_add_uploaded_by.ts new file mode 100644 index 000000000..b8426d324 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003300_trip_files_add_uploaded_by.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 33 (`db/migrations.ts`). + */ +export class Migration20200101003300_trip_files_add_uploaded_by extends Migration { + override name = 'Migration20200101003300_trip_files_add_uploaded_by'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'uploaded_by', + `uploaded_by INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'trip_files', 'starred', `starred INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'trip_files', 'deleted_at', `deleted_at TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003400_reservations_add_accommodation_id.ts b/server/src/db/migrations/Migration20200101003400_reservations_add_accommodation_id.ts new file mode 100644 index 000000000..0d379147e --- /dev/null +++ b/server/src/db/migrations/Migration20200101003400_reservations_add_accommodation_id.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 34 (`db/migrations.ts`). + */ +export class Migration20200101003400_reservations_add_accommodation_id extends Migration { + override name = 'Migration20200101003400_reservations_add_accommodation_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'reservations', + 'accommodation_id', + `accommodation_id INTEGER REFERENCES day_accommodations(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'reservations', 'metadata', `metadata TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003500_create_invite_tokens.ts b/server/src/db/migrations/Migration20200101003500_create_invite_tokens.ts new file mode 100644 index 000000000..cbbfe2904 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003500_create_invite_tokens.ts @@ -0,0 +1,22 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 35 (`db/migrations.ts`). + */ +export class Migration20200101003500_create_invite_tokens extends Migration { + override name = 'Migration20200101003500_create_invite_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS invite_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + max_uses INTEGER NOT NULL DEFAULT 1, + used_count INTEGER NOT NULL DEFAULT 0, + expires_at TEXT, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101003600_users_add_mfa_enabled.ts b/server/src/db/migrations/Migration20200101003600_users_add_mfa_enabled.ts new file mode 100644 index 000000000..811ee8166 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003600_users_add_mfa_enabled.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 36 (`db/migrations.ts`). + */ +export class Migration20200101003600_users_add_mfa_enabled extends Migration { + override name = 'Migration20200101003600_users_add_mfa_enabled'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'mfa_enabled', `mfa_enabled INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'users', 'mfa_secret', `mfa_secret TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101003700_create_packing_category_assignees.ts b/server/src/db/migrations/Migration20200101003700_create_packing_category_assignees.ts new file mode 100644 index 000000000..85b89a89c --- /dev/null +++ b/server/src/db/migrations/Migration20200101003700_create_packing_category_assignees.ts @@ -0,0 +1,20 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 37 (`db/migrations.ts`). + */ +export class Migration20200101003700_create_packing_category_assignees extends Migration { + override name = 'Migration20200101003700_create_packing_category_assignees'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_category_assignees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category_name TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(trip_id, category_name, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101003800_create_packing_templates.ts b/server/src/db/migrations/Migration20200101003800_create_packing_templates.ts new file mode 100644 index 000000000..57a438029 --- /dev/null +++ b/server/src/db/migrations/Migration20200101003800_create_packing_templates.ts @@ -0,0 +1,40 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 38 (`db/migrations.ts`). + */ +export class Migration20200101003800_create_packing_templates extends Migration { + override name = 'Migration20200101003800_create_packing_templates'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_template_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL REFERENCES packing_templates(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + + await execBestEffort(this, `DROP TABLE IF EXISTS packing_template_items`); + + await this.execute(` + CREATE TABLE packing_template_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER NOT NULL REFERENCES packing_template_categories(id) ON DELETE CASCADE, + name TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0 + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101003900_create_packing_bags.ts b/server/src/db/migrations/Migration20200101003900_create_packing_bags.ts new file mode 100644 index 000000000..695d773ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101003900_create_packing_bags.ts @@ -0,0 +1,32 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 39 (`db/migrations.ts`). + */ +export class Migration20200101003900_create_packing_bags extends Migration { + override name = 'Migration20200101003900_create_packing_bags'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_bags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT NOT NULL DEFAULT '#6366f1', + weight_limit_grams INTEGER, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await addColumnIfMissing(this, 'packing_items', 'weight_grams', `weight_grams INTEGER`); + + await addColumnIfMissing( + this, + 'packing_items', + 'bag_id', + `bag_id INTEGER REFERENCES packing_bags(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101004000_create_visited_countries.ts b/server/src/db/migrations/Migration20200101004000_create_visited_countries.ts new file mode 100644 index 000000000..c46857845 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004000_create_visited_countries.ts @@ -0,0 +1,20 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 40 (`db/migrations.ts`). + */ +export class Migration20200101004000_create_visited_countries extends Migration { + override name = 'Migration20200101004000_create_visited_countries'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS visited_countries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, country_code) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004100_create_bucket_list.ts b/server/src/db/migrations/Migration20200101004100_create_bucket_list.ts new file mode 100644 index 000000000..f372c8f59 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004100_create_bucket_list.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 41 (`db/migrations.ts`). + */ +export class Migration20200101004100_create_bucket_list extends Migration { + override name = 'Migration20200101004100_create_bucket_list'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS bucket_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + lat REAL, + lng REAL, + country_code TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004200_vacay_plans_add_weekend_days.ts b/server/src/db/migrations/Migration20200101004200_vacay_plans_add_weekend_days.ts new file mode 100644 index 000000000..1e0d455cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101004200_vacay_plans_add_weekend_days.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 42 (`db/migrations.ts`). + */ +export class Migration20200101004200_vacay_plans_add_weekend_days extends Migration { + override name = 'Migration20200101004200_vacay_plans_add_weekend_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_plans', 'weekend_days', `weekend_days TEXT DEFAULT '0,6'`); + } +} diff --git a/server/src/db/migrations/Migration20200101004300_users_add_immich_url.ts b/server/src/db/migrations/Migration20200101004300_users_add_immich_url.ts new file mode 100644 index 000000000..0ce188e30 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004300_users_add_immich_url.ts @@ -0,0 +1,35 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 43 (`db/migrations.ts`). + * + * Immich integration: per-user credentials, the linked-asset table, and the + * Photos addon tile (disabled by default). + */ +export class Migration20200101004300_users_add_immich_url extends Migration { + override name = 'Migration20200101004300_users_add_immich_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'immich_url', `immich_url TEXT`); + await addColumnIfMissing(this, 'users', 'immich_api_key', `immich_api_key TEXT`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS trip_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + immich_asset_id TEXT NOT NULL, + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, immich_asset_id) + ) + `); + + await execBestEffort( + this, + `INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)`, + ['memories', 'Photos', 'trip', 'Image', 0, 7], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101004400_create_file_links.ts b/server/src/db/migrations/Migration20200101004400_create_file_links.ts new file mode 100644 index 000000000..47168a494 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004400_create_file_links.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 44 (`db/migrations.ts`). + */ +export class Migration20200101004400_create_file_links extends Migration { + override name = 'Migration20200101004400_create_file_links'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS file_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES trip_files(id) ON DELETE CASCADE, + reservation_id INTEGER REFERENCES reservations(id) ON DELETE CASCADE, + assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE CASCADE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(file_id, reservation_id), + UNIQUE(file_id, assignment_id), + UNIQUE(file_id, place_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004500_reservations_add_day_plan_position.ts b/server/src/db/migrations/Migration20200101004500_reservations_add_day_plan_position.ts new file mode 100644 index 000000000..f0b707fbd --- /dev/null +++ b/server/src/db/migrations/Migration20200101004500_reservations_add_day_plan_position.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 45 (`db/migrations.ts`). + */ +export class Migration20200101004500_reservations_add_day_plan_position extends Migration { + override name = 'Migration20200101004500_reservations_add_day_plan_position'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'day_plan_position', `day_plan_position REAL DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101004600_budget_items_add_paid_by_user_id.ts b/server/src/db/migrations/Migration20200101004600_budget_items_add_paid_by_user_id.ts new file mode 100644 index 000000000..e67ecb690 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004600_budget_items_add_paid_by_user_id.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 46 (`db/migrations.ts`). + */ +export class Migration20200101004600_budget_items_add_paid_by_user_id extends Migration { + override name = 'Migration20200101004600_budget_items_add_paid_by_user_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'paid_by_user_id', `paid_by_user_id INTEGER REFERENCES users(id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101004700_bucket_list_add_target_date.ts b/server/src/db/migrations/Migration20200101004700_bucket_list_add_target_date.ts new file mode 100644 index 000000000..c0d85e6a3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004700_bucket_list_add_target_date.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 47 (`db/migrations.ts`). + */ +export class Migration20200101004700_bucket_list_add_target_date extends Migration { + override name = 'Migration20200101004700_bucket_list_add_target_date'; + + override async up(): Promise { + await addColumnIfMissing(this, 'bucket_list', 'target_date', `target_date TEXT DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101004800_create_notification_preferences.ts b/server/src/db/migrations/Migration20200101004800_create_notification_preferences.ts new file mode 100644 index 000000000..e4b3650cf --- /dev/null +++ b/server/src/db/migrations/Migration20200101004800_create_notification_preferences.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 48 (`db/migrations.ts`). + */ +export class Migration20200101004800_create_notification_preferences extends Migration { + override name = 'Migration20200101004800_create_notification_preferences'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS notification_preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + notify_trip_invite INTEGER DEFAULT 1, + notify_booking_change INTEGER DEFAULT 1, + notify_trip_reminder INTEGER DEFAULT 1, + notify_vacay_invite INTEGER DEFAULT 1, + notify_photos_shared INTEGER DEFAULT 1, + notify_collab_message INTEGER DEFAULT 1, + notify_packing_tagged INTEGER DEFAULT 1, + notify_webhook INTEGER DEFAULT 0, + UNIQUE(user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101004900_notification_preferences_add_notify_vacay_invite.ts b/server/src/db/migrations/Migration20200101004900_notification_preferences_add_notify_vacay_invite.ts new file mode 100644 index 000000000..e049bcb43 --- /dev/null +++ b/server/src/db/migrations/Migration20200101004900_notification_preferences_add_notify_vacay_invite.ts @@ -0,0 +1,39 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 49 (`db/migrations.ts`). + */ +export class Migration20200101004900_notification_preferences_add_notify_vacay_invite extends Migration { + override name = 'Migration20200101004900_notification_preferences_add_notify_vacay_invite'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_vacay_invite', + `notify_vacay_invite INTEGER DEFAULT 1`, + ); + + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_photos_shared', + `notify_photos_shared INTEGER DEFAULT 1`, + ); + + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_collab_message', + `notify_collab_message INTEGER DEFAULT 1`, + ); + + await addColumnIfMissing( + this, + 'notification_preferences', + 'notify_packing_tagged', + `notify_packing_tagged INTEGER DEFAULT 1`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101005000_create_share_tokens.ts b/server/src/db/migrations/Migration20200101005000_create_share_tokens.ts new file mode 100644 index 000000000..2a63323e8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005000_create_share_tokens.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 50 (`db/migrations.ts`). + */ +export class Migration20200101005000_create_share_tokens extends Migration { + override name = 'Migration20200101005000_create_share_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS share_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL REFERENCES users(id), + share_map INTEGER DEFAULT 1, + share_bookings INTEGER DEFAULT 1, + share_packing INTEGER DEFAULT 0, + share_budget INTEGER DEFAULT 0, + share_collab INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101005100_share_tokens_add_share_map.ts b/server/src/db/migrations/Migration20200101005100_share_tokens_add_share_map.ts new file mode 100644 index 000000000..5db4db8d7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005100_share_tokens_add_share_map.ts @@ -0,0 +1,21 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 51 (`db/migrations.ts`). + */ +export class Migration20200101005100_share_tokens_add_share_map extends Migration { + override name = 'Migration20200101005100_share_tokens_add_share_map'; + + override async up(): Promise { + await addColumnIfMissing(this, 'share_tokens', 'share_map', `share_map INTEGER DEFAULT 1`); + + await addColumnIfMissing(this, 'share_tokens', 'share_bookings', `share_bookings INTEGER DEFAULT 1`); + + await addColumnIfMissing(this, 'share_tokens', 'share_packing', `share_packing INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'share_tokens', 'share_budget', `share_budget INTEGER DEFAULT 0`); + + await addColumnIfMissing(this, 'share_tokens', 'share_collab', `share_collab INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101005200_create_audit_log.ts b/server/src/db/migrations/Migration20200101005200_create_audit_log.ts new file mode 100644 index 000000000..c9cd6f5c5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005200_create_audit_log.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 52 (`db/migrations.ts`). + */ +export class Migration20200101005200_create_audit_log extends Migration { + override name = 'Migration20200101005200_create_audit_log'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + resource TEXT, + details TEXT, + ip TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`); + } +} diff --git a/server/src/db/migrations/Migration20200101005300_users_add_mfa_backup_codes.ts b/server/src/db/migrations/Migration20200101005300_users_add_mfa_backup_codes.ts new file mode 100644 index 000000000..af8e499fb --- /dev/null +++ b/server/src/db/migrations/Migration20200101005300_users_add_mfa_backup_codes.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 53 (`db/migrations.ts`). + */ +export class Migration20200101005300_users_add_mfa_backup_codes extends Migration { + override name = 'Migration20200101005300_users_add_mfa_backup_codes'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'mfa_backup_codes', `mfa_backup_codes TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101005400_mcp_long_lived_api_tokens.ts b/server/src/db/migrations/Migration20200101005400_mcp_long_lived_api_tokens.ts new file mode 100644 index 000000000..33c9893ff --- /dev/null +++ b/server/src/db/migrations/Migration20200101005400_mcp_long_lived_api_tokens.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 54 (`db/migrations.ts`). + * + * MCP long-lived API tokens + */ +export class Migration20200101005400_mcp_long_lived_api_tokens extends Migration { + override name = 'Migration20200101005400_mcp_long_lived_api_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS mcp_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101005500_mcp_addon_entry.ts b/server/src/db/migrations/Migration20200101005500_mcp_addon_entry.ts new file mode 100644 index 000000000..2928316a5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005500_mcp_addon_entry.ts @@ -0,0 +1,19 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 55 (`db/migrations.ts`). + * + * MCP addon entry. + */ +export class Migration20200101005500_mcp_addon_entry extends Migration { + override name = 'Migration20200101005500_mcp_addon_entry'; + + override async up(): Promise { + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['mcp', 'MCP', 'Model Context Protocol for AI assistant integration', 'integration', 'Terminal', 0, 12], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101005600_index_on_mcp_tokens.ts b/server/src/db/migrations/Migration20200101005600_index_on_mcp_tokens.ts new file mode 100644 index 000000000..4ccbfcbe5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005600_index_on_mcp_tokens.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 56 (`db/migrations.ts`). + * + * Index on mcp_tokens.token_hash + */ +export class Migration20200101005600_index_on_mcp_tokens extends Migration { + override name = 'Migration20200101005600_index_on_mcp_tokens'; + + override up(): void { + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_tokens_hash ON mcp_tokens(token_hash)`); + } +} diff --git a/server/src/db/migrations/Migration20200101005700_ensure_mcp_addon_type_is_integration.ts b/server/src/db/migrations/Migration20200101005700_ensure_mcp_addon_type_is_integration.ts new file mode 100644 index 000000000..8567cdafa --- /dev/null +++ b/server/src/db/migrations/Migration20200101005700_ensure_mcp_addon_type_is_integration.ts @@ -0,0 +1,15 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 57 (`db/migrations.ts`). + * + * Ensure MCP addon type is 'integration'. + */ +export class Migration20200101005700_ensure_mcp_addon_type_is_integration extends Migration { + override name = 'Migration20200101005700_ensure_mcp_addon_type_is_integration'; + + override async up(): Promise { + await execBestEffort(this, `UPDATE addons SET type = 'integration' WHERE id = 'mcp'`); + } +} diff --git a/server/src/db/migrations/Migration20200101005800_places_add_route_geometry.ts b/server/src/db/migrations/Migration20200101005800_places_add_route_geometry.ts new file mode 100644 index 000000000..15ab1cfc9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005800_places_add_route_geometry.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 58 (`db/migrations.ts`). + */ +export class Migration20200101005800_places_add_route_geometry extends Migration { + override name = 'Migration20200101005800_places_add_route_geometry'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'route_geometry', `route_geometry TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101005900_users_add_must_change_password.ts b/server/src/db/migrations/Migration20200101005900_users_add_must_change_password.ts new file mode 100644 index 000000000..f25e36b57 --- /dev/null +++ b/server/src/db/migrations/Migration20200101005900_users_add_must_change_password.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 59 (`db/migrations.ts`). + */ +export class Migration20200101005900_users_add_must_change_password extends Migration { + override name = 'Migration20200101005900_users_add_must_change_password'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'must_change_password', `must_change_password INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101010000_trips_add_reminder_days.ts b/server/src/db/migrations/Migration20200101010000_trips_add_reminder_days.ts new file mode 100644 index 000000000..31d6a8202 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010000_trips_add_reminder_days.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 60 (`db/migrations.ts`). + */ +export class Migration20200101010000_trips_add_reminder_days extends Migration { + override name = 'Migration20200101010000_trips_add_reminder_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'reminder_days', `reminder_days INTEGER DEFAULT 3`); + } +} diff --git a/server/src/db/migrations/Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left.ts b/server/src/db/migrations/Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left.ts new file mode 100644 index 000000000..294fba686 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left.ts @@ -0,0 +1,20 @@ +import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 61 (`db/migrations.ts`). + * + * Encrypt any plaintext oidc_client_secret left in app_settings. + */ +export class Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left extends Migration { + override name = 'Migration20200101010100_encrypt_any_plaintext_oidc_client_secret_left'; + + override async up(): Promise { + const rows = (await this.execute(`SELECT value FROM app_settings WHERE key = 'oidc_client_secret'`)) as { + value: string | null; + }[]; + const value = rows[0]?.value; + if (!value || value.startsWith('enc:v1:')) return; + await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'oidc_client_secret'`, [encrypt_api_key(value)]); + } +} diff --git a/server/src/db/migrations/Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in.ts b/server/src/db/migrations/Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in.ts new file mode 100644 index 000000000..22c0bd15a --- /dev/null +++ b/server/src/db/migrations/Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in.ts @@ -0,0 +1,20 @@ +import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 62 (`db/migrations.ts`). + * + * Encrypt any plaintext smtp_pass left in app_settings. + */ +export class Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in extends Migration { + override name = 'Migration20200101010200_encrypt_any_plaintext_smtp_pass_left_in'; + + override async up(): Promise { + const rows = (await this.execute(`SELECT value FROM app_settings WHERE key = 'smtp_pass'`)) as { + value: string | null; + }[]; + const value = rows[0]?.value; + if (!value || value.startsWith('enc:v1:')) return; + await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'smtp_pass'`, [encrypt_api_key(value)]); + } +} diff --git a/server/src/db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts b/server/src/db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts new file mode 100644 index 000000000..2fbd64f76 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010300_encrypt_any_plaintext_immich_api_key_values.ts @@ -0,0 +1,23 @@ +import { encrypt_api_key } from '../../nest/common/crypto/apiKeyCrypto'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 63 (`db/migrations.ts`). + * + * Encrypt any plaintext immich_api_key values in the users table. + */ +export class Migration20200101010300_encrypt_any_plaintext_immich_api_key_values extends Migration { + override name = 'Migration20200101010300_encrypt_any_plaintext_immich_api_key_values'; + + override async up(): Promise { + const rows = (await this.execute( + `SELECT id, immich_api_key FROM users WHERE immich_api_key IS NOT NULL AND immich_api_key != '' AND immich_api_key NOT LIKE 'enc:v1:%'`, + )) as { id: number; immich_api_key: string }[]; + for (const row of rows) { + await this.execute(`UPDATE users SET immich_api_key = ? WHERE id = ?`, [ + encrypt_api_key(row.immich_api_key), + row.id, + ]); + } + } +} diff --git a/server/src/db/migrations/Migration20200101010400_budget_items_add_expense_date.ts b/server/src/db/migrations/Migration20200101010400_budget_items_add_expense_date.ts new file mode 100644 index 000000000..a411629c0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010400_budget_items_add_expense_date.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 64 (`db/migrations.ts`). + */ +export class Migration20200101010400_budget_items_add_expense_date extends Migration { + override name = 'Migration20200101010400_budget_items_add_expense_date'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'expense_date', `expense_date TEXT DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101010500_create_trip_album_links.ts b/server/src/db/migrations/Migration20200101010500_create_trip_album_links.ts new file mode 100644 index 000000000..e378a8816 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010500_create_trip_album_links.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 65 (`db/migrations.ts`). + */ +export class Migration20200101010500_create_trip_album_links extends Migration { + override name = 'Migration20200101010500_create_trip_album_links'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_album_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + immich_album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, immich_album_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101010600_create_notifications.ts b/server/src/db/migrations/Migration20200101010600_create_notifications.ts new file mode 100644 index 000000000..8d55d129d --- /dev/null +++ b/server/src/db/migrations/Migration20200101010600_create_notifications.ts @@ -0,0 +1,42 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 66 (`db/migrations.ts`). + */ +export class Migration20200101010600_create_notifications extends Migration { + override name = 'Migration20200101010600_create_notifications'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL CHECK(type IN ('simple', 'boolean', 'navigate')), + scope TEXT NOT NULL CHECK(scope IN ('trip', 'user', 'admin')), + target INTEGER NOT NULL, + sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title_key TEXT NOT NULL, + title_params TEXT DEFAULT '{}', + text_key TEXT NOT NULL, + text_params TEXT DEFAULT '{}', + positive_text_key TEXT, + negative_text_key TEXT, + positive_callback TEXT, + negative_callback TEXT, + response TEXT CHECK(response IN ('positive', 'negative')), + navigate_text_key TEXT, + navigate_target TEXT, + is_read INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON notifications(recipient_id, is_read, created_at DESC)`, + ); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_notifications_recipient_created ON notifications(recipient_id, created_at DESC)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101010700_create_trip_photos.ts b/server/src/db/migrations/Migration20200101010700_create_trip_photos.ts new file mode 100644 index 000000000..1e8585439 --- /dev/null +++ b/server/src/db/migrations/Migration20200101010700_create_trip_photos.ts @@ -0,0 +1,54 @@ +import { columnNames, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const TRIP_PHOTOS_DDL = ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + asset_id TEXT NOT NULL, + provider TEXT NOT NULL DEFAULT 'immich', + shared INTEGER NOT NULL DEFAULT 1, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, asset_id, provider) +`; + +/** + * Legacy migration step 67 (`db/migrations.ts`). + * + * Normalize trip_photos to the provider-based schema used by current routes. + * The table arrived in two shapes — Immich-only (`immich_asset_id`) and the + * provider-aware one — so the rebuild reads whichever columns are actually + * present and fills the rest with the Immich defaults. + */ +export class Migration20200101010700_create_trip_photos extends Migration { + override name = 'Migration20200101010700_create_trip_photos'; + + override async up(): Promise { + if (!(await tableExists(this, 'trip_photos'))) { + await this.execute(`CREATE TABLE trip_photos (${TRIP_PHOTOS_DDL})`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`); + return; + } + + const names = await columnNames(this, 'trip_photos'); + const assetSource = names.has('asset_id') ? 'asset_id' : names.has('immich_asset_id') ? 'immich_asset_id' : null; + if (!assetSource) return; + + const providerExpr = names.has('provider') + ? `CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END` + : `'immich'`; + const sharedExpr = names.has('shared') ? 'COALESCE(shared, 1)' : '1'; + const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + await this.execute(`CREATE TABLE trip_photos_new (${TRIP_PHOTOS_DDL})`); + await this.execute(` + INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, asset_id, provider, shared, added_at) + SELECT trip_id, user_id, ${assetSource}, ${providerExpr}, ${sharedExpr}, ${addedAtExpr} + FROM trip_photos + WHERE ${assetSource} IS NOT NULL AND TRIM(${assetSource}) != '' + `); + await this.execute(`DROP TABLE trip_photos`); + await this.execute(`ALTER TABLE trip_photos_new RENAME TO trip_photos`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101010800_create_trip_album_links.ts b/server/src/db/migrations/Migration20200101010800_create_trip_album_links.ts new file mode 100644 index 000000000..1cc875bef --- /dev/null +++ b/server/src/db/migrations/Migration20200101010800_create_trip_album_links.ts @@ -0,0 +1,56 @@ +import { columnNames, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const ALBUM_LINKS_DDL = ` + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + album_id TEXT NOT NULL, + album_name TEXT NOT NULL DEFAULT '', + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_synced_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, provider, album_id) +`; + +/** + * Legacy migration step 68 (`db/migrations.ts`). + * + * Normalize trip_album_links to the provider + album_id schema used by current + * routes, reading whichever of the two historical shapes the database has. + */ +export class Migration20200101010800_create_trip_album_links extends Migration { + override name = 'Migration20200101010800_create_trip_album_links'; + + override async up(): Promise { + if (!(await tableExists(this, 'trip_album_links'))) { + await this.execute(`CREATE TABLE trip_album_links (${ALBUM_LINKS_DDL})`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`); + return; + } + + const names = await columnNames(this, 'trip_album_links'); + const albumIdSource = names.has('album_id') ? 'album_id' : names.has('immich_album_id') ? 'immich_album_id' : null; + if (!albumIdSource) return; + + const providerExpr = names.has('provider') + ? `CASE WHEN provider IS NULL OR provider = '' THEN 'immich' ELSE provider END` + : `'immich'`; + const albumNameExpr = names.has('album_name') ? `COALESCE(album_name, '')` : `''`; + const syncEnabledExpr = names.has('sync_enabled') ? 'COALESCE(sync_enabled, 1)' : '1'; + const lastSyncedExpr = names.has('last_synced_at') ? 'last_synced_at' : 'NULL'; + const createdAtExpr = names.has('created_at') ? 'COALESCE(created_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + + await this.execute(`CREATE TABLE trip_album_links_new (${ALBUM_LINKS_DDL})`); + await this.execute(` + INSERT OR IGNORE INTO trip_album_links_new (trip_id, user_id, provider, album_id, album_name, sync_enabled, last_synced_at, created_at) + SELECT trip_id, user_id, ${providerExpr}, ${albumIdSource}, ${albumNameExpr}, ${syncEnabledExpr}, ${lastSyncedExpr}, ${createdAtExpr} + FROM trip_album_links + WHERE ${albumIdSource} IS NOT NULL AND TRIM(${albumIdSource}) != '' + `); + await this.execute(`DROP TABLE trip_album_links`); + await this.execute(`ALTER TABLE trip_album_links_new RENAME TO trip_album_links`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_album_links_trip ON trip_album_links(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101010900_users_add_synology_url.ts b/server/src/db/migrations/Migration20200101010900_users_add_synology_url.ts new file mode 100644 index 000000000..a381f85ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101010900_users_add_synology_url.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 69 (`db/migrations.ts`). + */ +export class Migration20200101010900_users_add_synology_url extends Migration { + override name = 'Migration20200101010900_users_add_synology_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'synology_url', `synology_url TEXT`); + + await addColumnIfMissing(this, 'users', 'synology_username', `synology_username TEXT`); + + await addColumnIfMissing(this, 'users', 'synology_password', `synology_password TEXT`); + + await addColumnIfMissing(this, 'users', 'synology_sid', `synology_sid TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101011000_step_070.ts b/server/src/db/migrations/Migration20200101011000_step_070.ts new file mode 100644 index 000000000..c6715abce --- /dev/null +++ b/server/src/db/migrations/Migration20200101011000_step_070.ts @@ -0,0 +1,97 @@ +import { execUnlessTableMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const PROVIDER_FIELDS: [string, string, string, string, string, number, number, string | null, string, number][] = [ + [ + 'synologyphotos', + 'synology_url', + 'providerUrl', + 'url', + 'https://synology.example.com', + 1, + 0, + 'synology_url', + 'synology_url', + 0, + ], + [ + 'synologyphotos', + 'synology_username', + 'providerUsername', + 'text', + 'Username', + 1, + 0, + 'synology_username', + 'synology_username', + 1, + ], + [ + 'synologyphotos', + 'synology_password', + 'providerPassword', + 'password', + 'Password', + 1, + 1, + null, + 'synology_password', + 2, + ], +]; + +/** + * Legacy migration step 70 (`db/migrations.ts`). + * + * Seed the Synology Photos provider and its fields in existing databases. Both + * statements are skipped when the provider tables do not exist yet — on those + * installs the seeder creates the rows instead. + */ +export class Migration20200101011000_step_070 extends Migration { + override name = 'Migration20200101011000_step_070'; + + override async up(): Promise { + await execUnlessTableMissing( + this, + ` + INSERT INTO photo_providers (id, name, description, icon, enabled, sort_order) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + description = excluded.description, + icon = excluded.icon, + enabled = excluded.enabled, + sort_order = excluded.sort_order + `, + [ + 'synologyphotos', + 'Synology Photos', + 'Synology Photos integration with separate account settings', + 'Image', + 0, + 1, + ], + ); + + for (const field of PROVIDER_FIELDS) { + await execUnlessTableMissing( + this, + ` + INSERT INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider_id, field_key) DO UPDATE SET + label = excluded.label, + input_type = excluded.input_type, + placeholder = excluded.placeholder, + required = excluded.required, + secret = excluded.secret, + settings_key = excluded.settings_key, + payload_key = excluded.payload_key, + sort_order = excluded.sort_order + `, + field, + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101011100_step_071.ts b/server/src/db/migrations/Migration20200101011100_step_071.ts new file mode 100644 index 000000000..a72836c66 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011100_step_071.ts @@ -0,0 +1,18 @@ +import { columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 71 (`db/migrations.ts`). + * + * Remove the stored config column from photo_providers now that it is generated + * from the provider id. + */ +export class Migration20200101011100_step_071 extends Migration { + override name = 'Migration20200101011100_step_071'; + + override async up(): Promise { + const names = await columnNames(this, 'photo_providers'); + if (!names.has('config')) return; + await this.execute(`ALTER TABLE photo_providers DROP COLUMN config`); + } +} diff --git a/server/src/db/migrations/Migration20200101011200_step_072.ts b/server/src/db/migrations/Migration20200101011200_step_072.ts new file mode 100644 index 000000000..cd5bdc70d --- /dev/null +++ b/server/src/db/migrations/Migration20200101011200_step_072.ts @@ -0,0 +1,22 @@ +import { columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 72 (`db/migrations.ts`). + * + * The Immich-only column names become provider-neutral ones. A database that + * already went through step 67/68 is left alone. + */ +export class Migration20200101011200_step_072 extends Migration { + override name = 'Migration20200101011200_step_072'; + + override async up(): Promise { + const names = await columnNames(this, 'trip_photos'); + if (names.has('asset_id') && !names.has('immich_asset_id')) return; + + await this.execute('ALTER TABLE `trip_photos` RENAME COLUMN immich_asset_id TO asset_id'); + await this.execute('ALTER TABLE `trip_photos` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); + await this.execute('ALTER TABLE `trip_album_links` ADD COLUMN provider TEXT NOT NULL DEFAULT "immich"'); + await this.execute('ALTER TABLE `trip_album_links` RENAME COLUMN immich_album_id TO album_id'); + } +} diff --git a/server/src/db/migrations/Migration20200101011300_trip_photos_add_album_link_id.ts b/server/src/db/migrations/Migration20200101011300_trip_photos_add_album_link_id.ts new file mode 100644 index 000000000..5938e91e7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011300_trip_photos_add_album_link_id.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 73 (`db/migrations.ts`). + */ +export class Migration20200101011300_trip_photos_add_album_link_id extends Migration { + override name = 'Migration20200101011300_trip_photos_add_album_link_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_photos', + 'album_link_id', + `album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL DEFAULT NULL`, + ); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_album_link ON trip_photos(album_link_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101011400_todo_items.ts b/server/src/db/migrations/Migration20200101011400_todo_items.ts new file mode 100644 index 000000000..36c665e52 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011400_todo_items.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 74 (`db/migrations.ts`). + * + * Migration 68: Todo items + */ +export class Migration20200101011400_todo_items extends Migration { + override name = 'Migration20200101011400_todo_items'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS todo_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + name TEXT NOT NULL, + checked INTEGER DEFAULT 0, + category TEXT, + sort_order INTEGER DEFAULT 0, + due_date TEXT, + description TEXT, + assigned_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + priority INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_todo_items_trip_id ON todo_items(trip_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS todo_category_assignees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category_name TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(trip_id, category_name, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101011500_backfill_addons.ts b/server/src/db/migrations/Migration20200101011500_backfill_addons.ts new file mode 100644 index 000000000..62c80f828 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011500_backfill_addons.ts @@ -0,0 +1,13 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 75 (`db/migrations.ts`). + */ +export class Migration20200101011500_backfill_addons extends Migration { + override name = 'Migration20200101011500_backfill_addons'; + + override async up(): Promise { + await execBestEffort(this, `UPDATE addons SET enabled = 0 WHERE id = 'memories'`); + } +} diff --git a/server/src/db/migrations/Migration20200101011600_place_region_cache_for_sub_national_atlas.ts b/server/src/db/migrations/Migration20200101011600_place_region_cache_for_sub_national_atlas.ts new file mode 100644 index 000000000..be30d43fc --- /dev/null +++ b/server/src/db/migrations/Migration20200101011600_place_region_cache_for_sub_national_atlas.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 76 (`db/migrations.ts`). + * + * Migration 69: Place region cache for sub-national Atlas regions + */ +export class Migration20200101011600_place_region_cache_for_sub_national_atlas extends Migration { + override name = 'Migration20200101011600_place_region_cache_for_sub_national_atlas'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_regions ( + place_id INTEGER PRIMARY KEY REFERENCES places(id) ON DELETE CASCADE, + country_code TEXT NOT NULL, + region_code TEXT NOT NULL, + region_name TEXT NOT NULL + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_regions_country ON place_regions(country_code)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_regions_region ON place_regions(region_code)`); + } +} diff --git a/server/src/db/migrations/Migration20200101011700_create_visited_regions.ts b/server/src/db/migrations/Migration20200101011700_create_visited_regions.ts new file mode 100644 index 000000000..923328451 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011700_create_visited_regions.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 77 (`db/migrations.ts`). + */ +export class Migration20200101011700_create_visited_regions extends Migration { + override name = 'Migration20200101011700_create_visited_regions'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS visited_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + region_code TEXT NOT NULL, + region_name TEXT NOT NULL, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, region_code) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_visited_regions_country ON visited_regions(country_code)`); + } +} diff --git a/server/src/db/migrations/Migration20200101011800_normalized_per_user_per_channel_notification_preferences.ts b/server/src/db/migrations/Migration20200101011800_normalized_per_user_per_channel_notification_preferences.ts new file mode 100644 index 000000000..c91983703 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011800_normalized_per_user_per_channel_notification_preferences.ts @@ -0,0 +1,69 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** Old single-row-per-user columns → the event_type they become. */ +const EVENT_COLUMNS: Record = { + trip_invite: 'notify_trip_invite', + booking_change: 'notify_booking_change', + trip_reminder: 'notify_trip_reminder', + vacay_invite: 'notify_vacay_invite', + photos_shared: 'notify_photos_shared', + collab_message: 'notify_collab_message', + packing_tagged: 'notify_packing_tagged', +}; + +/** + * Legacy migration step 78 (`db/migrations.ts`). + * + * Migration 71: Normalized per-user per-channel notification preferences. + * + * Only the *disabled* combinations are written: a missing row means enabled, so + * copying the defaults across would bloat the table for no change in behaviour. + */ +export class Migration20200101011800_normalized_per_user_per_channel_notification_preferences extends Migration { + override name = 'Migration20200101011800_normalized_per_user_per_channel_notification_preferences'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS notification_channel_preferences ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + channel TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (user_id, event_type, channel) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id)`); + + // The old table does not exist on fresh installs. + const oldPrefs = (await tableExists(this, 'notification_preferences')) + ? ((await this.execute(`SELECT * FROM notification_preferences`)) as Record[]) + : []; + + for (const row of oldPrefs) { + const userId = row.user_id; + const webhookEnabled = row.notify_webhook ?? 0; + for (const [eventType, col] of Object.entries(EVENT_COLUMNS)) { + const emailEnabled = row[col] ?? 1; + if (!emailEnabled) { + await this.execute( + `INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)`, + [userId, eventType, 'email', 0], + ); + } + if (!webhookEnabled) { + await this.execute( + `INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)`, + [userId, eventType, 'webhook', 0], + ); + } + } + } + + // Copy the existing single-channel setting to the new plural key. + await this.execute(` + INSERT OR IGNORE INTO app_settings (key, value) + SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel' + `); + } +} diff --git a/server/src/db/migrations/Migration20200101011900_drop_the_old_notification_preferences_table.ts b/server/src/db/migrations/Migration20200101011900_drop_the_old_notification_preferences_table.ts new file mode 100644 index 000000000..7d09b1313 --- /dev/null +++ b/server/src/db/migrations/Migration20200101011900_drop_the_old_notification_preferences_table.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 79 (`db/migrations.ts`). + * + * Migration 72: Drop the old notification_preferences table (data migrated to notification_channel_preferences in migration 71) + */ +export class Migration20200101011900_drop_the_old_notification_preferences_table extends Migration { + override name = 'Migration20200101011900_drop_the_old_notification_preferences_table'; + + override up(): void { + this.addSql(`DROP TABLE IF EXISTS notification_preferences`); + } +} diff --git a/server/src/db/migrations/Migration20200101012000_add_reservation_id_to_budget_items_for.ts b/server/src/db/migrations/Migration20200101012000_add_reservation_id_to_budget_items_for.ts new file mode 100644 index 000000000..bf8b66bed --- /dev/null +++ b/server/src/db/migrations/Migration20200101012000_add_reservation_id_to_budget_items_for.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 80 (`db/migrations.ts`). + * + * Migration 73: Add reservation_id to budget_items for linking budget entries to reservations + */ +export class Migration20200101012000_add_reservation_id_to_budget_items_for extends Migration { + override name = 'Migration20200101012000_add_reservation_id_to_budget_items_for'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'budget_items', + 'reservation_id', + `reservation_id INTEGER REFERENCES reservations(id) ON DELETE SET NULL DEFAULT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101012100_add_quantity_to_packing_items_user_id.ts b/server/src/db/migrations/Migration20200101012100_add_quantity_to_packing_items_user_id.ts new file mode 100644 index 000000000..d7d4a962a --- /dev/null +++ b/server/src/db/migrations/Migration20200101012100_add_quantity_to_packing_items_user_id.ts @@ -0,0 +1,43 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 81 (`db/migrations.ts`). + * + * Migration 74: Add quantity to packing_items + user_id to packing_bags + + * bag_members table. The single `user_id` becomes the first row of the new + * membership table; the column itself stays for the steps that still read it. + */ +export class Migration20200101012100_add_quantity_to_packing_items_user_id extends Migration { + override name = 'Migration20200101012100_add_quantity_to_packing_items_user_id'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'quantity', `quantity INTEGER NOT NULL DEFAULT 1`); + await addColumnIfMissing( + this, + 'packing_bags', + 'user_id', + `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL DEFAULT NULL`, + ); + + await this.execute(` + CREATE TABLE IF NOT EXISTS packing_bag_members ( + bag_id INTEGER NOT NULL REFERENCES packing_bags(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (bag_id, user_id) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_packing_bag_members_bag ON packing_bag_members(bag_id)`); + + const bagsWithUser = (await this.execute(`SELECT id, user_id FROM packing_bags WHERE user_id IS NOT NULL`)) as { + id: number; + user_id: number; + }[]; + for (const bag of bagsWithUser) { + await this.execute(`INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)`, [ + bag.id, + bag.user_id, + ]); + } + } +} diff --git a/server/src/db/migrations/Migration20200101012200_per_day_positions_for_multi_day_reservations.ts b/server/src/db/migrations/Migration20200101012200_per_day_positions_for_multi_day_reservations.ts new file mode 100644 index 000000000..ccb69e92d --- /dev/null +++ b/server/src/db/migrations/Migration20200101012200_per_day_positions_for_multi_day_reservations.ts @@ -0,0 +1,55 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** A reservation that still carries the old single, trip-wide plan position. */ +interface PositionedReservation { + id: number; + trip_id: number; + reservation_time: string | null; + reservation_end_time: string | null; + day_plan_position: number; +} + +/** + * Legacy migration step 82 (`db/migrations.ts`). + * + * Per-day positions for multi-day reservations: one global position becomes one + * row per day the reservation spans, so a stay can sit in a different slot on + * each of its days. + */ +export class Migration20200101012200_per_day_positions_for_multi_day_reservations extends Migration { + override name = 'Migration20200101012200_per_day_positions_for_multi_day_reservations'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS reservation_day_positions ( + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + position REAL NOT NULL, + PRIMARY KEY (reservation_id, day_id) + ) + `); + + const reservations = (await this.execute( + `SELECT id, trip_id, reservation_time, reservation_end_time, day_plan_position FROM reservations WHERE day_plan_position IS NOT NULL`, + )) as PositionedReservation[]; + + for (const r of reservations) { + const startDate = r.reservation_time?.split('T')[0]; + if (!startDate) continue; + const endDate = r.reservation_end_time?.split('T')[0] || startDate; + + const matchingDays = (await this.execute(`SELECT id FROM days WHERE trip_id = ? AND date >= ? AND date <= ?`, [ + r.trip_id, + startDate, + endDate, + ])) as { id: number }[]; + + for (const day of matchingDays) { + await this.execute( + `INSERT OR IGNORE INTO reservation_day_positions (reservation_id, day_id, position) VALUES (?, ?, ?)`, + [r.id, day.id, r.day_plan_position], + ); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101012300_budget_category_ordering.ts b/server/src/db/migrations/Migration20200101012300_budget_category_ordering.ts new file mode 100644 index 000000000..04906bf53 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012300_budget_category_ordering.ts @@ -0,0 +1,39 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 83 (`db/migrations.ts`). + * + * Budget category ordering. Existing categories are seeded in alphabetical order + * per trip, which is the order they were displayed in before this table existed. + */ +export class Migration20200101012300_budget_category_ordering extends Migration { + override name = 'Migration20200101012300_budget_category_ordering'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS budget_category_order ( + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + category TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (trip_id, category) + ) + `); + + const rows = (await this.execute( + `SELECT DISTINCT trip_id, category FROM budget_items ORDER BY trip_id, category`, + )) as { trip_id: number; category: string }[]; + + let lastTripId = -1; + let index = 0; + for (const row of rows) { + if (row.trip_id !== lastTripId) { + lastTripId = row.trip_id; + index = 0; + } + await this.execute( + `INSERT OR IGNORE INTO budget_category_order (trip_id, category, sort_order) VALUES (?, ?, ?)`, + [row.trip_id, row.category, index++], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101012400_naver_list_import_addon.ts b/server/src/db/migrations/Migration20200101012400_naver_list_import_addon.ts new file mode 100644 index 000000000..3218f6d25 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012400_naver_list_import_addon.ts @@ -0,0 +1,19 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 84 (`db/migrations.ts`). + * + * Naver list import addon (default off). + */ +export class Migration20200101012400_naver_list_import_addon extends Migration { + override name = 'Migration20200101012400_naver_list_import_addon'; + + override async up(): Promise { + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['naver_list_import', 'Naver List Import', 'Import places from shared Naver Maps lists', 'trip', 'Link2', 0, 13], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101012500_oauth_2.ts b/server/src/db/migrations/Migration20200101012500_oauth_2.ts new file mode 100644 index 000000000..f60c135b0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012500_oauth_2.ts @@ -0,0 +1,61 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 85 (`db/migrations.ts`). + * + * Migration: OAuth 2.1 clients, consents, and tokens for MCP + */ +export class Migration20200101012500_oauth_2 extends Migration { + override name = 'Migration20200101012500_oauth_2'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS oauth_clients ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + client_id TEXT UNIQUE NOT NULL, + client_secret_hash TEXT NOT NULL, + redirect_uris TEXT NOT NULL DEFAULT '[]', + allowed_scopes TEXT NOT NULL DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS oauth_consents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scopes TEXT NOT NULL DEFAULT '[]', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(client_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS oauth_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL REFERENCES oauth_clients(client_id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + access_token_hash TEXT UNIQUE NOT NULL, + refresh_token_hash TEXT UNIQUE NOT NULL, + scopes TEXT NOT NULL DEFAULT '[]', + access_token_expires_at DATETIME NOT NULL, + refresh_token_expires_at DATETIME NOT NULL, + revoked_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_oauth_tokens_user ON oauth_tokens(user_id)`); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_access ON oauth_tokens(access_token_hash)`); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_tokens_refresh ON oauth_tokens(refresh_token_hash)`); + } +} diff --git a/server/src/db/migrations/Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay.ts b/server/src/db/migrations/Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay.ts new file mode 100644 index 000000000..788770710 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 86 (`db/migrations.ts`). + * + * Migration: Refresh-token rotation chain tracking for replay detection + */ +export class Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay extends Migration { + override name = 'Migration20200101012600_refresh_token_rotation_chain_tracking_for_replay'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'oauth_tokens', + 'parent_token_id', + `parent_token_id INTEGER REFERENCES oauth_tokens(id)`, + ); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_oauth_tokens_parent ON oauth_tokens(parent_token_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101012700_public_client_support_for_browser_initiated_dynamic.ts b/server/src/db/migrations/Migration20200101012700_public_client_support_for_browser_initiated_dynamic.ts new file mode 100644 index 000000000..e43e84862 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012700_public_client_support_for_browser_initiated_dynamic.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 87 (`db/migrations.ts`). + * + * Migration: Public client support for browser-initiated dynamic registration (DCR) + */ +export class Migration20200101012700_public_client_support_for_browser_initiated_dynamic extends Migration { + override name = 'Migration20200101012700_public_client_support_for_browser_initiated_dynamic'; + + override async up(): Promise { + await addColumnIfMissing(this, 'oauth_clients', 'is_public', `is_public INTEGER NOT NULL DEFAULT 0`); + + await addColumnIfMissing(this, 'oauth_clients', 'created_via', `created_via TEXT NOT NULL DEFAULT 'settings_ui'`); + } +} diff --git a/server/src/db/migrations/Migration20200101012800_make_oauth_clients.ts b/server/src/db/migrations/Migration20200101012800_make_oauth_clients.ts new file mode 100644 index 000000000..9a0d777e0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101012800_make_oauth_clients.ts @@ -0,0 +1,42 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 88 (`db/migrations.ts`). + * + * Make oauth_clients.user_id nullable to support anonymous RFC 7591 DCR clients. + * + * The legacy step ran outside a transaction so it could `PRAGMA foreign_keys = + * OFF` around the drop-and-rename — SQLite ignores that pragma inside an open + * transaction, and `oauth_tokens`/`oauth_consents` point at this table. Here the + * migration stays transactional and uses `defer_foreign_keys` instead, which is + * the in-transaction equivalent: enforcement is postponed to the commit, by + * which point the rename has made every reference valid again. + */ +export class Migration20200101012800_make_oauth_clients extends Migration { + override name = 'Migration20200101012800_make_oauth_clients'; + + override async up(): Promise { + await this.execute(`PRAGMA defer_foreign_keys = ON`); + await this.execute(` + CREATE TABLE IF NOT EXISTS oauth_clients_new ( + id TEXT PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + client_id TEXT UNIQUE NOT NULL, + client_secret_hash TEXT NOT NULL, + redirect_uris TEXT NOT NULL DEFAULT '[]', + allowed_scopes TEXT NOT NULL DEFAULT '[]', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_public INTEGER NOT NULL DEFAULT 0, + created_via TEXT NOT NULL DEFAULT 'settings_ui' + ) + `); + await this.execute( + `INSERT INTO oauth_clients_new SELECT id, user_id, name, client_id, client_secret_hash, redirect_uris, allowed_scopes, created_at, is_public, created_via FROM oauth_clients`, + ); + await this.execute(`DROP TABLE oauth_clients`); + await this.execute(`ALTER TABLE oauth_clients_new RENAME TO oauth_clients`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_oauth_clients_user ON oauth_clients(user_id)`); + await this.execute(`CREATE UNIQUE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101012900_add_otp_field_skip_ssl_column_device.ts b/server/src/db/migrations/Migration20200101012900_add_otp_field_skip_ssl_column_device.ts new file mode 100644 index 000000000..46ecd587e --- /dev/null +++ b/server/src/db/migrations/Migration20200101012900_add_otp_field_skip_ssl_column_device.ts @@ -0,0 +1,39 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 89 (`db/migrations.ts`). + * + * Add OTP field, skip_ssl column, device_id (did) column, and hint column for + * Synology Photos. + */ +export class Migration20200101012900_add_otp_field_skip_ssl_column_device extends Migration { + override name = 'Migration20200101012900_add_otp_field_skip_ssl_column_device'; + + override async up(): Promise { + await addColumnIfMissing(this, 'photo_provider_fields', 'hint', `hint TEXT`); + + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('synologyphotos', 'synology_otp', 'providerOTP', 'text', '123456', 0, 0, NULL, 'synology_otp', 3) + `); + + await addColumnIfMissing(this, 'users', 'synology_skip_ssl', `synology_skip_ssl INTEGER NOT NULL DEFAULT 0`); + await addColumnIfMissing(this, 'users', 'synology_did', `synology_did TEXT`); + + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('synologyphotos', 'synology_skip_ssl', 'skipSSLVerification', 'checkbox', NULL, 0, 0, 'synology_skip_ssl', 'synology_skip_ssl', 4) + `); + + await this.execute(` + UPDATE photo_provider_fields + SET hint = 'providerUrlHintSynology' + WHERE provider_id = 'synologyphotos' AND field_key = 'synology_url' + `); + } +} diff --git a/server/src/db/migrations/Migration20200101013000_journey_addon.ts b/server/src/db/migrations/Migration20200101013000_journey_addon.ts new file mode 100644 index 000000000..83cce59c3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013000_journey_addon.ts @@ -0,0 +1,121 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 90 (`db/migrations.ts`). + * + * Migration 84: Journey addon — trip tracking & travel journal. Registered + * disabled: the addon is opt-in. + */ +export class Migration20200101013000_journey_addon extends Migration { + override name = 'Migration20200101013000_journey_addon'; + + override up(): void { + this.addSql(` + INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) + VALUES ('journey', 'Journey', 'Trip tracking & travel journal — check-ins, photos, daily stories', 'global', 'Compass', 0, '{}', 35) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journeys ( + id TEXT PRIMARY KEY, + trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + description TEXT, + cover_image TEXT, + status TEXT NOT NULL DEFAULT 'draft', + started_at TEXT, + ended_at TEXT, + is_public INTEGER NOT NULL DEFAULT 0, + public_token TEXT UNIQUE, + settings TEXT DEFAULT '{}', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_checkins ( + id TEXT PRIMARY KEY, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + name TEXT NOT NULL, + lat REAL, + lng REAL, + address TEXT, + country_code TEXT, + notes TEXT, + checked_in_at TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'manual', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_entries ( + id TEXT PRIMARY KEY, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL, + entry_date TEXT NOT NULL, + title TEXT, + body TEXT, + mood TEXT, + weather TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_photos ( + id TEXT PRIMARY KEY, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + checkin_id TEXT REFERENCES journey_checkins(id) ON DELETE SET NULL, + entry_id TEXT REFERENCES journey_entries(id) ON DELETE SET NULL, + storage_type TEXT NOT NULL DEFAULT 'local', + asset_id TEXT, + file_path TEXT, + thumbnail_path TEXT, + original_name TEXT, + mime_type TEXT, + size_bytes INTEGER, + caption TEXT, + taken_at TEXT, + lat REAL, + lng REAL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_location_trail ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + lat REAL NOT NULL, + lng REAL NOT NULL, + altitude REAL, + accuracy REAL, + recorded_at TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'dawarich' + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journeys_user ON journeys(user_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journeys_trip ON journeys(trip_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journeys_public_token ON journeys(public_token)`); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_checkins_journey ON journey_checkins(journey_id, checked_in_at)`, + ); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_entries_journey_date ON journey_entries(journey_id, entry_date)`, + ); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_photos_checkin ON journey_photos(checkin_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)`); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_trail_journey_time ON journey_location_trail(journey_id, recorded_at)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101013100_journal.ts b/server/src/db/migrations/Migration20200101013100_journal.ts new file mode 100644 index 000000000..822827706 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013100_journal.ts @@ -0,0 +1,40 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 91 (`db/migrations.ts`). + * + * Migration 85: Journal — richer entry fields for magazine-style design + */ +export class Migration20200101013100_journal extends Migration { + override name = 'Migration20200101013100_journal'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'highlight_tags', `highlight_tags TEXT`); + + await addColumnIfMissing(this, 'journey_entries', 'visibility', `visibility TEXT NOT NULL DEFAULT 'private'`); + + await addColumnIfMissing(this, 'journey_entries', 'hero_photo_id', `hero_photo_id TEXT`); + + await addColumnIfMissing(this, 'journey_entries', 'color_accent', `color_accent TEXT`); + + await addColumnIfMissing(this, 'journey_entries', 'place_name', `place_name TEXT`); + + await addColumnIfMissing( + this, + 'journey_entries', + 'place_id', + `place_id INTEGER REFERENCES places(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing(this, 'journey_entries', 'lat', `lat REAL`); + + await addColumnIfMissing(this, 'journey_entries', 'lng', `lng REAL`); + + await addColumnIfMissing(this, 'journey_checkins', 'photo_id', `photo_id TEXT`); + + await addColumnIfMissing(this, 'journey_photos', 'width', `width INTEGER`); + + await addColumnIfMissing(this, 'journey_photos', 'height', `height INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101013200_journey_multi_trip_support_sharing_collaboration.ts b/server/src/db/migrations/Migration20200101013200_journey_multi_trip_support_sharing_collaboration.ts new file mode 100644 index 000000000..55e730673 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013200_journey_multi_trip_support_sharing_collaboration.ts @@ -0,0 +1,53 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 92 (`db/migrations.ts`). + * + * Migration 86: Journey multi-trip support + sharing/collaboration + */ +export class Migration20200101013200_journey_multi_trip_support_sharing_collaboration extends Migration { + override name = 'Migration20200101013200_journey_multi_trip_support_sharing_collaboration'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_trips ( + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + added_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + PRIMARY KEY (journey_id, trip_id) + ) + `); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_trips_journey ON journey_trips(journey_id)`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id TEXT NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'viewer', + invited_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')), + UNIQUE(journey_id, user_id) + ) + `); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_members_user ON journey_members(user_id)`); + + await addColumnIfMissing( + this, + 'journey_entries', + 'user_id', + `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + + await addColumnIfMissing( + this, + 'journey_checkins', + 'user_id', + `user_id INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101013300_journey_rebuild.ts b/server/src/db/migrations/Migration20200101013300_journey_rebuild.ts new file mode 100644 index 000000000..573349c07 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013300_journey_rebuild.ts @@ -0,0 +1,226 @@ +import { bestEffort, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** A row from whichever shape of the old journey tables this database had. */ +type LegacyRow = Record; + +const NEW_TABLES = [ + `CREATE TABLE journeys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + title TEXT NOT NULL, + subtitle TEXT, + cover_gradient TEXT, + status TEXT DEFAULT 'draft', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) + )`, + `CREATE TABLE journey_trips ( + journey_id INTEGER NOT NULL, + trip_id INTEGER NOT NULL, + added_at INTEGER NOT NULL, + PRIMARY KEY (journey_id, trip_id), + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (trip_id) REFERENCES trips(id) ON DELETE CASCADE + )`, + `CREATE TABLE journey_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL, + source_trip_id INTEGER, + source_place_id INTEGER, + author_id INTEGER NOT NULL, + type TEXT NOT NULL, + title TEXT, + story TEXT, + entry_date TEXT NOT NULL, + entry_time TEXT, + location_name TEXT, + location_lat REAL, + location_lng REAL, + mood TEXT, + weather TEXT, + tags TEXT, + visibility TEXT DEFAULT 'private', + sort_order INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (source_trip_id) REFERENCES trips(id) ON DELETE SET NULL, + FOREIGN KEY (source_place_id) REFERENCES places(id) ON DELETE SET NULL, + FOREIGN KEY (author_id) REFERENCES users(id) + )`, + `CREATE TABLE journey_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + thumbnail_path TEXT, + caption TEXT, + sort_order INTEGER DEFAULT 0, + width INTEGER, + height INTEGER, + created_at INTEGER NOT NULL, + FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE + )`, + `CREATE TABLE journey_contributors ( + journey_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + added_at INTEGER NOT NULL, + PRIMARY KEY (journey_id, user_id), + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) + )`, +]; + +const NEW_INDEXES = [ + `CREATE INDEX idx_journeys_user ON journeys(user_id)`, + `CREATE INDEX idx_journey_entries_journey ON journey_entries(journey_id, entry_date)`, + `CREATE INDEX idx_journey_entries_source ON journey_entries(source_place_id)`, + `CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id)`, + `CREATE INDEX idx_journey_trips_journey ON journey_trips(journey_id)`, + `CREATE INDEX idx_journey_contributors_user ON journey_contributors(user_id)`, +]; + +/** + * Legacy migration step 93 (`db/migrations.ts`). + * + * Migration 87: Journey rebuild — new schema with trip sync. + * + * Step 90's journeys were TEXT-keyed and single-trip; this replaces them with + * integer keys, a journey↔trip join table and contributors. The old rows are + * read into memory, the tables dropped, and the rows re-inserted against the new + * shape — so the old TEXT ids have to be mapped to the new autoincrement ones. + */ +export class Migration20200101013300_journey_rebuild extends Migration { + override name = 'Migration20200101013300_journey_rebuild'; + + /** SQLite's `lastInsertRowid`, which `execute()` does not surface for raw SQL. */ + private async lastInsertId(): Promise { + const rows = (await this.execute(`SELECT last_insert_rowid() AS id`)) as { id: number }[]; + return Number(rows[0].id); + } + + override async up(): Promise { + let oldJourneys: LegacyRow[] = []; + let oldEntries: LegacyRow[] = []; + let oldPhotos: LegacyRow[] = []; + + if (await tableExists(this, 'journeys')) { + await bestEffort(async () => { + oldJourneys = (await this.execute(`SELECT * FROM journeys`)) as LegacyRow[]; + }); + await bestEffort(async () => { + oldEntries = (await this.execute(`SELECT * FROM journey_entries`)) as LegacyRow[]; + }); + await bestEffort(async () => { + oldPhotos = (await this.execute(`SELECT * FROM journey_photos`)) as LegacyRow[]; + }); + + for (const table of [ + 'journey_location_trail', + 'journey_photos', + 'journey_entries', + 'journey_checkins', + 'journey_members', + 'journey_trips', + 'journeys', + ]) { + await this.execute(`DROP TABLE IF EXISTS ${table}`); + } + } + + for (const ddl of NEW_TABLES) await this.execute(ddl); + for (const ddl of NEW_INDEXES) await this.execute(ddl); + + if (oldJourneys.length === 0) return; + + const now = Date.now(); + const toMillis = (value: string | number | null | undefined): number => (value ? new Date(value).getTime() : now); + + const journeyIdMap = new Map(); + for (const j of oldJourneys) { + await this.execute( + `INSERT INTO journeys (user_id, title, subtitle, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, + [ + j.user_id, + j.title || 'Untitled Journey', + j.description || null, + j.status || 'draft', + toMillis(j.created_at), + toMillis(j.updated_at), + ], + ); + const newJourneyId = await this.lastInsertId(); + journeyIdMap.set(j.id!, newJourneyId); + + await this.execute( + `INSERT OR IGNORE INTO journey_contributors (journey_id, user_id, role, added_at) VALUES (?, ?, 'owner', ?)`, + [newJourneyId, j.user_id, now], + ); + + if (j.trip_id) { + await bestEffort(async () => { + await this.execute(`INSERT OR IGNORE INTO journey_trips (journey_id, trip_id, added_at) VALUES (?, ?, ?)`, [ + newJourneyId, + j.trip_id, + now, + ]); + }); + } + } + + const entryIdMap = new Map(); + for (const e of oldEntries) { + const newJourneyId = journeyIdMap.get(e.journey_id!); + if (!newJourneyId) continue; + + const authorId = e.user_id || oldJourneys.find((j) => j.id === e.journey_id)?.user_id || 1; + await this.execute( + `INSERT INTO journey_entries (journey_id, author_id, type, title, story, entry_date, entry_time, location_name, location_lat, location_lng, mood, weather, visibility, sort_order, created_at, updated_at) + VALUES (?, ?, 'entry', ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + newJourneyId, + authorId, + e.title || null, + e.body || null, + e.entry_date || new Date().toISOString().split('T')[0], + e.place_name || null, + e.lat || null, + e.lng || null, + e.mood || null, + e.weather || null, + e.visibility || 'private', + e.sort_order || 0, + toMillis(e.created_at), + toMillis(e.updated_at), + ], + ); + entryIdMap.set(e.id!, await this.lastInsertId()); + } + + for (const p of oldPhotos) { + const newEntryId = p.entry_id ? entryIdMap.get(p.entry_id) : null; + if (!newEntryId || !p.file_path) continue; + + await this.execute( + `INSERT INTO journey_photos (entry_id, file_path, thumbnail_path, caption, sort_order, width, height, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + newEntryId, + p.file_path, + p.thumbnail_path || null, + p.caption || null, + p.sort_order || 0, + p.width || null, + p.height || null, + toMillis(p.created_at), + ], + ); + } + + console.log( + `[DB] Journey migration: imported ${journeyIdMap.size} journeys, ${entryIdMap.size} entries, photos migrated`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101013400_journey_photos.ts b/server/src/db/migrations/Migration20200101013400_journey_photos.ts new file mode 100644 index 000000000..57eebafa7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013400_journey_photos.ts @@ -0,0 +1,54 @@ +import { bestEffort, columnNames, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 94 (`db/migrations.ts`). + * + * Migration 88: Journey photos — provider support (Immich/Synology). + * + * Every part of this step was swallowed on failure in the original, and it is + * kept that way: the rebuild below assumes the integer-keyed `journey_photos` + * shape, so on a database still carrying step 90's TEXT-keyed table the copy + * fails and the later journey rebuild (step 93) is what actually fixes it. + */ +export class Migration20200101013400_journey_photos extends Migration { + override name = 'Migration20200101013400_journey_photos'; + + override async up(): Promise { + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN provider TEXT NOT NULL DEFAULT 'local'`); + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN asset_id TEXT`); + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN owner_id INTEGER REFERENCES users(id)`); + await execBestEffort(this, `ALTER TABLE journey_photos ADD COLUMN shared INTEGER NOT NULL DEFAULT 1`); + + // file_path was NOT NULL — recreate the table to make it nullable. + const names = await columnNames(this, 'journey_photos'); + if (!names.has('provider')) return; + + await bestEffort(async () => { + await this.execute(` + CREATE TABLE journey_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + provider TEXT NOT NULL DEFAULT 'local', + asset_id TEXT, + owner_id INTEGER REFERENCES users(id), + file_path TEXT, + thumbnail_path TEXT, + caption TEXT, + sort_order INTEGER DEFAULT 0, + width INTEGER, + height INTEGER, + shared INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE + ) + `); + await this.execute( + `INSERT INTO journey_photos_new SELECT id, entry_id, provider, asset_id, owner_id, file_path, thumbnail_path, caption, sort_order, width, height, shared, created_at FROM journey_photos`, + ); + await this.execute(`DROP TABLE journey_photos`); + await this.execute(`ALTER TABLE journey_photos_new RENAME TO journey_photos`); + await this.execute(`CREATE INDEX idx_journey_photos_entry ON journey_photos(entry_id)`); + }); + } +} diff --git a/server/src/db/migrations/Migration20200101013500_journey_cover_image.ts b/server/src/db/migrations/Migration20200101013500_journey_cover_image.ts new file mode 100644 index 000000000..a389a57fc --- /dev/null +++ b/server/src/db/migrations/Migration20200101013500_journey_cover_image.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 95 (`db/migrations.ts`). + * + * Migration 89: Journey cover image + */ +export class Migration20200101013500_journey_cover_image extends Migration { + override name = 'Migration20200101013500_journey_cover_image'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journeys', 'cover_image', `cover_image TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101013600_pros_cons_for_journey_entries.ts b/server/src/db/migrations/Migration20200101013600_pros_cons_for_journey_entries.ts new file mode 100644 index 000000000..3e47376da --- /dev/null +++ b/server/src/db/migrations/Migration20200101013600_pros_cons_for_journey_entries.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 96 (`db/migrations.ts`). + * + * Migration 90: Pros/Cons for journey entries + */ +export class Migration20200101013600_pros_cons_for_journey_entries extends Migration { + override name = 'Migration20200101013600_pros_cons_for_journey_entries'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'pros_cons', `pros_cons TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101013700_journey_share_tokens.ts b/server/src/db/migrations/Migration20200101013700_journey_share_tokens.ts new file mode 100644 index 000000000..f17a08caf --- /dev/null +++ b/server/src/db/migrations/Migration20200101013700_journey_share_tokens.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 97 (`db/migrations.ts`). + * + * Migration 91: Journey share tokens + */ +export class Migration20200101013700_journey_share_tokens extends Migration { + override name = 'Migration20200101013700_journey_share_tokens'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_share_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL, + share_timeline INTEGER DEFAULT 1, + share_gallery INTEGER DEFAULT 1, + share_map INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (journey_id) REFERENCES journeys(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) + ) + `); + + this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS idx_journey_share_journey ON journey_share_tokens(journey_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101013800_vacay_week_start_setting.ts b/server/src/db/migrations/Migration20200101013800_vacay_week_start_setting.ts new file mode 100644 index 000000000..5a2f48ba4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013800_vacay_week_start_setting.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 98 (`db/migrations.ts`). + * + * Migration: Vacay week_start setting (0=Sunday, 1=Monday default) + */ +export class Migration20200101013800_vacay_week_start_setting extends Migration { + override name = 'Migration20200101013800_vacay_week_start_setting'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_plans', 'week_start', `week_start INTEGER NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101013900_unified_photo_provider_abstraction_layer.ts b/server/src/db/migrations/Migration20200101013900_unified_photo_provider_abstraction_layer.ts new file mode 100644 index 000000000..01aa9f374 --- /dev/null +++ b/server/src/db/migrations/Migration20200101013900_unified_photo_provider_abstraction_layer.ts @@ -0,0 +1,136 @@ +import { columnNames, tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +const TRIP_PHOTOS_NEW = ` + CREATE TABLE trip_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + shared INTEGER NOT NULL DEFAULT 1, + album_link_id INTEGER REFERENCES trip_album_links(id) ON DELETE SET NULL, + added_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, user_id, photo_id) + ) +`; + +/** + * Legacy migration step 99 (`db/migrations.ts`). + * + * Unified Photo Provider Abstraction Layer (#584). A central `trek_photos` + * registry; `trip_photos` and `journey_photos` stop carrying provider details + * and reference it by `photo_id` instead. + * + * `trip_photos` arrived in several shapes over the releases, so the copy reads + * whichever columns the database actually has. + */ +export class Migration20200101013900_unified_photo_provider_abstraction_layer extends Migration { + override name = 'Migration20200101013900_unified_photo_provider_abstraction_layer'; + + override async up(): Promise { + // 1. The central photo registry. + await this.execute(` + CREATE TABLE IF NOT EXISTS trek_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + asset_id TEXT, + owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + file_path TEXT, + thumbnail_path TEXT, + width INTEGER, + height INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_trek_photos_provider_asset ON trek_photos(provider, asset_id, owner_id) WHERE asset_id IS NOT NULL`, + ); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trek_photos_owner ON trek_photos(owner_id)`); + + // 2. trip_photos → trek_photos + photo_id FK. + if (await tableExists(this, 'trip_photos')) { + const names = await columnNames(this, 'trip_photos'); + const hasProvider = names.has('provider'); + const assetCol = names.has('asset_id') ? 'asset_id' : names.has('immich_asset_id') ? 'immich_asset_id' : null; + + if (assetCol) { + const providerExpr = hasProvider ? 'provider' : `'immich'`; + // Qualified alias needed in the JOIN, where both tables have `provider`. + const providerJoinExpr = hasProvider ? 'tp.provider' : `'immich'`; + const sharedExpr = names.has('shared') ? 'shared' : '1'; + const addedAtExpr = names.has('added_at') ? 'COALESCE(added_at, CURRENT_TIMESTAMP)' : 'CURRENT_TIMESTAMP'; + const albumLinkExpr = names.has('album_link_id') ? 'album_link_id' : 'NULL'; + + await this.execute(` + INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, created_at) + SELECT DISTINCT ${providerExpr}, ${assetCol}, user_id, ${addedAtExpr} + FROM trip_photos + WHERE ${assetCol} IS NOT NULL AND TRIM(${assetCol}) != '' + `); + await this.execute(TRIP_PHOTOS_NEW); + await this.execute(` + INSERT OR IGNORE INTO trip_photos_new (trip_id, user_id, photo_id, shared, album_link_id, added_at) + SELECT tp.trip_id, tp.user_id, tkp.id, ${sharedExpr}, ${albumLinkExpr}, ${addedAtExpr} + FROM trip_photos tp + JOIN trek_photos tkp ON tkp.provider = ${providerJoinExpr} AND tkp.asset_id = tp.${assetCol} AND tkp.owner_id = tp.user_id + `); + } else { + // No asset column at all — recreate empty. + await this.execute(TRIP_PHOTOS_NEW); + } + + await this.execute(`DROP TABLE trip_photos`); + await this.execute(`ALTER TABLE trip_photos_new RENAME TO trip_photos`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_trip ON trip_photos(trip_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_photos_photo ON trip_photos(photo_id)`); + } + + // 3. journey_photos → trek_photos + photo_id FK. + if (await tableExists(this, 'journey_photos')) { + await this.execute(` + INSERT OR IGNORE INTO trek_photos (provider, asset_id, owner_id, width, height, created_at) + SELECT DISTINCT provider, asset_id, owner_id, width, height, created_at + FROM journey_photos + WHERE provider != 'local' AND asset_id IS NOT NULL AND TRIM(asset_id) != '' + `); + // Local photos are unique per row, so no de-duplication. + await this.execute(` + INSERT INTO trek_photos (provider, file_path, thumbnail_path, width, height, created_at) + SELECT 'local', file_path, thumbnail_path, width, height, created_at + FROM journey_photos + WHERE provider = 'local' AND file_path IS NOT NULL + `); + + await this.execute(` + CREATE TABLE journey_photos_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_id INTEGER NOT NULL, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + caption TEXT, + sort_order INTEGER DEFAULT 0, + shared INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + FOREIGN KEY (entry_id) REFERENCES journey_entries(id) ON DELETE CASCADE + ) + `); + await this.execute(` + INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) + SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at + FROM journey_photos jp + JOIN trek_photos tkp ON tkp.provider = jp.provider AND tkp.asset_id = jp.asset_id AND tkp.owner_id = jp.owner_id + WHERE jp.provider != 'local' AND jp.asset_id IS NOT NULL + `); + await this.execute(` + INSERT INTO journey_photos_new (entry_id, photo_id, caption, sort_order, shared, created_at) + SELECT jp.entry_id, tkp.id, jp.caption, jp.sort_order, jp.shared, jp.created_at + FROM journey_photos jp + JOIN trek_photos tkp ON tkp.provider = 'local' AND tkp.file_path = jp.file_path + WHERE jp.provider = 'local' AND jp.file_path IS NOT NULL + `); + await this.execute(`DROP TABLE journey_photos`); + await this.execute(`ALTER TABLE journey_photos_new RENAME TO journey_photos`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_photos_entry ON journey_photos(entry_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_photos_photo ON journey_photos(photo_id)`); + } + } +} diff --git a/server/src/db/migrations/Migration20200101014000_hide_skeletons_per_user_setting_on_journey.ts b/server/src/db/migrations/Migration20200101014000_hide_skeletons_per_user_setting_on_journey.ts new file mode 100644 index 000000000..17295bddb --- /dev/null +++ b/server/src/db/migrations/Migration20200101014000_hide_skeletons_per_user_setting_on_journey.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 100 (`db/migrations.ts`). + * + * Migration 99: hide_skeletons per-user setting on journey_contributors + */ +export class Migration20200101014000_hide_skeletons_per_user_setting_on_journey extends Migration { + override name = 'Migration20200101014000_hide_skeletons_per_user_setting_on_journey'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'journey_contributors', + 'hide_skeletons', + `hide_skeletons INTEGER NOT NULL DEFAULT 0`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101014100_idempotency_keys_for_offline_mutation_replay.ts b/server/src/db/migrations/Migration20200101014100_idempotency_keys_for_offline_mutation_replay.ts new file mode 100644 index 000000000..9a9d5d149 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014100_idempotency_keys_for_offline_mutation_replay.ts @@ -0,0 +1,27 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 101 (`db/migrations.ts`). + * + * Migration 100: Idempotency keys for offline mutation replay + */ +export class Migration20200101014100_idempotency_keys_for_offline_mutation_replay extends Migration { + override name = 'Migration20200101014100_idempotency_keys_for_offline_mutation_replay'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS idempotency_keys ( + key TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (key, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101014200_enable_naver_list_import_by_default.ts b/server/src/db/migrations/Migration20200101014200_enable_naver_list_import_by_default.ts new file mode 100644 index 000000000..16623322a --- /dev/null +++ b/server/src/db/migrations/Migration20200101014200_enable_naver_list_import_by_default.ts @@ -0,0 +1,14 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 102 (`db/migrations.ts`). + * + * Migration 101: Enable naver_list_import by default. + */ +export class Migration20200101014200_enable_naver_list_import_by_default extends Migration { + override name = 'Migration20200101014200_enable_naver_list_import_by_default'; + + override up(): void { + this.addSql(`UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'`); + } +} diff --git a/server/src/db/migrations/Migration20200101014300_add_check_in_end_column_for_check.ts b/server/src/db/migrations/Migration20200101014300_add_check_in_end_column_for_check.ts new file mode 100644 index 000000000..394f7cb0f --- /dev/null +++ b/server/src/db/migrations/Migration20200101014300_add_check_in_end_column_for_check.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 103 (`db/migrations.ts`). + * + * Migration 102: Add check_in_end column for check-in time ranges + */ +export class Migration20200101014300_add_check_in_end_column_for_check extends Migration { + override name = 'Migration20200101014300_add_check_in_end_column_for_check'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_accommodations', 'check_in_end', `check_in_end TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101014400_system_notices.ts b/server/src/db/migrations/Migration20200101014400_system_notices.ts new file mode 100644 index 000000000..d32be4e77 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014400_system_notices.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 104 (`db/migrations.ts`). + * + * Migration 103: System notices — user tracking columns + dismissals table + */ +export class Migration20200101014400_system_notices extends Migration { + override name = 'Migration20200101014400_system_notices'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'first_seen_version', `first_seen_version TEXT NOT NULL DEFAULT '0.0.0'`); + + await addColumnIfMissing(this, 'users', 'login_count', `login_count INTEGER NOT NULL DEFAULT 0`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS user_notice_dismissals ( + user_id INTEGER NOT NULL, + notice_id TEXT NOT NULL, + dismissed_at INTEGER NOT NULL, + PRIMARY KEY (user_id, notice_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014500_passphrase_support_for_synology_shared_album_links.ts b/server/src/db/migrations/Migration20200101014500_passphrase_support_for_synology_shared_album_links.ts new file mode 100644 index 000000000..709326910 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014500_passphrase_support_for_synology_shared_album_links.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 105 (`db/migrations.ts`). + * + * Migration 104: Passphrase support for Synology shared-album links (#689) + */ +export class Migration20200101014500_passphrase_support_for_synology_shared_album_links extends Migration { + override name = 'Migration20200101014500_passphrase_support_for_synology_shared_album_links'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trip_album_links', 'passphrase', `passphrase TEXT DEFAULT NULL`); + + await addColumnIfMissing(this, 'trek_photos', 'passphrase', `passphrase TEXT DEFAULT NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101014600_persistent_google_place_photo_disk_cache_registry.ts b/server/src/db/migrations/Migration20200101014600_persistent_google_place_photo_disk_cache_registry.ts new file mode 100644 index 000000000..66893d672 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014600_persistent_google_place_photo_disk_cache_registry.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 106 (`db/migrations.ts`). + * + * Migration 105: Persistent Google place photo disk cache registry + */ +export class Migration20200101014600_persistent_google_place_photo_disk_cache_registry extends Migration { + override name = 'Migration20200101014600_persistent_google_place_photo_disk_cache_registry'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS google_place_photo_meta ( + place_id TEXT PRIMARY KEY, + attribution TEXT, + fetched_at INTEGER NOT NULL, + error_at INTEGER + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014700_persistent_place_details_row_cache.ts b/server/src/db/migrations/Migration20200101014700_persistent_place_details_row_cache.ts new file mode 100644 index 000000000..dfddb1d65 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014700_persistent_place_details_row_cache.ts @@ -0,0 +1,23 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 107 (`db/migrations.ts`). + * + * Migration 106: Persistent Place Details row cache + */ +export class Migration20200101014700_persistent_place_details_row_cache extends Migration { + override name = 'Migration20200101014700_persistent_place_details_row_cache'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_details_cache ( + place_id TEXT NOT NULL, + lang TEXT NOT NULL DEFAULT '', + expanded INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + PRIMARY KEY (place_id, lang, expanded) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014800_backfill_expired_signed_google_photo_urls_to.ts b/server/src/db/migrations/Migration20200101014800_backfill_expired_signed_google_photo_urls_to.ts new file mode 100644 index 000000000..53254a409 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014800_backfill_expired_signed_google_photo_urls_to.ts @@ -0,0 +1,28 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 108 (`db/migrations.ts`). + * + * Migration 107: Backfill expired signed Google photo URLs to stable proxy URLs. + * + * The legacy step was declared `raw` (run outside a transaction); it is a single + * UPDATE with no pragma requirement, so it runs transactionally here. + */ +export class Migration20200101014800_backfill_expired_signed_google_photo_urls_to extends Migration { + override name = 'Migration20200101014800_backfill_expired_signed_google_photo_urls_to'; + + override up(): void { + this.addSql(` + UPDATE places + SET image_url = '/api/maps/place-photo/' || google_place_id || '/bytes', + updated_at = CURRENT_TIMESTAMP + WHERE google_place_id IS NOT NULL + AND image_url IS NOT NULL + AND image_url != '' + AND ( + (image_url LIKE '%googleusercontent.com%' AND image_url LIKE '%/places/%/photos/%') + OR (image_url LIKE '%places.googleapis.com%' AND image_url LIKE '%/places/%/photos/%') + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101014900_disk_cache_metadata_for_remote_provider_photo.ts b/server/src/db/migrations/Migration20200101014900_disk_cache_metadata_for_remote_provider_photo.ts new file mode 100644 index 000000000..2bc5e7bf1 --- /dev/null +++ b/server/src/db/migrations/Migration20200101014900_disk_cache_metadata_for_remote_provider_photo.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 109 (`db/migrations.ts`). + * + * Migration 108: Disk cache metadata for remote-provider photo thumbnails (Immich / Synology) + */ +export class Migration20200101014900_disk_cache_metadata_for_remote_provider_photo extends Migration { + override name = 'Migration20200101014900_disk_cache_metadata_for_remote_provider_photo'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS trek_photo_cache_meta ( + cache_key TEXT PRIMARY KEY, + content_type TEXT NOT NULL DEFAULT 'image/jpeg', + fetched_at INTEGER NOT NULL + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_trek_photo_cache_meta_fetched_at ON trek_photo_cache_meta (fetched_at)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101015000_reservation_endpoints.ts b/server/src/db/migrations/Migration20200101015000_reservation_endpoints.ts new file mode 100644 index 000000000..7c7a63985 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015000_reservation_endpoints.ts @@ -0,0 +1,36 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 110 (`db/migrations.ts`). + * + * Migration 109: Reservation endpoints (from/to points for flights, trains, ferries, car rentals) — #384 + #587 + */ +export class Migration20200101015000_reservation_endpoints extends Migration { + override name = 'Migration20200101015000_reservation_endpoints'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS reservation_endpoints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + role TEXT NOT NULL, + sequence INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL, + code TEXT, + lat REAL NOT NULL, + lng REAL NOT NULL, + timezone TEXT, + local_time TEXT, + local_date TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_reservation_endpoints_reservation_id ON reservation_endpoints(reservation_id)`, + ); + + await addColumnIfMissing(this, 'reservations', 'needs_review', `needs_review INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101015100_reservations_add_end_day_id.ts b/server/src/db/migrations/Migration20200101015100_reservations_add_end_day_id.ts new file mode 100644 index 000000000..55a2ddd64 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015100_reservations_add_end_day_id.ts @@ -0,0 +1,47 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 111 (`db/migrations.ts`). + * + * Migration 110 — link transport reservations to days via day_id / end_day_id + */ +export class Migration20200101015100_reservations_add_end_day_id extends Migration { + override name = 'Migration20200101015100_reservations_add_end_day_id'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'reservations', + 'end_day_id', + `end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL`, + ); + + await this.execute(` + UPDATE reservations + SET day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_time, 1, 10) + LIMIT 1 + ) + WHERE type IN ('flight','train','car','cruise','bus') + AND reservation_time IS NOT NULL + AND day_id IS NULL + `); + + await this.execute(` + UPDATE reservations + SET end_day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + LIMIT 1 + ) + WHERE type IN ('flight','train','car','cruise','bus') + AND reservation_end_time IS NOT NULL + AND end_day_id IS NULL + AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101015200_opt_in_immich_auto_upload.ts b/server/src/db/migrations/Migration20200101015200_opt_in_immich_auto_upload.ts new file mode 100644 index 000000000..025c93539 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015200_opt_in_immich_auto_upload.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 112 (`db/migrations.ts`). + * + * Migration 111: opt-in Immich auto-upload — users column only (#730) + * Default is off — uploading to Immich must be an explicit choice, not a + * side effect of having a writable API key. + */ +export class Migration20200101015200_opt_in_immich_auto_upload extends Migration { + override name = 'Migration20200101015200_opt_in_immich_auto_upload'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'immich_auto_upload', `immich_auto_upload INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101015300_expose_immich_auto_upload_toggle_in_the.ts b/server/src/db/migrations/Migration20200101015300_expose_immich_auto_upload_toggle_in_the.ts new file mode 100644 index 000000000..612eb0e37 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015300_expose_immich_auto_upload_toggle_in_the.ts @@ -0,0 +1,27 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 113 (`db/migrations.ts`). + * + * Migration 112: expose the immich auto-upload toggle in the Settings UI (#730). + * Runs after Immich provider seeding so the FK to photo_providers holds. + */ +export class Migration20200101015300_expose_immich_auto_upload_toggle_in_the extends Migration { + override name = 'Migration20200101015300_expose_immich_auto_upload_toggle_in_the'; + + override async up(): Promise { + if (!(await tableExists(this, 'photo_providers'))) return; + if (!(await tableExists(this, 'photo_provider_fields'))) return; + + const immich = await this.execute(`SELECT 1 AS present FROM photo_providers WHERE id = 'immich' LIMIT 1`); + if (immich.length === 0) return; + + await this.execute(` + INSERT OR IGNORE INTO photo_provider_fields + (provider_id, field_key, label, input_type, placeholder, required, secret, settings_key, payload_key, sort_order) + VALUES + ('immich', 'immich_auto_upload', 'immichAutoUpload', 'checkbox', NULL, 0, 0, 'auto_upload', 'auto_upload', 5) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101015400_rfc_8707_resource_indicators.ts b/server/src/db/migrations/Migration20200101015400_rfc_8707_resource_indicators.ts new file mode 100644 index 000000000..3ef141c09 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015400_rfc_8707_resource_indicators.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 114 (`db/migrations.ts`). + * + * Migration: RFC 8707 resource indicators — audience-bind OAuth tokens to /mcp + */ +export class Migration20200101015400_rfc_8707_resource_indicators extends Migration { + override name = 'Migration20200101015400_rfc_8707_resource_indicators'; + + override async up(): Promise { + await addColumnIfMissing(this, 'oauth_tokens', 'audience', `audience TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101015500_password_reset.ts b/server/src/db/migrations/Migration20200101015500_password_reset.ts new file mode 100644 index 000000000..8f60a53ef --- /dev/null +++ b/server/src/db/migrations/Migration20200101015500_password_reset.ts @@ -0,0 +1,33 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 115 (`db/migrations.ts`). + * + * Migration: password reset — add password_version for session + * invalidation, and a token table keyed by SHA-256 hash (raw tokens + * never hit the DB). + */ +export class Migration20200101015500_password_reset extends Migration { + override name = 'Migration20200101015500_password_reset'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'password_version', `password_version INTEGER NOT NULL DEFAULT 0`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + consumed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_ip TEXT + ) + `); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_prt_user ON password_reset_tokens(user_id)`); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_prt_hash ON password_reset_tokens(token_hash)`); + } +} diff --git a/server/src/db/migrations/Migration20200101015600_todo_due_date_reminders.ts b/server/src/db/migrations/Migration20200101015600_todo_due_date_reminders.ts new file mode 100644 index 000000000..c4b48771d --- /dev/null +++ b/server/src/db/migrations/Migration20200101015600_todo_due_date_reminders.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 116 (`db/migrations.ts`). + * + * Migration: todo due-date reminders — track when we last sent a + * reminder for each todo so we don't spam the same notification + * every day the scheduler runs. + */ +export class Migration20200101015600_todo_due_date_reminders extends Migration { + override name = 'Migration20200101015600_todo_due_date_reminders'; + + override async up(): Promise { + await addColumnIfMissing(this, 'todo_items', 'reminded_at', `reminded_at DATETIME`); + } +} diff --git a/server/src/db/migrations/Migration20200101015700_security_audit_batch_1.ts b/server/src/db/migrations/Migration20200101015700_security_audit_batch_1.ts new file mode 100644 index 000000000..48a30cc7c --- /dev/null +++ b/server/src/db/migrations/Migration20200101015700_security_audit_batch_1.ts @@ -0,0 +1,56 @@ +import { addColumnIfMissing, bestEffort, columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 117 (`db/migrations.ts`). + * + * Security audit batch 1 — columns + indexes required by several fixes bundled + * into one PR. + * + * - `share_tokens.expires_at`: public share links now get a 90-day TTL by + * default; existing rows stay NULL (= no expiry) so already-published links + * are not silently broken. + * - Missing indexes on high-cardinality query paths (PERF-H1 in the audit): + * every `listTrips()` used to full-scan trips on user_id, and + * notifications/photos/reservations had similar gaps. + */ +export class Migration20200101015700_security_audit_batch_1 extends Migration { + override name = 'Migration20200101015700_security_audit_batch_1'; + + override async up(): Promise { + await addColumnIfMissing(this, 'share_tokens', 'expires_at', `expires_at TEXT`); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trips_created_at ON trips(created_at DESC)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_photos_day_id ON photos(day_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_photos_place_id ON photos(place_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_reservations_day_id ON reservations(day_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_share_tokens_token ON share_tokens(token)`); + + // day_accommodations may have either start_day_id/end_day_id or a single + // day_id depending on how far the schema has evolved; build whichever index + // makes sense for the live columns. Non-fatal: the table may not exist on + // very old installs. + await bestEffort(async () => { + const names = await columnNames(this, 'day_accommodations'); + if (names.has('start_day_id')) { + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)`, + ); + } + if (names.has('end_day_id')) { + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)`, + ); + } + }); + + // The notifications schema has varied; probe before indexing. + await bestEffort(async () => { + const names = await columnNames(this, 'notifications'); + if (names.has('target') && names.has('scope')) { + await this.execute(`CREATE INDEX IF NOT EXISTS idx_notifications_target_scope ON notifications(target, scope)`); + } + }); + } +} diff --git a/server/src/db/migrations/Migration20200101015800_widen_idempotency_keys_primary_key_to.ts b/server/src/db/migrations/Migration20200101015800_widen_idempotency_keys_primary_key_to.ts new file mode 100644 index 000000000..bc549649e --- /dev/null +++ b/server/src/db/migrations/Migration20200101015800_widen_idempotency_keys_primary_key_to.ts @@ -0,0 +1,42 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 118 (`db/migrations.ts`). + * + * Widen the idempotency_keys primary key to (key, user_id, method, path). The + * middleware lookup was widened in the same audit batch so a reused + * `X-Idempotency-Key` against a different endpoint does not replay the cached + * body of an unrelated request. The old PK was only (key, user_id), so the + * `INSERT OR IGNORE` on the second endpoint silently skipped — the cache never + * stored request B's response and replays re-executed the handler. Rebuilding + * with the widened PK preserves existing rows: the old PK guarantees there are + * no conflicts under the new, strictly looser unique key. + */ +export class Migration20200101015800_widen_idempotency_keys_primary_key_to extends Migration { + override name = 'Migration20200101015800_widen_idempotency_keys_primary_key_to'; + + override async up(): Promise { + if (!(await tableExists(this, 'idempotency_keys'))) return; + + await this.execute(` + CREATE TABLE idempotency_keys_new ( + key TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + method TEXT NOT NULL, + path TEXT NOT NULL, + status_code INTEGER NOT NULL, + response_body TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + PRIMARY KEY (key, user_id, method, path) + ) + `); + await this.execute(` + INSERT INTO idempotency_keys_new (key, user_id, method, path, status_code, response_body, created_at) + SELECT key, user_id, method, path, status_code, response_body, created_at FROM idempotency_keys + `); + await this.execute(`DROP TABLE idempotency_keys`); + await this.execute(`ALTER TABLE idempotency_keys_new RENAME TO idempotency_keys`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued.ts b/server/src/db/migrations/Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued.ts new file mode 100644 index 000000000..a4c686280 --- /dev/null +++ b/server/src/db/migrations/Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued.ts @@ -0,0 +1,20 @@ +import { columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 119 (`db/migrations.ts`). + * + * SEC-H6: revoke all OAuth tokens issued before audience binding was enforced. + * `mcp/index.ts` now unconditionally checks audience; tokens with + * `audience = NULL` would be permanently rejected by that check, so removing + * them here avoids leaving dead rows and makes the intent clear. + */ +export class Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued extends Migration { + override name = 'Migration20200101015900_sec_h6_revoke_all_oauth_tokens_issued'; + + override async up(): Promise { + const names = await columnNames(this, 'oauth_tokens'); + if (!names.has('audience')) return; + await this.execute(`DELETE FROM oauth_tokens WHERE audience IS NULL`); + } +} diff --git a/server/src/db/migrations/Migration20200101020000_remove_not_null_constraint_on_day_accommodations.ts b/server/src/db/migrations/Migration20200101020000_remove_not_null_constraint_on_day_accommodations.ts new file mode 100644 index 000000000..8a5d5b596 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020000_remove_not_null_constraint_on_day_accommodations.ts @@ -0,0 +1,48 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 120 (`db/migrations.ts`). + * + * Remove NOT NULL constraint on day_accommodations.place_id so hotel + * reservations created from the Bookings tab without a linked place can + * still persist their date range. Change ON DELETE CASCADE → SET NULL so + * deleting a place orphans the accommodation row instead of cascading. + */ +export class Migration20200101020000_remove_not_null_constraint_on_day_accommodations extends Migration { + override name = 'Migration20200101020000_remove_not_null_constraint_on_day_accommodations'; + + override up(): void { + this.addSql(` + CREATE TABLE day_accommodations_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + check_in TEXT, + check_in_end TEXT, + check_out TEXT, + confirmation TEXT, + notes TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + INSERT INTO day_accommodations_new + SELECT id, trip_id, place_id, start_day_id, end_day_id, + check_in, check_in_end, check_out, confirmation, notes, created_at + FROM day_accommodations + `); + + this.addSql(`DROP TABLE day_accommodations`); + + this.addSql(`ALTER TABLE day_accommodations_new RENAME TO day_accommodations`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_trip_id ON day_accommodations(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_start_day_id ON day_accommodations(start_day_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_accommodations_end_day_id ON day_accommodations(end_day_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101020100_null_out_proxy_image_url_entries_that.ts b/server/src/db/migrations/Migration20200101020100_null_out_proxy_image_url_entries_that.ts new file mode 100644 index 000000000..add7bd2cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101020100_null_out_proxy_image_url_entries_that.ts @@ -0,0 +1,30 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 121 (`db/migrations.ts`). + * + * Migration: null out proxy image_url entries that have no backing disk cache. + * Migrations 107 and the migration below wrote /api/maps/place-photo//bytes + * into places.image_url without actually fetching/caching the photo bytes. The + * photoService short-circuits on that prefix and hits /bytes directly → 404. + * Rows with a confirmed disk cache entry in google_place_photo_meta are left alone; + * only stale proxy URLs (never actually fetched) are cleared so the normal + * fetch-and-cache flow can repopulate them. + */ +export class Migration20200101020100_null_out_proxy_image_url_entries_that extends Migration { + override name = 'Migration20200101020100_null_out_proxy_image_url_entries_that'; + + override up(): void { + this.addSql(` + UPDATE places + SET image_url = NULL, updated_at = CURRENT_TIMESTAMP + WHERE image_url LIKE '/api/maps/place-photo/%/bytes' + AND google_place_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM google_place_photo_meta + WHERE place_id = places.google_place_id + AND error_at IS NULL + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020200_clear_legacy_google_photo_urls_missed_by.ts b/server/src/db/migrations/Migration20200101020200_clear_legacy_google_photo_urls_missed_by.ts new file mode 100644 index 000000000..b9a8863bd --- /dev/null +++ b/server/src/db/migrations/Migration20200101020200_clear_legacy_google_photo_urls_missed_by.ts @@ -0,0 +1,30 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 122 (`db/migrations.ts`). + * + * Migration: clear legacy Google photo URLs missed by Migration 107. + * Migration 107 matched /places/%/photos/% only; lh3.googleusercontent.com URLs use + * /place-photos/ or /places/ paths and were skipped. NULL those stale URLs + * so the normal fetch-and-cache flow repopulates image_url with a real proxy URL. + */ +export class Migration20200101020200_clear_legacy_google_photo_urls_missed_by extends Migration { + override name = 'Migration20200101020200_clear_legacy_google_photo_urls_missed_by'; + + override up(): void { + this.addSql(` + UPDATE places + SET image_url = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE image_url IS NOT NULL + AND image_url != '' + AND image_url NOT LIKE '/api/maps/place-photo/%' + AND ( + image_url LIKE 'http://%googleusercontent.com/%' + OR image_url LIKE 'https://%googleusercontent.com/%' + OR image_url LIKE 'http://%places.googleapis.com/%' + OR image_url LIKE 'https://%places.googleapis.com/%' + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020300_journey_gallery_refactor.ts b/server/src/db/migrations/Migration20200101020300_journey_gallery_refactor.ts new file mode 100644 index 000000000..cae9d29ea --- /dev/null +++ b/server/src/db/migrations/Migration20200101020300_journey_gallery_refactor.ts @@ -0,0 +1,104 @@ +import { tableExists } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 123 (`db/migrations.ts`). + * + * Migration 121: Journey gallery refactor — decouple photo ownership from + * entries. `journey_photos` becomes a per-journey gallery (one row per unique + * photo per journey). A new junction table `journey_entry_photos` links gallery + * photos to the entries that reference them, so the same photo can appear in + * several entries without being duplicated. Synthetic wrapper entries + * ('Gallery', '[Trip Photos]') created by the old model are removed — the + * gallery table replaces them. + */ +export class Migration20200101020300_journey_gallery_refactor extends Migration { + override name = 'Migration20200101020300_journey_gallery_refactor'; + + override async up(): Promise { + const hasOld = await tableExists(this, 'journey_photos'); + const hasBackup = await tableExists(this, 'journey_photos_old'); + if (hasOld && !hasBackup) { + await this.execute(`ALTER TABLE journey_photos RENAME TO journey_photos_old`); + } + + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_photos ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + photo_id INTEGER NOT NULL REFERENCES trek_photos(id) ON DELETE CASCADE, + caption TEXT, + shared INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + provider TEXT, + asset_id TEXT, + owner_id INTEGER, + created_at INTEGER NOT NULL, + UNIQUE(journey_id, photo_id) + ) + `); + + await this.execute(` + CREATE TABLE IF NOT EXISTS journey_entry_photos ( + entry_id INTEGER NOT NULL REFERENCES journey_entries(id) ON DELETE CASCADE, + journey_photo_id INTEGER NOT NULL REFERENCES journey_photos(id) ON DELETE CASCADE, + sort_order INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + PRIMARY KEY(entry_id, journey_photo_id) + ) + `); + + if (hasOld || hasBackup) { + // Backfill the gallery: deduplicate by (journey_id, photo_id), keeping the + // earliest row (MIN(id) = earliest created_at on AUTOINCREMENT). + await this.execute(` + INSERT OR IGNORE INTO journey_photos + (journey_id, photo_id, caption, shared, sort_order, created_at) + SELECT + je.journey_id, + jpo.photo_id, + jpo.caption, + jpo.shared, + jpo.sort_order, + jpo.created_at + FROM journey_photos_old jpo + JOIN journey_entries je ON je.id = jpo.entry_id + WHERE jpo.id IN ( + SELECT MIN(jpo2.id) + FROM journey_photos_old jpo2 + JOIN journey_entries je2 ON je2.id = jpo2.entry_id + GROUP BY je2.journey_id, jpo2.photo_id + ) + `); + + // Backfill the junction: one row per (entry_id, photo_id), resolved to the + // new gallery ids. + await this.execute(` + INSERT OR IGNORE INTO journey_entry_photos + (entry_id, journey_photo_id, sort_order, created_at) + SELECT + jpo.entry_id, + jp.id, + jpo.sort_order, + jpo.created_at + FROM journey_photos_old jpo + JOIN journey_entries je ON je.id = jpo.entry_id + JOIN journey_photos jp + ON jp.journey_id = je.journey_id + AND jp.photo_id = jpo.photo_id + `); + + await this.execute(`DROP TABLE journey_photos_old`); + } + + // Remove the synthetic wrapper entries the gallery model replaces. The + // ON DELETE CASCADE on journey_entry_photos cleans up the junction rows. + await this.execute(`DELETE FROM journey_entries WHERE title IN ('Gallery', '[Trip Photos]')`); + + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_photos_journey ON journey_photos(journey_id)`); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_entry ON journey_entry_photos(entry_id)`); + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_journey_entry_photos_photo ON journey_entry_photos(journey_photo_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101020400_correct_stale_day_id_end_day_id.ts b/server/src/db/migrations/Migration20200101020400_correct_stale_day_id_end_day_id.ts new file mode 100644 index 000000000..765af9be5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020400_correct_stale_day_id_end_day_id.ts @@ -0,0 +1,74 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 124 (`db/migrations.ts`). + * + * Migration 122: Correct stale day_id / end_day_id on non-transport + * reservations. Migration 110 only backfilled transport types; tours, + * restaurants, events and "other" bookings kept a stale day_id from + * older code paths that often defaulted to the first day of the trip. + * Starting with v3.0.0 the planner renders reservations by day_id + * instead of reservation_time, so those stale rows show up on the + * wrong day. This migration nulls out day_id / end_day_id values that + * don't match the reservation's time and then backfills them from + * reservation_time / reservation_end_time. + */ +export class Migration20200101020400_correct_stale_day_id_end_day_id extends Migration { + override name = 'Migration20200101020400_correct_stale_day_id_end_day_id'; + + override up(): void { + this.addSql(` + UPDATE reservations + SET day_id = NULL + WHERE reservation_time IS NOT NULL + AND day_id IS NOT NULL + AND type != 'hotel' + AND NOT EXISTS ( + SELECT 1 FROM days d + WHERE d.id = reservations.day_id + AND d.date = substr(reservations.reservation_time, 1, 10) + ) + `); + + this.addSql(` + UPDATE reservations + SET end_day_id = NULL + WHERE reservation_end_time IS NOT NULL + AND end_day_id IS NOT NULL + AND type != 'hotel' + AND NOT EXISTS ( + SELECT 1 FROM days d + WHERE d.id = reservations.end_day_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + ) + `); + + this.addSql(` + UPDATE reservations + SET day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_time, 1, 10) + LIMIT 1 + ) + WHERE type != 'hotel' + AND reservation_time IS NOT NULL + AND day_id IS NULL + `); + + this.addSql(` + UPDATE reservations + SET end_day_id = ( + SELECT d.id FROM days d + WHERE d.trip_id = reservations.trip_id + AND d.date = substr(reservations.reservation_end_time, 1, 10) + LIMIT 1 + ) + WHERE type != 'hotel' + AND reservation_end_time IS NOT NULL + AND end_day_id IS NULL + AND substr(reservations.reservation_end_time, 1, 10) + != substr(reservations.reservation_time, 1, 10) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020500_846_make_sort_order_authoritative_within_a.ts b/server/src/db/migrations/Migration20200101020500_846_make_sort_order_authoritative_within_a.ts new file mode 100644 index 000000000..d6f2cebc5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020500_846_make_sort_order_authoritative_within_a.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 125 (`db/migrations.ts`). + * + * #846: make sort_order authoritative within a day. The previous ORDER BY put + * entry_time before sort_order, silently ignoring reorder clicks when two + * same-date entries had different times. The backfill renumbers using the old + * effective key (entry_time ASC, id ASC) so existing journeys keep their + * current visual order. + */ +export class Migration20200101020500_846_make_sort_order_authoritative_within_a extends Migration { + override name = 'Migration20200101020500_846_make_sort_order_authoritative_within_a'; + + override up(): void { + this.addSql(` + WITH ranked AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY journey_id, entry_date + ORDER BY entry_time ASC, id ASC + ) - 1 AS rn + FROM journey_entries + ) + UPDATE journey_entries + SET sort_order = (SELECT rn FROM ranked WHERE ranked.id = journey_entries.id) + `); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_journey_entries_order ON journey_entries(journey_id, entry_date, sort_order)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101020600_swap_inverted_start_day_id_end_day.ts b/server/src/db/migrations/Migration20200101020600_swap_inverted_start_day_id_end_day.ts new file mode 100644 index 000000000..2c52ac6fd --- /dev/null +++ b/server/src/db/migrations/Migration20200101020600_swap_inverted_start_day_id_end_day.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 126 (`db/migrations.ts`). + * + * Swap inverted start_day_id/end_day_id pairs in day_accommodations caused + * by the old Math.min/Math.max picker bug (pre-8e05ba7) which used raw IDs + * instead of positional order on trips with non-monotonic day ID layouts. + */ +export class Migration20200101020600_swap_inverted_start_day_id_end_day extends Migration { + override name = 'Migration20200101020600_swap_inverted_start_day_id_end_day'; + + override up(): void { + this.addSql(` + UPDATE day_accommodations + SET start_day_id = end_day_id, end_day_id = start_day_id + WHERE (SELECT day_number FROM days WHERE id = start_day_id) + > (SELECT day_number FROM days WHERE id = end_day_id) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101020700_prepare_migration_to_nest_typeorm.ts b/server/src/db/migrations/Migration20200101020700_prepare_migration_to_nest_typeorm.ts new file mode 100644 index 000000000..e69030b32 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020700_prepare_migration_to_nest_typeorm.ts @@ -0,0 +1,21 @@ +import { readEnv } from '../../app-config'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 127 (`db/migrations.ts`). + * + * Prepare migration to nest + typeorm. + */ +export class Migration20200101020700_prepare_migration_to_nest_typeorm extends Migration { + override name = 'Migration20200101020700_prepare_migration_to_nest_typeorm'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL)`, + ); + await this.execute(`INSERT INTO migrations (timestamp, name) VALUES (1777810195344, 'InitialSchema1777810195344')`); + await this.execute(`INSERT INTO app_settings (key, value) VALUES ('app_version', ?)`, [ + readEnv().app.appVersion || '3.0.14', + ]); + } +} diff --git a/server/src/db/migrations/Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames.ts b/server/src/db/migrations/Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames.ts new file mode 100644 index 000000000..401ec39d4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames.ts @@ -0,0 +1,91 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** A user row with whitespace around its username or email. */ +interface DirtyRow { + id: number; + username?: string; + email?: string; +} + +/** + * Legacy migration step 128 (`db/migrations.ts`). + * + * Trim leading/trailing whitespace from stored usernames and emails. + * + * A trimmed value can collide with an existing account, and the unique indexes + * would reject the UPDATE. Rather than fail the boot, the colliding row is + * suffixed with `__migrated_` and a flag is written to `app_settings` so the + * admin UI can surface that a manual review is needed. A renamed email means + * that user cannot sign in with it until an operator fixes it — which is the + * behaviour the original chose, deliberately, over refusing to start. + */ +export class Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames extends Migration { + override name = 'Migration20200101020800_trim_leading_trailing_whitespace_from_stored_usernames'; + + override async up(): Promise { + let hadCollision = false; + + const dirtyUsernames = (await this.execute( + `SELECT id, username FROM users WHERE username != TRIM(username)`, + )) as DirtyRow[]; + + for (const row of dirtyUsernames) { + const trimmed = row.username!.trim(); + const collisions = (await this.execute(`SELECT id FROM users WHERE LOWER(username) = LOWER(?) AND id != ?`, [ + trimmed, + row.id, + ])) as { id: number }[]; + const collision = collisions[0]; + + const final = collision ? `${trimmed}__migrated_${row.id}` : trimmed; + if (collision) { + hadCollision = true; + console.warn( + `[migration] WHITESPACE COLLISION username: user id=${row.id} ` + + `original=${JSON.stringify(row.username)} trimmed="${trimmed}" ` + + `collides with user id=${collision.id}. Renamed to "${final}". ` + + `Manual review required.`, + ); + } else { + console.warn( + `[migration] Trimmed username for user id=${row.id}: ` + `${JSON.stringify(row.username)} → "${final}"`, + ); + } + await this.execute(`UPDATE users SET username = ? WHERE id = ?`, [final, row.id]); + } + + const dirtyEmails = (await this.execute(`SELECT id, email FROM users WHERE email != TRIM(email)`)) as DirtyRow[]; + + for (const row of dirtyEmails) { + const trimmed = row.email!.trim(); + const collisions = (await this.execute(`SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND id != ?`, [ + trimmed, + row.id, + ])) as { id: number }[]; + const collision = collisions[0]; + + let final = trimmed; + if (collision) { + hadCollision = true; + const at = trimmed.lastIndexOf('@'); + final = + at > 0 ? `${trimmed.slice(0, at)}__migrated_${row.id}${trimmed.slice(at)}` : `${trimmed}__migrated_${row.id}`; + console.warn( + `[migration] WHITESPACE COLLISION email: user id=${row.id} ` + + `original=${JSON.stringify(row.email)} trimmed="${trimmed}" ` + + `collides with user id=${collision.id}. Renamed to "${final}". ` + + `User cannot sign in with this email until manually corrected.`, + ); + } else { + console.warn(`[migration] Trimmed email for user id=${row.id}: ` + `${JSON.stringify(row.email)} → "${final}"`); + } + await this.execute(`UPDATE users SET email = ? WHERE id = ?`, [final, row.id]); + } + + if (hadCollision) { + await this.execute( + `INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')`, + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101020900_create_schema_version_new.ts b/server/src/db/migrations/Migration20200101020900_create_schema_version_new.ts new file mode 100644 index 000000000..eb23cdff9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101020900_create_schema_version_new.ts @@ -0,0 +1,24 @@ +import { readEnv } from '../../app-config'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 129 (`db/migrations.ts`). + * + * Give `schema_version` a primary key. The original table was a single bare + * `version` column. + */ +export class Migration20200101020900_create_schema_version_new extends Migration { + override name = 'Migration20200101020900_create_schema_version_new'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS schema_version_new (id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER NOT NULL)`, + ); + await this.execute(`INSERT INTO schema_version_new (version) SELECT version FROM schema_version`); + await this.execute(`DROP TABLE schema_version`); + await this.execute(`ALTER TABLE schema_version_new RENAME TO schema_version`); + await this.execute(`UPDATE app_settings SET value = ? WHERE key = 'app_version'`, [ + readEnv().app.appVersion || '3.0.15', + ]); + } +} diff --git a/server/src/db/migrations/Migration20200101021000_oauth_2.ts b/server/src/db/migrations/Migration20200101021000_oauth_2.ts new file mode 100644 index 000000000..a208b1ce5 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021000_oauth_2.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 130 (`db/migrations.ts`). + * + * Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential + * clients to skip the browser consent flow entirely and obtain tokens directly + * via client_id + client_secret. Flag is immutable after creation so existing + * authorization-code clients are not silently upgraded. + */ +export class Migration20200101021000_oauth_2 extends Migration { + override name = 'Migration20200101021000_oauth_2'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'oauth_clients', + 'allows_client_credentials', + `allows_client_credentials INTEGER NOT NULL DEFAULT 0`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021100_drop_stale_atlas_cache_rows_for_territories.ts b/server/src/db/migrations/Migration20200101021100_drop_stale_atlas_cache_rows_for_territories.ts new file mode 100644 index 000000000..7689816ba --- /dev/null +++ b/server/src/db/migrations/Migration20200101021100_drop_stale_atlas_cache_rows_for_territories.ts @@ -0,0 +1,38 @@ +import { execUnlessTableMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** [minLng, minLat, maxLng, maxLat] per territory. */ +const ENCLAVE_BOXES: [number, number, number, number][] = [ + [113.83, 22.15, 114.43, 22.56], // HK + [113.53, 22.1, 113.6, 22.21], // MO + [12.4, 43.89, 12.52, 43.99], // SM + [12.44, 41.9, 12.46, 41.91], // VA + [7.4, 43.72, 7.44, 43.75], // MC + [9.47, 47.05, 9.64, 47.27], // LI + [-5.36, 36.11, -5.33, 36.16], // GI + [-67.3, 17.88, -65.22, 18.53], // PR +]; + +/** + * Legacy migration step 131 (`db/migrations.ts`). + * + * Drop stale atlas cache rows for territories that used to resolve to their + * surrounding country (Hong Kong/Macau as China, San Marino/Vatican as Italy, + * etc.) before their own bounding boxes existed. The next atlas stats request + * re-resolves any place inside these boxes with the corrected country code. + */ +export class Migration20200101021100_drop_stale_atlas_cache_rows_for_territories extends Migration { + override name = 'Migration20200101021100_drop_stale_atlas_cache_rows_for_territories'; + + override async up(): Promise { + for (const [minLng, minLat, maxLng, maxLat] of ENCLAVE_BOXES) { + await execUnlessTableMissing( + this, + `DELETE FROM place_regions WHERE place_id IN ( + SELECT id FROM places WHERE lat BETWEEN ? AND ? AND lng BETWEEN ? AND ? + )`, + [minLat, maxLat, minLng, maxLng], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101021200_costs_rework.ts b/server/src/db/migrations/Migration20200101021200_costs_rework.ts new file mode 100644 index 000000000..b602da336 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021200_costs_rework.ts @@ -0,0 +1,65 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 132 (`db/migrations.ts`). + * + * Costs rework (budget → "Costs", Tricount/Splitwise style). Adds, additively + * and without touching existing rows: + * - per-expense currency + exchange_rate, so an expense can be entered in a + * foreign currency and converted to the trip base currency (NULL currency = + * base currency; rate 1.0). Closes the multi-currency request (#551). + * - budget_item_payers: several people can each have paid part of one expense + * (amounts in the expense currency), replacing the single paid_by_user_id. + * - budget_settlements: persisted "X paid Y" transfers so the settle-up history + * (with undo) is shared across all trip members. + * + * The equal-split participants stay in budget_item_members. The single legacy + * payer is backfilled into budget_item_payers as one payer covering the total. + */ +export class Migration20200101021200_costs_rework extends Migration { + override name = 'Migration20200101021200_costs_rework'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'currency', `currency TEXT`); + await addColumnIfMissing(this, 'budget_items', 'exchange_rate', `exchange_rate REAL NOT NULL DEFAULT 1`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS budget_item_payers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount REAL NOT NULL DEFAULT 0, + UNIQUE(budget_item_id, user_id) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_budget_item_payers_item ON budget_item_payers(budget_item_id)`); + + await this.execute(` + CREATE TABLE IF NOT EXISTS budget_settlements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + from_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + amount REAL NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_by_user_id INTEGER REFERENCES users(id) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_budget_settlements_trip ON budget_settlements(trip_id)`); + + // Backfill the legacy single payer: that person paid the full total of the + // expense, in the (base) currency the existing amount was already stored in. + // `paid_by_user_id` does not exist on every database — skip where it is absent. + try { + await this.execute(` + INSERT OR IGNORE INTO budget_item_payers (budget_item_id, user_id, amount) + SELECT id, paid_by_user_id, total_price + FROM budget_items + WHERE paid_by_user_id IS NOT NULL + `); + } catch (err: unknown) { + if (!(err instanceof Error) || !err.message.includes('no such column')) throw err; + } + } +} diff --git a/server/src/db/migrations/Migration20200101021300_rename_the_budget_planner_addon_to_costs.ts b/server/src/db/migrations/Migration20200101021300_rename_the_budget_planner_addon_to_costs.ts new file mode 100644 index 000000000..74bba8b6e --- /dev/null +++ b/server/src/db/migrations/Migration20200101021300_rename_the_budget_planner_addon_to_costs.ts @@ -0,0 +1,18 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 133 (`db/migrations.ts`). + * + * Rename the "Budget Planner" addon to "Costs" in the admin add-on list. This is + * a display rename only — the addon id, tables, permissions and MCP tools all + * stay 'budget'. Scoped to the default name so a customised one is kept. + */ +export class Migration20200101021300_rename_the_budget_planner_addon_to_costs extends Migration { + override name = 'Migration20200101021300_rename_the_budget_planner_addon_to_costs'; + + override up(): void { + this.addSql( + `UPDATE addons SET name = 'Costs', description = 'Track and split trip expenses' WHERE id = 'budget' AND name = 'Budget Planner'`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021400_webauthn_passkey_support_per_user_credentials_single.ts b/server/src/db/migrations/Migration20200101021400_webauthn_passkey_support_per_user_credentials_single.ts new file mode 100644 index 000000000..69cc73d35 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021400_webauthn_passkey_support_per_user_credentials_single.ts @@ -0,0 +1,46 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 134 (`db/migrations.ts`). + * + * WebAuthn / passkey support: per-user credentials + single-use login + * challenges. Additive (CREATE TABLE IF NOT EXISTS) so existing installs are + * untouched; both tables also live in schema.ts for fresh installs. + */ +export class Migration20200101021400_webauthn_passkey_support_per_user_credentials_single extends Migration { + override name = 'Migration20200101021400_webauthn_passkey_support_per_user_credentials_single'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + transports TEXT, + device_type TEXT, + backed_up INTEGER NOT NULL DEFAULT 0, + name TEXT, + aaguid TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + last_used_at DATETIME + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user ON webauthn_credentials(user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS webauthn_challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge TEXT NOT NULL UNIQUE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + type TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_expires ON webauthn_challenges(expires_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries.ts b/server/src/db/migrations/Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries.ts new file mode 100644 index 000000000..94292840f --- /dev/null +++ b/server/src/db/migrations/Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries.ts @@ -0,0 +1,131 @@ +import { Migration } from '@mikro-orm/migrations'; + +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; + +/** A manually-marked sub-national region carrying a possibly-stale code. */ +interface VisitedRegion { + id: number; + region_code: string; + region_name: string; + country_code: string; +} + +interface Admin1Feature { + properties?: { iso_a2?: string; iso_3166_2?: string; name?: string }; +} + +/** + * Curated crosswalk for regions absorbed into a *renamed* successor (the + * name-match step cannot catch these, because the name changed). Norway's + * 2018/2020 reforms; extend as the pinned geoBoundaries dataset gains further + * reforms. + */ +const MERGE_CROSSWALK: Record = { + 'NO-04': 'NO-34', + 'NO-05': 'NO-34', // Hedmark, Oppland → Innlandet + 'NO-12': 'NO-46', + 'NO-14': 'NO-46', // Hordaland, Sogn og Fjordane → Vestland + 'NO-09': 'NO-42', + 'NO-10': 'NO-42', // Aust-/Vest-Agder → Agder + 'NO-01': 'NO-30', + 'NO-02': 'NO-30', + 'NO-06': 'NO-30', // Østfold/Akershus/Buskerud → Viken + 'NO-07': 'NO-38', + 'NO-08': 'NO-38', // Vestfold, Telemark → Vestfold og Telemark + 'NO-19': 'NO-54', + 'NO-20': 'NO-54', // Troms, Finnmark → Troms og Finnmark + 'NO-16': 'NO-50', + 'NO-17': 'NO-50', // Sør-/Nord-Trøndelag → Trøndelag +}; + +/** + * Legacy migration step 135 (`db/migrations.ts`). + * + * Atlas dropped Natural Earth for geoBoundaries. Manually-marked sub-national + * regions (`visited_regions`) stored the OLD Natural Earth ISO-3166-2 codes; + * some no longer match any polygon in the new bundle and would stop + * highlighting. Reconcile every row against the ACTUAL shipped admin-1 bundle so + * this covers *all* countries, not just one hand-listed reform: + * 1. code still present in the new bundle → leave it (already correct); + * 2. else a region in the same country shares → adopt that region's code+name + * the stored region_name (case-insensitive) (handles code re-spellings, + * e.g. ES-AN → ES_AND); + * 3. else a curated merge crosswalk maps it → adopt the merged region; + * 4. else → leave as-is (cannot be resolved; the client's name fallback may + * still highlight it, and nothing is destroyed). + * + * Other Atlas tables need NO remap: `visited_countries` / `bucket_list` hold + * only ISO-3166-1 alpha-2 codes (invariant across the swap), `bucket_list.name` + * is free text we must not auto-rewrite, and `place_regions` is a re-derivable + * Nominatim cache. + */ +export class Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries extends Migration { + override name = 'Migration20200101021500_atlas_dropped_natural_earth_for_geoboundaries'; + + override async up(): Promise { + const rows = (await this.execute( + `SELECT id, region_code, region_name, country_code FROM visited_regions`, + )) as VisitedRegion[]; + if (rows.length === 0) return; // nothing marked → skip the bundle read entirely + + // Index the shipped admin-1 bundle: valid codes, name→code per country, + // code→name. `__dirname` resolves ../../../assets under both dist + // (dist/db/migrations) and ts-node/vitest (src/db/migrations). + let features: Admin1Feature[]; + try { + const file = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz'); + features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || []; + } catch { + features = []; // bundle missing → degrade to the curated crosswalk below + } + + const validCodes = new Set(); + const nameToCode = new Map(); // `${A2}|${nameLower}` → code + const codeToName = new Map(); + for (const f of features) { + const a2 = (f.properties?.iso_a2 || '').toUpperCase(); + const code = f.properties?.iso_3166_2 || ''; + const name = f.properties?.name || ''; + if (!code) continue; + validCodes.add(code); + if (!codeToName.has(code)) codeToName.set(code, name); + if (a2 && name) nameToCode.set(`${a2}|${name.toLowerCase()}`, code); + } + + const resolve = (row: VisitedRegion): string | null => { + if (validCodes.has(row.region_code)) return null; // already valid + const a2 = (row.country_code || '').toUpperCase(); + const byName = nameToCode.get(`${a2}|${(row.region_name || '').toLowerCase()}`); + if (byName) return byName; + const merged = MERGE_CROSSWALK[row.region_code]; + // Only trust the crosswalk target if it actually exists in the bundle (or + // the bundle was unreadable, in which case apply the curated map blindly). + if (merged && (validCodes.size === 0 || validCodes.has(merged))) return merged; + return null; + }; + + for (const row of rows) { + const newCode = resolve(row); + if (!newCode || newCode === row.region_code) continue; + const newName = codeToName.get(newCode) || row.region_name; + + await this.execute(`UPDATE OR IGNORE visited_regions SET region_code = ?, region_name = ? WHERE id = ?`, [ + newCode, + newName, + row.id, + ]); + + // UNIQUE(user_id, region_code): if the user already had the target code the + // UPDATE was IGNORED and this row still carries the old code → drop the + // duplicate. + const after = (await this.execute(`SELECT region_code FROM visited_regions WHERE id = ?`, [row.id])) as { + region_code: string; + }[]; + if (after[0]?.region_code === row.region_code) { + await this.execute(`DELETE FROM visited_regions WHERE id = ?`, [row.id]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101021600_step_136.ts b/server/src/db/migrations/Migration20200101021600_step_136.ts new file mode 100644 index 000000000..3302dcead --- /dev/null +++ b/server/src/db/migrations/Migration20200101021600_step_136.ts @@ -0,0 +1,21 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 136 (`db/migrations.ts`). + * + * AirTrail integration addon — disabled by default (opt-in). The per-user + * connection lives in Settings → Integrations; this row is only the admin-level + * global toggle. + */ +export class Migration20200101021600_step_136 extends Migration { + override name = 'Migration20200101021600_step_136'; + + override async up(): Promise { + await execBestEffort( + this, + `INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)`, + ['airtrail', 'AirTrail', 'Sync flights from your self-hosted AirTrail instance', 'integration', 'Plane', 0, 14], + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021700_users_add_airtrail_url.ts b/server/src/db/migrations/Migration20200101021700_users_add_airtrail_url.ts new file mode 100644 index 000000000..469d3a34d --- /dev/null +++ b/server/src/db/migrations/Migration20200101021700_users_add_airtrail_url.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 137 (`db/migrations.ts`). + */ +export class Migration20200101021700_users_add_airtrail_url extends Migration { + override name = 'Migration20200101021700_users_add_airtrail_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'airtrail_url', `airtrail_url TEXT`); + + await addColumnIfMissing(this, 'users', 'airtrail_api_key', `airtrail_api_key TEXT`); + + await addColumnIfMissing( + this, + 'users', + 'airtrail_allow_insecure_tls', + `airtrail_allow_insecure_tls INTEGER DEFAULT 0`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021800_reservations_add_external_source.ts b/server/src/db/migrations/Migration20200101021800_reservations_add_external_source.ts new file mode 100644 index 000000000..4b9c6b354 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021800_reservations_add_external_source.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 138 (`db/migrations.ts`). + */ +export class Migration20200101021800_reservations_add_external_source extends Migration { + override name = 'Migration20200101021800_reservations_add_external_source'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'external_source', `external_source TEXT`); + + await addColumnIfMissing(this, 'reservations', 'external_id', `external_id TEXT`); + + await addColumnIfMissing(this, 'reservations', 'external_owner_user_id', `external_owner_user_id INTEGER`); + + await addColumnIfMissing(this, 'reservations', 'external_synced_at', `external_synced_at TEXT`); + + await addColumnIfMissing(this, 'reservations', 'sync_enabled', `sync_enabled INTEGER DEFAULT 1`); + + await addColumnIfMissing(this, 'reservations', 'external_hash', `external_hash TEXT`); + + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_reservations_external ON reservations(external_source, external_id, trip_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101021900_users_add_airtrail_write_enabled.ts b/server/src/db/migrations/Migration20200101021900_users_add_airtrail_write_enabled.ts new file mode 100644 index 000000000..71dc7b906 --- /dev/null +++ b/server/src/db/migrations/Migration20200101021900_users_add_airtrail_write_enabled.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 139 (`db/migrations.ts`). + */ +export class Migration20200101021900_users_add_airtrail_write_enabled extends Migration { + override name = 'Migration20200101021900_users_add_airtrail_write_enabled'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'airtrail_write_enabled', `airtrail_write_enabled INTEGER DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101022000_store_google_maps_feature_ids_separately_from.ts b/server/src/db/migrations/Migration20200101022000_store_google_maps_feature_ids_separately_from.ts new file mode 100644 index 000000000..81d316e58 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022000_store_google_maps_feature_ids_separately_from.ts @@ -0,0 +1,15 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 140 (`db/migrations.ts`). + * + * Store Google Maps feature IDs separately from real Google Places API IDs. + */ +export class Migration20200101022000_store_google_maps_feature_ids_separately_from extends Migration { + override name = 'Migration20200101022000_store_google_maps_feature_ids_separately_from'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'google_ftid', `google_ftid TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101022100_remember_the_app_version_a_notice_was.ts b/server/src/db/migrations/Migration20200101022100_remember_the_app_version_a_notice_was.ts new file mode 100644 index 000000000..015d12ec6 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022100_remember_the_app_version_a_notice_was.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 141 (`db/migrations.ts`). + * + * Remember the app version a notice was dismissed at, so per-version recurring + * notices (e.g. the thank-you) re-appear on the next install/upgrade. + */ +export class Migration20200101022100_remember_the_app_version_a_notice_was extends Migration { + override name = 'Migration20200101022100_remember_the_app_version_a_notice_was'; + + override async up(): Promise { + await addColumnIfMissing(this, 'user_notice_dismissals', 'dismissed_app_version', `dismissed_app_version TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101022200_budget_item_members_add_amount.ts b/server/src/db/migrations/Migration20200101022200_budget_item_members_add_amount.ts new file mode 100644 index 000000000..bb48a476d --- /dev/null +++ b/server/src/db/migrations/Migration20200101022200_budget_item_members_add_amount.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 142 (`db/migrations.ts`). + */ +export class Migration20200101022200_budget_item_members_add_amount extends Migration { + override name = 'Migration20200101022200_budget_item_members_add_amount'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_item_members', 'amount', `amount REAL`); + } +} diff --git a/server/src/db/migrations/Migration20200101022300_calendar_feed_tokens.ts b/server/src/db/migrations/Migration20200101022300_calendar_feed_tokens.ts new file mode 100644 index 000000000..9526d07ad --- /dev/null +++ b/server/src/db/migrations/Migration20200101022300_calendar_feed_tokens.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 143 (`db/migrations.ts`). + * + * Calendar feed tokens — subscribable ICS links for per-trip and all-trips feeds + */ +export class Migration20200101022300_calendar_feed_tokens extends Migration { + override name = 'Migration20200101022300_calendar_feed_tokens'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trips', 'feed_token', `feed_token TEXT`); + + await addColumnIfMissing(this, 'users', 'feed_token', `feed_token TEXT`); + + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_trips_feed_token ON trips(feed_token) WHERE feed_token IS NOT NULL`, + ); + + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_feed_token ON users(feed_token) WHERE feed_token IS NOT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection.ts b/server/src/db/migrations/Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection.ts new file mode 100644 index 000000000..61c28d653 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 144 (`db/migrations.ts`). + * + * Optimistic-concurrency token for offline conflict detection (#1135). + * packing_items had only created_at, so an offline edit could not be checked + * against a concurrent server change. SQLite forbids a non-constant DEFAULT on + * ALTER ADD COLUMN, so add it nullable and backfill from created_at; new rows + * set it explicitly (packingService). Additive: a request without the + * X-Base-Updated-At header keeps the old last-write-wins behaviour. + */ +export class Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection extends Migration { + override name = 'Migration20200101022400_optimistic_concurrency_token_for_offline_conflict_detection'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'updated_at', `updated_at DATETIME`); + + await this.execute( + `UPDATE packing_items SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP) WHERE updated_at IS NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101022500_video_support.ts b/server/src/db/migrations/Migration20200101022500_video_support.ts new file mode 100644 index 000000000..23ee89f59 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022500_video_support.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 145 (`db/migrations.ts`). + * + * Video support (#823): the trek_photos registry held only images. media_type + * discriminates image vs video so the gallery, lightbox and provider proxy can + * branch; duration_ms is optional metadata for the player. Additive — existing + * rows default to 'image'. + */ +export class Migration20200101022500_video_support extends Migration { + override name = 'Migration20200101022500_video_support'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trek_photos', 'media_type', `media_type TEXT NOT NULL DEFAULT 'image'`); + await addColumnIfMissing(this, 'trek_photos', 'duration_ms', `duration_ms INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101022600_dedicated_booking_url.ts b/server/src/db/migrations/Migration20200101022600_dedicated_booking_url.ts new file mode 100644 index 000000000..90fb684a3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022600_dedicated_booking_url.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 146 (`db/migrations.ts`). + * + * Dedicated booking URL (#935) — users previously stuffed links into notes. + * Additive nullable TEXT; existing rows default to NULL. + */ +export class Migration20200101022600_dedicated_booking_url extends Migration { + override name = 'Migration20200101022600_dedicated_booking_url'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'url', `url TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101022700_private_packing_items.ts b/server/src/db/migrations/Migration20200101022700_private_packing_items.ts new file mode 100644 index 000000000..e7d879fe9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022700_private_packing_items.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 147 (`db/migrations.ts`). + * + * Private packing items (#858): an item can be hidden from other trip members. + * `is_private` toggles the visibility; `owner_id` records who it belongs to so + * the listing can show it only to them. `owner_id` is NULL on legacy rows + * (shared). + */ +export class Migration20200101022700_private_packing_items extends Migration { + override name = 'Migration20200101022700_private_packing_items'; + + override async up(): Promise { + await addColumnIfMissing(this, 'packing_items', 'is_private', `is_private INTEGER NOT NULL DEFAULT 0`); + await addColumnIfMissing( + this, + 'packing_items', + 'owner_id', + `owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101022800_guest_members.ts b/server/src/db/migrations/Migration20200101022800_guest_members.ts new file mode 100644 index 000000000..d491e5f14 --- /dev/null +++ b/server/src/db/migrations/Migration20200101022800_guest_members.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 148 (`db/migrations.ts`). + * + * Guest members (#1362): people added to a trip without an account. A guest is a + * users row flagged is_guest=1 (no usable credentials) joined into trip_members, + * so it's assignable everywhere a member is — but must never authenticate or show + * up in the global user directory. The flag is the discriminator for those guards. + */ +export class Migration20200101022800_guest_members extends Migration { + override name = 'Migration20200101022800_guest_members'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'is_guest', `is_guest INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101022900_three_tier_packing_sharing.ts b/server/src/db/migrations/Migration20200101022900_three_tier_packing_sharing.ts new file mode 100644 index 000000000..1a5aa389f --- /dev/null +++ b/server/src/db/migrations/Migration20200101022900_three_tier_packing_sharing.ts @@ -0,0 +1,36 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 149 (`db/migrations.ts`). + * + * Three-tier packing sharing (#858 follow-up): an item is Common (is_private=0, + * every existing item — non-breaking), Personal (is_private=1, owner only) or + * Shared-with-people (is_private=1 + recipient rows). owner_id is the "bringer". + * Contributors are extra people who said "I can bring that too" on a Common item + * (status 'pending' until the owner accepts). + */ +export class Migration20200101022900_three_tier_packing_sharing extends Migration { + override name = 'Migration20200101022900_three_tier_packing_sharing'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_item_recipients ( + item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (item_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_packing_item_recipients_user ON packing_item_recipients(user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS packing_item_contributors ( + item_id INTEGER NOT NULL REFERENCES packing_items(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'accepted', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (item_id, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101023000_collections_addon.ts b/server/src/db/migrations/Migration20200101023000_collections_addon.ts new file mode 100644 index 000000000..7f7e89a10 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023000_collections_addon.ts @@ -0,0 +1,89 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 150 (`db/migrations.ts`). + * + * Collections addon — personal place library (#1081). Multi-list + per-place + * status (idea/want/visited) + fusion sharing. + */ +export class Migration20200101023000_collections_addon extends Migration { + override name = 'Migration20200101023000_collections_addon'; + + override up(): void { + this.addSql(` + INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, config, sort_order) + VALUES ('collections', 'Collections', 'Personal place library — save places across trips into named lists, copy into any trip, share with others', 'global', 'Bookmark', 0, '{}', 16) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + color TEXT DEFAULT '#6366f1', + icon TEXT DEFAULT 'Bookmark', + cover_image TEXT, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_places ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + saved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + lat REAL, + lng REAL, + address TEXT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + price REAL, + currency TEXT, + notes TEXT, + image_url TEXT, + google_place_id TEXT, + google_ftid TEXT, + osm_id TEXT, + website TEXT, + phone TEXT, + status TEXT NOT NULL DEFAULT 'idea', + source_trip_id INTEGER, + source_place_id INTEGER, + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_tags ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, tag_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_places_collection ON collection_places(collection_id)`); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_members_user ON collection_members(user_id)`); + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_tags_place ON collection_place_tags(collection_place_id)`, + ); + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_tags_tag ON collection_place_tags(tag_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101023100_user_added_links_on_collections_saved_places.ts b/server/src/db/migrations/Migration20200101023100_user_added_links_on_collections_saved_places.ts new file mode 100644 index 000000000..57e68b255 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023100_user_added_links_on_collections_saved_places.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 151 (`db/migrations.ts`). + * + * Migration 151: user-added links on collections + saved places (JSON text) + */ +export class Migration20200101023100_user_added_links_on_collections_saved_places extends Migration { + override name = 'Migration20200101023100_user_added_links_on_collections_saved_places'; + + override async up(): Promise { + await addColumnIfMissing(this, 'collections', 'links', `links TEXT`); + + await addColumnIfMissing(this, 'collection_places', 'links', `links TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101023200_per_member_permission_role_on_a_shared.ts b/server/src/db/migrations/Migration20200101023200_per_member_permission_role_on_a_shared.ts new file mode 100644 index 000000000..113fa420a --- /dev/null +++ b/server/src/db/migrations/Migration20200101023200_per_member_permission_role_on_a_shared.ts @@ -0,0 +1,16 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 152 (`db/migrations.ts`). + * + * Migration 152: per-member permission role on a shared list. Existing + * accepted members default to 'editor' so nothing regresses. + */ +export class Migration20200101023200_per_member_permission_role_on_a_shared extends Migration { + override name = 'Migration20200101023200_per_member_permission_role_on_a_shared'; + + override async up(): Promise { + await addColumnIfMissing(this, 'collection_members', 'role', `role TEXT NOT NULL DEFAULT 'editor'`); + } +} diff --git a/server/src/db/migrations/Migration20200101023300_per_trip_invite_links.ts b/server/src/db/migrations/Migration20200101023300_per_trip_invite_links.ts new file mode 100644 index 000000000..b046845a2 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023300_per_trip_invite_links.ts @@ -0,0 +1,28 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 153 (`db/migrations.ts`). + * + * Migration 153: per-trip invite links (#1143). One rotating token per trip; + * a logged-in existing user who opens the link joins the trip as a member. + * Deleting the trip drops the token (CASCADE); the creator is nulled if their + * account is removed so the link keeps working. + */ +export class Migration20200101023300_per_trip_invite_links extends Migration { + override name = 'Migration20200101023300_per_trip_invite_links'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_invite_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL UNIQUE REFERENCES trips(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + expires_at TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_invite_tokens_token ON trip_invite_tokens(token)`); + } +} diff --git a/server/src/db/migrations/Migration20200101023400_optional_trip_binding_on_an_admin_invite.ts b/server/src/db/migrations/Migration20200101023400_optional_trip_binding_on_an_admin_invite.ts new file mode 100644 index 000000000..5bbd83bcb --- /dev/null +++ b/server/src/db/migrations/Migration20200101023400_optional_trip_binding_on_an_admin_invite.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 154 (`db/migrations.ts`). + * + * Migration 154: optional trip binding on an admin invite link (#1402). A user + * who REGISTERS via the link is auto-added to the trip. Nullable for backward + * compatibility; ON DELETE SET NULL so removing the trip just unbinds the invite. + */ +export class Migration20200101023400_optional_trip_binding_on_an_admin_invite extends Migration { + override name = 'Migration20200101023400_optional_trip_binding_on_an_admin_invite'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'invite_tokens', + 'trip_id', + `trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101023500_plugin_system_scaffold.ts b/server/src/db/migrations/Migration20200101023500_plugin_system_scaffold.ts new file mode 100644 index 000000000..4e4e0ccbb --- /dev/null +++ b/server/src/db/migrations/Migration20200101023500_plugin_system_scaffold.ts @@ -0,0 +1,83 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 155 (`db/migrations.ts`). + * + * Migration 155: plugin system scaffold (#plugins). A plugin is a row here; + * its code lives on the /plugins volume and (once the runtime lands) runs in + * an isolated child process. This migration only lays down the registry + * tables — nothing executes yet. Own data lives in a per-plugin sqlite file + * under /plugins-data, never in these tables. + */ +export class Migration20200101023500_plugin_system_scaffold extends Migration { + override name = 'Migration20200101023500_plugin_system_scaffold'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugins ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + type TEXT NOT NULL DEFAULT 'integration', + icon TEXT DEFAULT 'Blocks', + version TEXT, + api_version INTEGER DEFAULT 1, + min_trek_version TEXT, + permissions TEXT DEFAULT '[]', + granted_permissions TEXT DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'inactive', + config TEXT DEFAULT '{}', + source_repo TEXT, + source_commit TEXT, + sha256 TEXT, + crash_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + reviewed_at TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + installed_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_meta_migrations ( + plugin_id TEXT NOT NULL, + migration_id TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (plugin_id, migration_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_error_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + ts DATETIME DEFAULT CURRENT_TIMESTAMP, + level TEXT NOT NULL DEFAULT 'error', + message TEXT, + stack TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_plugin_error_log_plugin ON plugin_error_log(plugin_id, ts)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_settings_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + field_key TEXT NOT NULL, + label TEXT, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + hint TEXT, + required INTEGER NOT NULL DEFAULT 0, + secret INTEGER NOT NULL DEFAULT 0, + scope TEXT NOT NULL DEFAULT 'instance', + options TEXT, + oauth_config TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + UNIQUE (plugin_id, field_key) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101023600_separate_the_admin_s_on_off_intent.ts b/server/src/db/migrations/Migration20200101023600_separate_the_admin_s_on_off_intent.ts new file mode 100644 index 000000000..02656b020 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023600_separate_the_admin_s_on_off_intent.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing, execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 156 (`db/migrations.ts`). + * + * Migration 156: separate the admin's ON/OFF intent (`enabled`) from the + * runtime health (`status`). A crash used to flip status to 'error', which + * erased the "keep it on" intent, so the plugin never rebooted after a deploy. + * Boot now retries every enabled plugin regardless of last status. + */ +export class Migration20200101023600_separate_the_admin_s_on_off_intent extends Migration { + override name = 'Migration20200101023600_separate_the_admin_s_on_off_intent'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'enabled', `enabled INTEGER NOT NULL DEFAULT 0`); + + await execBestEffort(this, `UPDATE plugins SET enabled = 1 WHERE status != 'inactive'`); + } +} diff --git a/server/src/db/migrations/Migration20200101023700_plugin_capabilities.ts b/server/src/db/migrations/Migration20200101023700_plugin_capabilities.ts new file mode 100644 index 000000000..14f67399d --- /dev/null +++ b/server/src/db/migrations/Migration20200101023700_plugin_capabilities.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 157 (`db/migrations.ts`). + * + * Migration 157: plugin capabilities (from trek-plugin.json) — the client + * needs them to place widgets (e.g. widget.slot 'hero' renders as an overlay + * on the boarding-pass bar instead of the dashboard sidebar). + */ +export class Migration20200101023700_plugin_capabilities extends Migration { + override name = 'Migration20200101023700_plugin_capabilities'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'capabilities', `capabilities TEXT NOT NULL DEFAULT '{}'`); + } +} diff --git a/server/src/db/migrations/Migration20200101023800_tofu_pin_for_a_plugin_s_author.ts b/server/src/db/migrations/Migration20200101023800_tofu_pin_for_a_plugin_s_author.ts new file mode 100644 index 000000000..7d2619a8c --- /dev/null +++ b/server/src/db/migrations/Migration20200101023800_tofu_pin_for_a_plugin_s_author.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 158 (`db/migrations.ts`). + * + * Migration 158: TOFU pin for a plugin's author signing key (#plugins, #4). + * Set on first install of a signed plugin; a later install whose registry key + * differs is a hard stop (author change / key rotation / attack) unless an + * admin re-trusts. NULL for unsigned plugins (signing is opt-in). + */ +export class Migration20200101023800_tofu_pin_for_a_plugin_s_author extends Migration { + override name = 'Migration20200101023800_tofu_pin_for_a_plugin_s_author'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'author_pubkey', `author_pubkey TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101023900_hash_chained_capability_audit_log.ts b/server/src/db/migrations/Migration20200101023900_hash_chained_capability_audit_log.ts new file mode 100644 index 000000000..0323cad41 --- /dev/null +++ b/server/src/db/migrations/Migration20200101023900_hash_chained_capability_audit_log.ts @@ -0,0 +1,39 @@ +import { execBestEffort } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 159 (`db/migrations.ts`). + * + * Migration 159: hash-chained capability audit log (#plugins, L1 hardening). + * Every host-mediated capability call the plugin makes is recorded at the RPC + * boundary (where the plugin provably can't reach) with the host-bound acting + * user and a per-plugin hash chain, so wide data grants stay attributable + + * tamper-evident + user-visible. + */ +export class Migration20200101023900_hash_chained_capability_audit_log extends Migration { + override name = 'Migration20200101023900_hash_chained_capability_audit_log'; + + override async up(): Promise { + await execBestEffort( + this, + ` + CREATE TABLE IF NOT EXISTS plugin_capability_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + acting_user_id INTEGER, + method TEXT NOT NULL, + resource TEXT, + code TEXT NOT NULL, + ts TEXT NOT NULL DEFAULT (datetime('now')), + prev_hash TEXT, + hash TEXT NOT NULL + ) + `, + ); + + await execBestEffort( + this, + `CREATE INDEX IF NOT EXISTS idx_plugin_audit_plugin ON plugin_capability_audit (plugin_id, id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101024000_per_collection_custom_labels.ts b/server/src/db/migrations/Migration20200101024000_per_collection_custom_labels.ts new file mode 100644 index 000000000..ca8c9ecfe --- /dev/null +++ b/server/src/db/migrations/Migration20200101024000_per_collection_custom_labels.ts @@ -0,0 +1,41 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 160 (`db/migrations.ts`). + * + * Migration 160: per-collection custom labels (#collections). Each list owns + * its own label set (unlike the instance-wide `tags` table), and a place can + * carry several labels. Used for grouping + filtering places within a list. + */ +export class Migration20200101024000_per_collection_custom_labels extends Migration { + override name = 'Migration20200101024000_per_collection_custom_labels'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + color TEXT DEFAULT '#6366f1', + sort_order INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_labels ( + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + label_id INTEGER NOT NULL REFERENCES collection_labels(id) ON DELETE CASCADE, + PRIMARY KEY (collection_place_id, label_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_labels_collection ON collection_labels(collection_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_labels_place ON collection_place_labels(collection_place_id)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collection_place_labels_label ON collection_place_labels(label_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101024100_plugin_owned_metadata_on_core_entities.ts b/server/src/db/migrations/Migration20200101024100_plugin_owned_metadata_on_core_entities.ts new file mode 100644 index 000000000..d881a20fe --- /dev/null +++ b/server/src/db/migrations/Migration20200101024100_plugin_owned_metadata_on_core_entities.ts @@ -0,0 +1,34 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 161 (`db/migrations.ts`). + * + * Migration 161: plugin-owned metadata on core entities (#1429). A namespaced + * key/value store so a plugin can attach data to a trip/place/day WITHOUT + * forking the core schema. One row per (plugin, entity, key); a plugin only + * ever sees its own rows. entity_type is polymorphic (no cross-table FK), so + * rows are purged by plugin_id on uninstall; entity deletes leave harmless + * orphans that the plugin's own reads never surface. + */ +export class Migration20200101024100_plugin_owned_metadata_on_core_entities extends Migration { + override name = 'Migration20200101024100_plugin_owned_metadata_on_core_entities'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_entity_metadata ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, entity_type, entity_id, key) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_plugin_meta_entity ON plugin_entity_metadata (plugin_id, entity_type, entity_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101024200_freeze_the_fx_rate_on_settle_up.ts b/server/src/db/migrations/Migration20200101024200_freeze_the_fx_rate_on_settle_up.ts new file mode 100644 index 000000000..8dc5be0b0 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024200_freeze_the_fx_rate_on_settle_up.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 162 (`db/migrations.ts`). + * + * Freeze the FX rate on settle-up transfers too (#1445). budget_settlements + * stored only a bare `amount` in whatever display currency the payer was + * viewing, so a later live-rate drift re-opened an already-settled position + * with a few-cent residual. Capture the display `currency` and the rate frozen + * at settle time (units of that currency per 1 trip currency), mirroring the + * budget_items columns. Legacy rows keep currency = NULL / rate = 1 and stay on + * live rates until re-edited. + */ +export class Migration20200101024200_freeze_the_fx_rate_on_settle_up extends Migration { + override name = 'Migration20200101024200_freeze_the_fx_rate_on_settle_up'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_settlements', 'currency', `currency TEXT`); + + await addColumnIfMissing(this, 'budget_settlements', 'exchange_rate', `exchange_rate REAL NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101024300_1446_guests_are_per_trip_people_but.ts b/server/src/db/migrations/Migration20200101024300_1446_guests_are_per_trip_people_but.ts new file mode 100644 index 000000000..f8bc726b7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024300_1446_guests_are_per_trip_people_but.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 163 (`db/migrations.ts`). + * + * #1446: guests are per-trip people, but their display name lived in the globally + * UNIQUE users.username, so a second "Jake" on another trip was auto-renamed to + * "Jake 2". Add a non-unique display_name; new guests store the human name here and + * get a uuid-based username that is never shown (the member views COALESCE to it). + */ +export class Migration20200101024300_1446_guests_are_per_trip_people_but extends Migration { + override name = 'Migration20200101024300_1446_guests_are_per_trip_people_but'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'display_name', `display_name TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101024400_plugin_dependencies.ts b/server/src/db/migrations/Migration20200101024400_plugin_dependencies.ts new file mode 100644 index 000000000..8d1e48208 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024400_plugin_dependencies.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 164 (`db/migrations.ts`). + * + * Plugin dependencies (#plugins): a plugin's trek-plugin.json can now declare + * `requiredAddons` (addon ids that must be enabled to activate) and + * `pluginDependencies` ({id, version-range} of other plugins that must be + * installed + satisfied). Stored as one JSON blob and populated by the + * discovery upsert on every install path. Legacy rows default to '{}' (no deps). + */ +export class Migration20200101024400_plugin_dependencies extends Migration { + override name = 'Migration20200101024400_plugin_dependencies'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'dependencies', `dependencies TEXT NOT NULL DEFAULT '{}'`); + } +} diff --git a/server/src/db/migrations/Migration20200101024500_per_user_plugin_settings.ts b/server/src/db/migrations/Migration20200101024500_per_user_plugin_settings.ts new file mode 100644 index 000000000..c180d68a7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024500_per_user_plugin_settings.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 165 (`db/migrations.ts`). + * + * Per-user plugin settings (#plugins). A plugin can declare `scope:'user'` + * settings fields (e.g. an API key or a personal preference); each USER stores + * their own values here, separate from the admin-owned instance `plugins.config`. + * Secrets are encrypted with the same apiKeyCrypto as instance secrets and are + * never echoed back to the client (masked). Runtime reads the acting user's row. + */ +export class Migration20200101024500_per_user_plugin_settings extends Migration { + override name = 'Migration20200101024500_per_user_plugin_settings'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_user_config ( + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + config TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (plugin_id, user_id) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101024600_host_brokered_outbound_oauth.ts b/server/src/db/migrations/Migration20200101024600_host_brokered_outbound_oauth.ts new file mode 100644 index 000000000..c42b527ae --- /dev/null +++ b/server/src/db/migrations/Migration20200101024600_host_brokered_outbound_oauth.ts @@ -0,0 +1,38 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 166 (`db/migrations.ts`). + * + * Host-brokered outbound OAuth (#plugins). A plugin becomes an OAuth *client* of a + * third-party service; the HOST runs authorize->callback->token->refresh with + * PKCE+state and owns the tokens — the plugin never sees the refresh token. Tokens + * are per-user + encrypted at rest; the in-flight PKCE verifier/state is short-lived. + */ +export class Migration20200101024600_host_brokered_outbound_oauth extends Migration { + override name = 'Migration20200101024600_host_brokered_outbound_oauth'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_oauth_tokens ( + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + access_token TEXT, + refresh_token TEXT, + expires_at INTEGER, + scope TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (plugin_id, user_id) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_oauth_state ( + state TEXT PRIMARY KEY, + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + verifier TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101024700_persistent_plugin_scheduler.ts b/server/src/db/migrations/Migration20200101024700_persistent_plugin_scheduler.ts new file mode 100644 index 000000000..2470eff02 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024700_persistent_plugin_scheduler.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 167 (`db/migrations.ts`). + * + * Persistent plugin scheduler (#plugins). A plugin with the existing `jobs:run` + * grant can schedule a userless callback to fire at a future time (once, or + * recurring), surviving server restarts because the entry lives here. Same risk + * class as cron jobs (no user, no trip reads, own db + declared egress only) — + * so it rides on `jobs:run`, no new consent. UNIQUE(plugin_id, name) makes + * scheduler.set an upsert and cancel deterministic. Rows are purged on uninstall. + */ +export class Migration20200101024700_persistent_plugin_scheduler extends Migration { + override name = 'Migration20200101024700_persistent_plugin_scheduler'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_scheduled_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + name TEXT NOT NULL, + due_at INTEGER NOT NULL, + payload TEXT NOT NULL DEFAULT 'null', + every_ms INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, name) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_plugin_sched_due ON plugin_scheduled_tasks (due_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101024800_durable_gdpr_erasure_queue.ts b/server/src/db/migrations/Migration20200101024800_durable_gdpr_erasure_queue.ts new file mode 100644 index 000000000..7dafe5607 --- /dev/null +++ b/server/src/db/migrations/Migration20200101024800_durable_gdpr_erasure_queue.ts @@ -0,0 +1,30 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 168 (`db/migrations.ts`). + * + * Durable GDPR erasure queue (#plugins). When a TREK account is deleted, every + * installed plugin holding `hook:user-data` gets a pending row here so its own + * deleteUserData handler runs even if the plugin was offline at delete time — + * erasure must not be lost across a restart, so it is persisted (unlike the + * best-effort event buffer). The row is removed once the plugin acknowledges, + * and all of a plugin's rows are purged on uninstall. UNIQUE(plugin_id, user_id) + * makes re-enqueue idempotent. + */ +export class Migration20200101024800_durable_gdpr_erasure_queue extends Migration { + override name = 'Migration20200101024800_durable_gdpr_erasure_queue'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_user_erasure_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_plugin_erasure_plugin ON plugin_user_erasure_queue (plugin_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101024900_tombstones_for_atlas_countries_the_user_has.ts b/server/src/db/migrations/Migration20200101024900_tombstones_for_atlas_countries_the_user_has.ts new file mode 100644 index 000000000..0121713cc --- /dev/null +++ b/server/src/db/migrations/Migration20200101024900_tombstones_for_atlas_countries_the_user_has.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 169 (`db/migrations.ts`). + * + * Tombstones for Atlas countries the user has explicitly removed (#1490). + * Atlas derives visited countries from trip places and transport endpoints on every + * request, so those countries have no row to delete — "Remove" deleted from + * visited_countries (which never had the row), the client hid it optimistically, and + * the next getStats re-derived it. Recording the removal here lets getStats suppress + * a derived country. Re-marking a country deletes its tombstone. + */ +export class Migration20200101024900_tombstones_for_atlas_countries_the_user_has extends Migration { + override name = 'Migration20200101024900_tombstones_for_atlas_countries_the_user_has'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS hidden_countries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, country_code) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_hidden_countries_user ON hidden_countries (user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin.ts b/server/src/db/migrations/Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin.ts new file mode 100644 index 000000000..85ce0d166 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin.ts @@ -0,0 +1,37 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 170 (`db/migrations.ts`). + * + * Operator-supplied egress hosts for a plugin (#plugins). + * + * A plugin's egress allow-list is fixed in its manifest at publish time, but a + * plugin that talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the + * operator's hostname — so a community plugin could serve nobody. These rows let + * the ADMIN add hosts post-install; the runtime unions them into the child's + * allow-list at spawn. Consent stays with the admin (never the end user), + * exactly as for manifest egress. + */ +export class Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin extends Migration { + override name = 'Migration20200101025000_operator_supplied_egress_hosts_for_a_plugin'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS plugin_egress_hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + host TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (plugin_id, host) + ) + `); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_plugin_egress_hosts_plugin ON plugin_egress_hosts (plugin_id)`); + + // Whether the plugin DECLARED that it needs operator-supplied hosts. Only + // such a plugin may have hosts added — an admin can never widen egress for a + // plugin that never asked for it, so install-time consent still bounds what + // is possible. + await addColumnIfMissing(this, 'plugins', 'operator_egress', `operator_egress INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101025100_settings_page_action_buttons_a_plugin_contributes.ts b/server/src/db/migrations/Migration20200101025100_settings_page_action_buttons_a_plugin_contributes.ts new file mode 100644 index 000000000..9f75f3554 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025100_settings_page_action_buttons_a_plugin_contributes.ts @@ -0,0 +1,26 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 171 (`db/migrations.ts`). + * + * Settings-page action buttons a plugin contributes ("Test connection", "Sync now"). + * Descriptors only — the handler lives in the plugin's code and is invoked host-side + * with the CLICKING user bound, so it can read that user's own settings. + */ +export class Migration20200101025100_settings_page_action_buttons_a_plugin_contributes extends Migration { + override name = 'Migration20200101025100_settings_page_action_buttons_a_plugin_contributes'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS plugin_actions ( + plugin_id TEXT NOT NULL, + action_key TEXT NOT NULL, + label TEXT NOT NULL, + hint TEXT, + danger INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (plugin_id, action_key) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101025200_why_a_plugin_s_update_was_refused.ts b/server/src/db/migrations/Migration20200101025200_why_a_plugin_s_update_was_refused.ts new file mode 100644 index 000000000..cc60d7300 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025200_why_a_plugin_s_update_was_refused.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 172 (`db/migrations.ts`). + * + * Why a plugin's update was REFUSED by the signature check (#plugins). A refused + * update leaves a working plugin pinned at its old version — previously the + * reason lived only in a transient toast, so the plugin quietly stopped updating + * and the admin had to re-attempt an update to rediscover why. Record it instead. + * + * `update_block_version` is the registry version that was refused: once the + * registry offers something NEWER, the block describes an artifact nobody is + * being offered anymore, so it reads as stale and the admin can just re-attempt + * (the next install re-verifies and either succeeds or re-blocks with fresh + * values). Deliberately no `status = 'error'` — the plugin still runs fine on + * its old code. + */ +export class Migration20200101025200_why_a_plugin_s_update_was_refused extends Migration { + override name = 'Migration20200101025200_why_a_plugin_s_update_was_refused'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'update_block_code', `update_block_code TEXT`); + await addColumnIfMissing(this, 'plugins', 'update_block_detail', `update_block_detail TEXT`); + await addColumnIfMissing(this, 'plugins', 'update_block_version', `update_block_version TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101025300_the_semver_range_of_trek_versions_a.ts b/server/src/db/migrations/Migration20200101025300_the_semver_range_of_trek_versions_a.ts new file mode 100644 index 000000000..62c4658cd --- /dev/null +++ b/server/src/db/migrations/Migration20200101025300_the_semver_range_of_trek_versions_a.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 173 (`db/migrations.ts`). + * + * The semver RANGE of TREK versions a plugin declares it supports (its manifest's + * `trek`, e.g. ">=3.2.0 <4.0.0"). The existing `min_trek_version` only carries the + * lower bound, so it cannot express "stops working at 4.0" — which is precisely the + * case the activation gate has to catch after a TREK upgrade. Kept nullable: a plugin + * installed before this column existed has no range recorded, and the gate refuses to + * activate it rather than guessing (see TREK_VERSION_UNKNOWN). + */ +export class Migration20200101025300_the_semver_range_of_trek_versions_a extends Migration { + override name = 'Migration20200101025300_the_semver_range_of_trek_versions_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'trek_range', `trek_range TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101025400_place_regions_is_a_re_derivable_nominatim.ts b/server/src/db/migrations/Migration20200101025400_place_regions_is_a_re_derivable_nominatim.ts new file mode 100644 index 000000000..ec44ba57a --- /dev/null +++ b/server/src/db/migrations/Migration20200101025400_place_regions_is_a_re_derivable_nominatim.ts @@ -0,0 +1,27 @@ +import { execUnlessTableMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 174 (`db/migrations.ts`). + * + * `place_regions` is a re-derivable Nominatim cache, only ever populated for a + * place ID that isn't already cached — so a wrong row, once written, was + * permanent. Region resolution now resolves a place's lat/lng directly against + * the bundled admin1 polygons (the same ones the client renders) instead of + * trusting Nominatim's address level, which could name a subdivision level the + * bundle doesn't carry (Barcelona's ES-B province vs the bundle's ES-CT + * autonomous community) and never highlight. That fix only helps places + * re-resolved after it, so clear the cache once and let every place re-resolve + * on the next Atlas load. The country_code stored alongside is cleared too, + * which also drops the old wrong-country rows a US-state-abbreviation address + * used to produce. + */ +export class Migration20200101025400_place_regions_is_a_re_derivable_nominatim extends Migration { + override name = 'Migration20200101025400_place_regions_is_a_re_derivable_nominatim'; + + override async up(): Promise { + // place_regions is created by an earlier migration; tolerate its absence on + // an unusual partial DB rather than aborting startup. + await execUnlessTableMissing(this, `DELETE FROM place_regions`); + } +} diff --git a/server/src/db/migrations/Migration20200101025500_tombstones_for_atlas_regions_the_user_has.ts b/server/src/db/migrations/Migration20200101025500_tombstones_for_atlas_regions_the_user_has.ts new file mode 100644 index 000000000..c9c61422a --- /dev/null +++ b/server/src/db/migrations/Migration20200101025500_tombstones_for_atlas_regions_the_user_has.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 175 (`db/migrations.ts`). + * + * Tombstones for Atlas regions the user has explicitly removed — the region-level + * counterpart to hidden_countries above (#1490). A visited region is normally derived + * fresh from place_regions/visited_regions on every request, so "removing" it has + * nothing to delete; recording it here lets getVisitedRegions suppress a derived region + * the same way getStats already suppresses a derived country. Unlike the country-level + * tombstone (originally only reachable for a manually-marked or zero-count country), + * this also covers a region derived from real place data — e.g. one that ended up on + * the wrong side of a border-simplification gap and the user just wants gone. + */ +export class Migration20200101025500_tombstones_for_atlas_regions_the_user_has extends Migration { + override name = 'Migration20200101025500_tombstones_for_atlas_regions_the_user_has'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS hidden_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + region_code TEXT NOT NULL, + country_code TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, region_code) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_hidden_regions_user ON hidden_regions (user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101025600_half_vacation_days.ts b/server/src/db/migrations/Migration20200101025600_half_vacation_days.ts new file mode 100644 index 000000000..a7f2964be --- /dev/null +++ b/server/src/db/migrations/Migration20200101025600_half_vacation_days.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 176 (`db/migrations.ts`). + * + * Half vacation days (#552): a vacay entry can now count as a full day (1) or + * a half day (0.5) toward the entitlement. Existing entries default to a full + * day, so the entitlement maths are unchanged for everyone already using vacay. + * Guarded so re-running the migration tail (e.g. the crosswalk test) is a no-op. + */ +export class Migration20200101025600_half_vacation_days extends Migration { + override name = 'Migration20200101025600_half_vacation_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_entries', 'fraction', `fraction REAL NOT NULL DEFAULT 1`); + } +} diff --git a/server/src/db/migrations/Migration20200101025700_read_only_vacay_calendar_sharing.ts b/server/src/db/migrations/Migration20200101025700_read_only_vacay_calendar_sharing.ts new file mode 100644 index 000000000..500a1abe8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025700_read_only_vacay_calendar_sharing.ts @@ -0,0 +1,29 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 177 (`db/migrations.ts`). + * + * Read-only vacay calendar sharing (#444/#667): a user can let other users + * view their vacation calendar without fusing plans. owner_id is the sharing + * user, user_id the viewer; hidden lets the viewer hide the overlay without + * removing the share. Follows the person, not the plan, so it survives + * fusion and dissolution. + */ +export class Migration20200101025700_read_only_vacay_calendar_sharing extends Migration { + override name = 'Migration20200101025700_read_only_vacay_calendar_sharing'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_shares ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + hidden INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE (owner_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_vacay_shares_user ON vacay_shares (user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101025800_collaborative_place_ratings.ts b/server/src/db/migrations/Migration20200101025800_collaborative_place_ratings.ts new file mode 100644 index 000000000..96c4ea004 --- /dev/null +++ b/server/src/db/migrations/Migration20200101025800_collaborative_place_ratings.ts @@ -0,0 +1,42 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 178 (`db/migrations.ts`). + * + * Collaborative place ratings (#1435): every trip member can rate a trip + * place 1-5, every collection member a saved place; the displayed value is + * the average. One row per user and place, mirroring collab_poll_votes. + */ +export class Migration20200101025800_collaborative_place_ratings extends Migration { + override name = 'Migration20200101025800_collaborative_place_ratings'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(place_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_ratings_place ON place_ratings (place_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS collection_place_ratings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_place_id INTEGER NOT NULL REFERENCES collection_places(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rating INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(collection_place_id, user_id) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_collection_place_ratings_place ON collection_place_ratings (collection_place_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101025900_per_segment_travel_mode.ts b/server/src/db/migrations/Migration20200101025900_per_segment_travel_mode.ts new file mode 100644 index 000000000..0fcede2fb --- /dev/null +++ b/server/src/db/migrations/Migration20200101025900_per_segment_travel_mode.ts @@ -0,0 +1,21 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 179 (`db/migrations.ts`). + * + * Per-segment travel mode (#1281): the day-plan route can use a different + * transport mode for each leg. leg_transport_mode on an assignment is the mode + * of the leg LEAVING that stop (NULL = inherit the day default); days gains a + * persisted default_transport_mode so the whole-day choice survives a reload. + * Both nullable → existing itineraries keep today's single-mode behaviour. + */ +export class Migration20200101025900_per_segment_travel_mode extends Migration { + override name = 'Migration20200101025900_per_segment_travel_mode'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'leg_transport_mode', `leg_transport_mode TEXT`); + + await addColumnIfMissing(this, 'days', 'default_transport_mode', `default_transport_mode TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101030000_school_holidays_are_a_visual_vacay_calendar.ts b/server/src/db/migrations/Migration20200101030000_school_holidays_are_a_visual_vacay_calendar.ts new file mode 100644 index 000000000..0590f238e --- /dev/null +++ b/server/src/db/migrations/Migration20200101030000_school_holidays_are_a_visual_vacay_calendar.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 180 (`db/migrations.ts`). + * + * School holidays are a visual Vacay calendar layer. Keep them separate from + * public holidays so applyHolidayCalendars never removes vacation entries for + * school-break dates. + */ +export class Migration20200101030000_school_holidays_are_a_visual_vacay_calendar extends Migration { + override name = 'Migration20200101030000_school_holidays_are_a_visual_vacay_calendar'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'vacay_plans', + 'school_holidays_enabled', + `school_holidays_enabled INTEGER DEFAULT 0`, + ); + await addColumnIfMissing(this, 'vacay_holiday_calendars', 'type', `type TEXT NOT NULL DEFAULT 'public_holiday'`); + } +} diff --git a/server/src/db/migrations/Migration20200101030100_1517.ts b/server/src/db/migrations/Migration20200101030100_1517.ts new file mode 100644 index 000000000..85a6466ed --- /dev/null +++ b/server/src/db/migrations/Migration20200101030100_1517.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 181 (`db/migrations.ts`). + * + * #1517 — assign trip members / named guests to a reservation (mirrors budget_item_members). + */ +export class Migration20200101030100_1517 extends Migration { + override name = 'Migration20200101030100_1517'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS reservation_travelers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + reservation_id INTEGER NOT NULL REFERENCES reservations(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(reservation_id, user_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_reservation_travelers_res ON reservation_travelers(reservation_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_reservation_travelers_user ON reservation_travelers(user_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101030200_comp_flex_days.ts b/server/src/db/migrations/Migration20200101030200_comp_flex_days.ts new file mode 100644 index 000000000..ee464ac9f --- /dev/null +++ b/server/src/db/migrations/Migration20200101030200_comp_flex_days.ts @@ -0,0 +1,17 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 182 (`db/migrations.ts`). + * + * Comp/Flex days (#1074): a vacay entry is either a vacation day (counts toward + * the entitlement) or a comp/flex day (kind='comp', costs 0 — flextime/overtime + * offset). Orthogonal to fraction, so a half comp day is kind='comp' + fraction=0.5. + */ +export class Migration20200101030200_comp_flex_days extends Migration { + override name = 'Migration20200101030200_comp_flex_days'; + + override async up(): Promise { + await addColumnIfMissing(this, 'vacay_entries', 'kind', `kind TEXT NOT NULL DEFAULT 'vacation'`); + } +} diff --git a/server/src/db/migrations/Migration20200101030300_configurable_vacation_year.ts b/server/src/db/migrations/Migration20200101030300_configurable_vacation_year.ts new file mode 100644 index 000000000..d0103718d --- /dev/null +++ b/server/src/db/migrations/Migration20200101030300_configurable_vacation_year.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 183 (`db/migrations.ts`). + * + * Configurable vacation year (#737): per-user leave-year window. 'calendar' keeps + * the Jan 1–Dec 31 default (unchanged for everyone); 'fiscal' starts on a fixed + * month/day; 'anniversary' starts on the month/day of the hire date. The year + * integer still names a period; the service resolves it to a [start,end) range. + */ +export class Migration20200101030300_configurable_vacation_year extends Migration { + override name = 'Migration20200101030300_configurable_vacation_year'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS vacay_user_settings ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + year_type TEXT NOT NULL DEFAULT 'calendar', + year_start_month INTEGER NOT NULL DEFAULT 1, + year_start_day INTEGER NOT NULL DEFAULT 1, + hire_date TEXT + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101030400_manual_gpx_track_colour.ts b/server/src/db/migrations/Migration20200101030400_manual_gpx_track_colour.ts new file mode 100644 index 000000000..cf30cbdce --- /dev/null +++ b/server/src/db/migrations/Migration20200101030400_manual_gpx_track_colour.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 184 (`db/migrations.ts`). + * + * Manual GPX track colour (#776): imported tracks all render in the same blue + * because the importer never assigns a category, so several walks in the same + * area are indistinguishable. NULL keeps the old behaviour (category colour, + * then the #3b82f6 fallback) for every existing row. + */ +export class Migration20200101030400_manual_gpx_track_colour extends Migration { + override name = 'Migration20200101030400_manual_gpx_track_colour'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'route_color', `route_color TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101030500_backfill_the_three_places_feature_toggles_before.ts b/server/src/db/migrations/Migration20200101030500_backfill_the_three_places_feature_toggles_before.ts new file mode 100644 index 000000000..95e3bd883 --- /dev/null +++ b/server/src/db/migrations/Migration20200101030500_backfill_the_three_places_feature_toggles_before.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +const PLACES_TOGGLES = ['places_photos_enabled', 'places_autocomplete_enabled', 'places_details_enabled']; + +/** + * Legacy migration step 185 (`db/migrations.ts`). + * + * Backfill the three places feature toggles before their read flips from + * fail-open (`value !== 'false'`) to fail-closed (`value === 'true'`), so + * existing installs that never touched the admin switches keep the features they + * have today. A row that already says 'false' is left alone. + */ +export class Migration20200101030500_backfill_the_three_places_feature_toggles_before extends Migration { + override name = 'Migration20200101030500_backfill_the_three_places_feature_toggles_before'; + + override up(): void { + for (const key of PLACES_TOGGLES) { + this.addSql(`INSERT OR IGNORE INTO app_settings (key, value) VALUES ('${key}', 'true')`); + } + } +} diff --git a/server/src/db/migrations/Migration20200101030600_free_the_note_column_on_budget_items.ts b/server/src/db/migrations/Migration20200101030600_free_the_note_column_on_budget_items.ts new file mode 100644 index 000000000..2a1a1a7f4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101030600_free_the_note_column_on_budget_items.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 186 (`db/migrations.ts`). + * + * Free the `note` column on budget items for its actual purpose (#1658). The + * costs UI stores an itemized receipt in it as `TICKETJSON:{...}`, which means an + * expense split by receipt can never carry a written note, and saving an expense + * any other way wipes whatever was typed in the budget table. The receipt moves + * to its own column and note becomes text again. + * + * GLOB, not LIKE: LIKE is case-insensitive in SQLite, so a hand-written note + * starting "ticketjson:" would be chopped up and its text dropped. + */ +export class Migration20200101030600_free_the_note_column_on_budget_items extends Migration { + override name = 'Migration20200101030600_free_the_note_column_on_budget_items'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_items', 'ticket_json', `ticket_json TEXT`); + await this.execute(` + UPDATE budget_items + SET ticket_json = substr(note, 12), note = NULL + WHERE note GLOB 'TICKETJSON:*' + `); + } +} diff --git a/server/src/db/migrations/Migration20200101030700_note_colours.ts b/server/src/db/migrations/Migration20200101030700_note_colours.ts new file mode 100644 index 000000000..5b86940d4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101030700_note_colours.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 187 (`db/migrations.ts`). + * + * Note colours (#1629). A day note is a label as much as a reminder — "watch + * out", "must see", "already booked" — and a wall of identical grey cards + * makes that impossible to see at a glance. NULL keeps the neutral card + * every existing note has today. + */ +export class Migration20200101030700_note_colours extends Migration { + override name = 'Migration20200101030700_note_colours'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_notes', 'color', `color TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101030800_per_segment_travel_mode_for_boundary_legs.ts b/server/src/db/migrations/Migration20200101030800_per_segment_travel_mode_for_boundary_legs.ts new file mode 100644 index 000000000..4450d004c --- /dev/null +++ b/server/src/db/migrations/Migration20200101030800_per_segment_travel_mode_for_boundary_legs.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 188 (`db/migrations.ts`). + * + * Per-segment travel mode for boundary legs: a leg whose ORIGIN is not a place + * (booking arrival, morning hotel) stores its mode on the DESTINATION stop. + * NULL = inherit the day default. INERT whenever the previous timeline element + * is itself a place (that place's outgoing leg_transport_mode wins). + * Appended LAST: the array is index-addressed against schema_version, so a slot + * inserted anywhere above this line is simply skipped on every existing database. + */ +export class Migration20200101030800_per_segment_travel_mode_for_boundary_legs extends Migration { + override name = 'Migration20200101030800_per_segment_travel_mode_for_boundary_legs'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'day_assignments', + 'incoming_leg_transport_mode', + `incoming_leg_transport_mode TEXT`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101030900_1298.ts b/server/src/db/migrations/Migration20200101030900_1298.ts new file mode 100644 index 000000000..d6d3a43af --- /dev/null +++ b/server/src/db/migrations/Migration20200101030900_1298.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 189 (`db/migrations.ts`). + * + * #1298 — an expense can hang off a place, exactly as it already hangs off a + * reservation. Same nullable FK, same ON DELETE SET NULL: the delete paths + * take the linked expense with the place themselves, so the constraint is a + * backstop for anything that removes a place without going through them. + * Appended LAST: the array is index-addressed against schema_version, so a + * slot inserted above this line never runs on an existing database. + */ +export class Migration20200101030900_1298 extends Migration { + override name = 'Migration20200101030900_1298'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'budget_items', + 'place_id', + `place_id INTEGER REFERENCES places(id) ON DELETE SET NULL DEFAULT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101031000_a_reservation_day_positions_row_only_makes.ts b/server/src/db/migrations/Migration20200101031000_a_reservation_day_positions_row_only_makes.ts new file mode 100644 index 000000000..63ac9e6b4 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031000_a_reservation_day_positions_row_only_makes.ts @@ -0,0 +1,28 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 190 (`db/migrations.ts`). + * + * A reservation_day_positions row only makes sense when its reservation and + * its day are on the same trip. The table carries no trip_id and its two + * foreign keys only ask that the ids exist, so pairs that never belonged + * together could accumulate; the writer refuses them now, and this clears + * whatever an older build let through. This slot keeps the index it shipped + * with on dev — the array is index-addressed against schema_version. + */ +export class Migration20200101031000_a_reservation_day_positions_row_only_makes extends Migration { + override name = 'Migration20200101031000_a_reservation_day_positions_row_only_makes'; + + override up(): void { + this.addSql(` + DELETE FROM reservation_day_positions + WHERE rowid IN ( + SELECT rdp.rowid + FROM reservation_day_positions rdp + JOIN reservations r ON r.id = rdp.reservation_id + JOIN days d ON d.id = rdp.day_id + WHERE d.trip_id <> r.trip_id + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101031100_1939.ts b/server/src/db/migrations/Migration20200101031100_1939.ts new file mode 100644 index 000000000..918eeef15 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031100_1939.ts @@ -0,0 +1,58 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 191 (`db/migrations.ts`). + * + * #1939 — the Google Places and Unsplash keys are instance configuration and now + * resolve out of app_settings (nest/settings/instance-api-keys.ts). This carries + * the value the old resolver would have handed out — the lowest-id admin who has + * one — into that row, so an install that upgrades keeps the key it was + * searching with instead of falling back to OpenStreetMap. + * + * Only where that key already was the whole install's, though. The resolver + * reads the instance row before the caller's own column, so promoting a personal + * key while a second row still holds one would move that member onto a + * stranger's key and a stranger's bill without anyone saying so. As soon as + * another row has a value for the same column nothing is written: everybody + * keeps resolving what they resolved before, and a member with no key of their + * own searches via OpenStreetMap until an admin saves the instance key once in + * the panel. Handing a key to the whole instance is the admin's call, not a + * migration's. + * + * The users columns are left alone: they are still the per-user fallback, and + * nothing here can lose a value. Stored blobs are copied verbatim; both sides + * use the same apiKeyCrypto format, legacy plaintext included. + */ +export class Migration20200101031100_1939 extends Migration { + override name = 'Migration20200101031100_1939'; + + override async up(): Promise { + for (const column of ['maps_api_key', 'unsplash_api_key'] as const) { + const existing = (await this.execute(`SELECT value FROM app_settings WHERE key = ?`, [column])) as { + value: string | null; + }[]; + if (existing[0]?.value) continue; + + const candidates = (await this.execute( + `SELECT id, ${column} AS value FROM users + WHERE role = 'admin' AND ${column} IS NOT NULL AND ${column} != '' + ORDER BY id ASC LIMIT 1`, + )) as { id: number; value: string }[]; + const row = candidates[0]; + if (!row?.value) continue; + + const otherHolder = await this.execute( + `SELECT 1 AS present FROM users + WHERE id != ? AND ${column} IS NOT NULL AND ${column} != '' LIMIT 1`, + [row.id], + ); + if (otherHolder.length > 0) continue; + + await this.execute( + `INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [column, row.value], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101031200_capture_metadata_on_the_photo_itself.ts b/server/src/db/migrations/Migration20200101031200_capture_metadata_on_the_photo_itself.ts new file mode 100644 index 000000000..73cf521bb --- /dev/null +++ b/server/src/db/migrations/Migration20200101031200_capture_metadata_on_the_photo_itself.ts @@ -0,0 +1,26 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 192 (`db/migrations.ts`). + * + * Capture metadata on the photo itself (#1614): when it was taken and where. + * Both are needed before photos can sit on the Journey map by their own + * coordinates, and before a gallery can be ordered by when a picture was taken + * rather than when it happened to be added. Nullable throughout — most providers + * answer with neither, and a photo without them is normal. + */ +export class Migration20200101031200_capture_metadata_on_the_photo_itself extends Migration { + override name = 'Migration20200101031200_capture_metadata_on_the_photo_itself'; + + override async up(): Promise { + await addColumnIfMissing(this, 'trek_photos', 'taken_at', `taken_at TEXT`); + await addColumnIfMissing(this, 'trek_photos', 'lat', `lat REAL`); + await addColumnIfMissing(this, 'trek_photos', 'lng', `lng REAL`); + + // Answering "which photos of this journey have coordinates" without a scan. + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_trek_photos_geo ON trek_photos(lat, lng) WHERE lat IS NOT NULL AND lng IS NOT NULL`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101031300_a_journey_shared_while_the_trip_is.ts b/server/src/db/migrations/Migration20200101031300_a_journey_shared_while_the_trip_is.ts new file mode 100644 index 000000000..c861df6e6 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031300_a_journey_shared_while_the_trip_is.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 193 (`db/migrations.ts`). + * + * A journey shared while the trip is still running reads like a blog, and a + * blog puts the newest entry first (#1614). The owner decides per share link, + * because it is a property of how the link is meant to be read, not of the + * journey. Off by default: an already-published link must not reorder itself + * under its readers. + */ +export class Migration20200101031300_a_journey_shared_while_the_trip_is extends Migration { + override name = 'Migration20200101031300_a_journey_shared_while_the_trip_is'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_share_tokens', 'newest_first', `newest_first INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101031400_trek_studio_books.ts b/server/src/db/migrations/Migration20200101031400_trek_studio_books.ts new file mode 100644 index 000000000..f87a6f133 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031400_trek_studio_books.ts @@ -0,0 +1,37 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 194 (`db/migrations.ts`). + * + * TREK Studio books (#1973). + * One row per book, the document itself stored as JSON. A book is a + * document rather than a graph of records: the editor loads it whole, the + * renderer prints it whole, and nothing ever queries "which books contain a + * heart-shaped frame". Normalising spreads and elements into tables would + * buy queries nobody makes and cost a join on every open plus a schema + * migration for every new element kind. + * `updated_at` and `version` are what make concurrent editing possible: + * the version increments on every write, and a client that saves against a + * version it did not read gets told rather than silently overwriting. + */ +export class Migration20200101031400_trek_studio_books extends Migration { + override name = 'Migration20200101031400_trek_studio_books'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS journey_books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + document TEXT NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_journey_books_journey ON journey_books(journey_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101031500_great_britain_gained_its_counties_and_boroughs.ts b/server/src/db/migrations/Migration20200101031500_great_britain_gained_its_counties_and_boroughs.ts new file mode 100644 index 000000000..14afbb064 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031500_great_britain_gained_its_counties_and_boroughs.ts @@ -0,0 +1,79 @@ +import { Migration } from '@mikro-orm/migrations'; + +import fs from 'fs'; +import path from 'path'; +import zlib from 'zlib'; + +interface VisitedRegion { + id: number; + region_code: string; + region_name: string; + country_code: string; +} + +interface Admin1Feature { + properties?: { iso_a2?: string; iso_3166_2?: string; name?: string }; +} + +/** + * Legacy migration step 195 (`db/migrations.ts`). + * + * Great Britain gained its counties and boroughs back (#1974). + * + * geoBoundaries' GBR ADM1 is the four constituent countries, so since 3.1.0 the + * atlas had no polygon below "England" and a London borough marked as visited + * had nothing to light up. The bundle now ships GB at ADM2, which is the + * granularity the rest of the world gets and the granularity the pre-3.1.0 + * Natural Earth layer had. + * + * Two things need saying to the data that already exists: + * + * `visited_regions` rows marked before 3.1.0 hold the old Natural Earth borough + * codes. They are reconciled by name within the same country, exactly as the + * 3.1.1 step does for every other country — a row that still cannot be resolved + * is left alone rather than destroyed, and the client's name fallback may well + * still highlight it. + * + * `place_regions` is a re-derivable cache, and it is full of GB-ENG rows that + * were correct under the old bundle. Nothing re-derives a row that is already + * there, so without clearing them every English place would keep reporting + * "England" forever. + */ +export class Migration20200101031500_great_britain_gained_its_counties_and_boroughs extends Migration { + override name = 'Migration20200101031500_great_britain_gained_its_counties_and_boroughs'; + + override async up(): Promise { + await this.execute(`DELETE FROM place_regions WHERE UPPER(COALESCE(country_code, '')) = 'GB'`); + + const rows = (await this.execute( + `SELECT id, region_code, region_name, country_code FROM visited_regions WHERE UPPER(COALESCE(country_code, '')) = 'GB'`, + )) as VisitedRegion[]; + if (rows.length === 0) return; + + let features: Admin1Feature[]; + try { + const file = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz'); + features = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')).features || []; + } catch { + return; // bundle unreadable — leave the rows untouched rather than guessing + } + + const validCodes = new Set(); + const nameToCode = new Map(); + for (const f of features) { + const p = f.properties || {}; + if ((p.iso_a2 || '').toUpperCase() !== 'GB' || !p.iso_3166_2) continue; + validCodes.add(p.iso_3166_2); + if (p.name) nameToCode.set(p.name.toLowerCase(), p.iso_3166_2); + } + if (validCodes.size === 0) return; + + for (const row of rows) { + if (validCodes.has(row.region_code)) continue; + const byName = nameToCode.get((row.region_name || '').toLowerCase()); + if (byName) { + await this.execute(`UPDATE OR IGNORE visited_regions SET region_code = ? WHERE id = ?`, [byName, row.id]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101031600_storage_slice_2.ts b/server/src/db/migrations/Migration20200101031600_storage_slice_2.ts new file mode 100644 index 000000000..bf8ec5eb1 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031600_storage_slice_2.ts @@ -0,0 +1,25 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 196 (`db/migrations.ts`). + * + * Storage slice 2 — collab note attachments historically stored 'files/' + * in trip_files.filename while the file manager stored bare names in the same + * column; the storage layer addresses objects as category + bare name, so + * normalize the legacy rows. substr is 1-indexed: 7 drops the six chars of + * 'files/'. The LIKE guard makes it a no-op on already-bare rows. + * Appended LAST again, and for the same reason as the note this replaces: + * the array is index-addressed against schema_version, so a slot that has + * shipped in dev keeps the index it shipped with and anything from this + * branch goes after it. A database that already ran this migration at its + * pre-merge index replays it harmlessly (the LIKE guard) but skips whatever + * now occupies that index. Acceptable only because this branch has never + * been published; never do this with a released slot. + */ +export class Migration20200101031600_storage_slice_2 extends Migration { + override name = 'Migration20200101031600_storage_slice_2'; + + override up(): void { + this.addSql(`UPDATE trip_files SET filename = substr(filename, 7) WHERE filename LIKE 'files/%'`); + } +} diff --git a/server/src/db/migrations/Migration20200101031700_give_back_the_notes_the_ticketjson_step.ts b/server/src/db/migrations/Migration20200101031700_give_back_the_notes_the_ticketjson_step.ts new file mode 100644 index 000000000..e646fb9a8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101031700_give_back_the_notes_the_ticketjson_step.ts @@ -0,0 +1,43 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 197 (`db/migrations.ts`). + * + * Give back the notes the TICKETJSON step chopped up (#1658). + * + * That step matched with LIKE, which is case-insensitive in SQLite, so a note + * somebody had typed starting "ticketjson:" was read as a receipt: the first + * eleven characters were dropped, the rest was moved into ticket_json and the + * note was set to NULL. The step now matches with GLOB, but that only helps a + * fresh install — the legacy array was index-addressed against schema_version, + * so a repaired step never ran again on a database that had already applied it. + * + * A receipt comes out of JSON.stringify and always parses; typed text does not. + * So a row moves back only when its ticket_json fails to parse AND the note is + * still empty — anything that parses, and any row someone has written a note on + * since, is left exactly as it is. The eleven marker characters themselves are + * gone for good: their case was the only thing separating a receipt from a note, + * and writing "TICKETJSON:" back would hand the row straight to the reader that + * reads that prefix as a receipt. + */ +export class Migration20200101031700_give_back_the_notes_the_ticketjson_step extends Migration { + override name = 'Migration20200101031700_give_back_the_notes_the_ticketjson_step'; + + override async up(): Promise { + const rows = (await this.execute( + `SELECT id, ticket_json FROM budget_items + WHERE ticket_json IS NOT NULL AND ticket_json != '' AND COALESCE(note, '') = ''`, + )) as { id: number; ticket_json: string }[]; + + for (const row of rows) { + try { + JSON.parse(row.ticket_json); + } catch { + await this.execute(`UPDATE budget_items SET note = ?, ticket_json = NULL WHERE id = ?`, [ + row.ticket_json, + row.id, + ]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101031800_a_deliberate_non_latest_plugin_install_sets.ts b/server/src/db/migrations/Migration20200101031800_a_deliberate_non_latest_plugin_install_sets.ts new file mode 100644 index 000000000..6b2ff866f --- /dev/null +++ b/server/src/db/migrations/Migration20200101031800_a_deliberate_non_latest_plugin_install_sets.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 198 (`db/migrations.ts`). + * + * A deliberate non-latest plugin install sets `update_hold`: the row leaves the + * update banner and "Update all" until the admin resumes updates, or until an + * install lands back on the newest compatible version. Only an EXPLICIT version + * pick ever sets it — dependency resolution pins versions too, but never + * deliberately. Appended LAST — the array is index-addressed against schema_version. + */ +export class Migration20200101031800_a_deliberate_non_latest_plugin_install_sets extends Migration { + override name = 'Migration20200101031800_a_deliberate_non_latest_plugin_install_sets'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugins', 'update_hold', `update_hold INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101031900_reservations_an_automated_ingest_parked_for_review.ts b/server/src/db/migrations/Migration20200101031900_reservations_an_automated_ingest_parked_for_review.ts new file mode 100644 index 000000000..ce8de24ec --- /dev/null +++ b/server/src/db/migrations/Migration20200101031900_reservations_an_automated_ingest_parked_for_review.ts @@ -0,0 +1,22 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 199 (`db/migrations.ts`). + * + * Reservations an automated ingest parked for review are 'staged' and stay out + * of the two anonymous exports (ICS feed, shared trip) until a person confirms + * them. Nothing writes 'staged' yet; this is the gate the mail ingest writes + * through. + * + * 'live' as the default is what keeps this from being a breaking change: SQLite + * fills every existing row on the ALTER, and no current writer names the column, + * so every booking that is visible today stays visible. + */ +export class Migration20200101031900_reservations_an_automated_ingest_parked_for_review extends Migration { + override name = 'Migration20200101031900_reservations_an_automated_ingest_parked_for_review'; + + override async up(): Promise { + await addColumnIfMissing(this, 'reservations', 'ingest_state', `ingest_state TEXT NOT NULL DEFAULT 'live'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032000_separate_an_integration_key_from_an_mcp.ts b/server/src/db/migrations/Migration20200101032000_separate_an_integration_key_from_an_mcp.ts new file mode 100644 index 000000000..9d19028f2 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032000_separate_an_integration_key_from_an_mcp.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 200 (`db/migrations.ts`). + * + * Separate an integration key from an MCP token (#2089). + * Both live in mcp_tokens and until now both opened everything a token can + * open. That was fine while /mcp was the only consumer; with a public REST + * surface it means a key somebody minted for a chat client also reads their + * trips over HTTP, and a key minted for an integration can drive every MCP + * tool. One credential, two very different blast radii. + * `kind` splits them, and every existing row becomes 'mcp' — that is what + * they were issued for, and silently widening a key that is already in + * somebody's config would be the opposite of what this migration is for. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032000_separate_an_integration_key_from_an_mcp extends Migration { + override name = 'Migration20200101032000_separate_an_integration_key_from_an_mcp'; + + override async up(): Promise { + await addColumnIfMissing(this, 'mcp_tokens', 'kind', `kind TEXT NOT NULL DEFAULT 'mcp'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032100_naver_list_import_was_typed_trip_but.ts b/server/src/db/migrations/Migration20200101032100_naver_list_import_was_typed_trip_but.ts new file mode 100644 index 000000000..2bf2947ea --- /dev/null +++ b/server/src/db/migrations/Migration20200101032100_naver_list_import_was_typed_trip_but.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 201 (`db/migrations.ts`). + * + * `naver_list_import` was typed 'trip', but a trip addon is one that earns its + * own tab inside a trip — this one has no tab (it is not in tripTabs.ts) and no + * page. It calls an external service to pull places into the sidebar, which is + * exactly what 'integration' means here. + * + * The type is presentational: only `type === 'global'` is read anywhere (client + * navItems.ts), so this moves the tile between admin groups and changes nothing + * about how the import behaves. + */ +export class Migration20200101032100_naver_list_import_was_typed_trip_but extends Migration { + override name = 'Migration20200101032100_naver_list_import_was_typed_trip_but'; + + override up(): void { + this.addSql(`UPDATE addons SET type = 'integration' WHERE id = 'naver_list_import'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032200_whether_a_journey_s_map_draws_the.ts b/server/src/db/migrations/Migration20200101032200_whether_a_journey_s_map_draws_the.ts new file mode 100644 index 000000000..2d9520e11 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032200_whether_a_journey_s_map_draws_the.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 202 (`db/migrations.ts`). + * + * Whether a journey's map draws the GPX tracks of the trips behind it (#2194). + * #1260 added those tracks unconditionally and with nothing to switch off, + * on the reasoning that a route imported into a linked trip is part of the + * journey's story. For a trip carrying a season of recorded drives it is + * instead a map nobody asked for, drawn from places that never became an + * entry — so it becomes a journey-level setting. + * DEFAULT 0, i.e. off: the tracks are opt-in from here on. That is a + * deliberate behaviour change rather than a preserved default — a journal + * should show what its author put in it, and #1260's set is everything the + * linked trips happen to contain. Owners who want them back have one + * switch in Journey Settings. + */ +export class Migration20200101032200_whether_a_journey_s_map_draws_the extends Migration { + override name = 'Migration20200101032200_whether_a_journey_s_map_draws_the'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journeys', 'show_trip_tracks', `show_trip_tracks INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101032300_settings_field_defaults.ts b/server/src/db/migrations/Migration20200101032300_settings_field_defaults.ts new file mode 100644 index 000000000..fab647aaf --- /dev/null +++ b/server/src/db/migrations/Migration20200101032300_settings_field_defaults.ts @@ -0,0 +1,19 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 203 (`db/migrations.ts`). + * + * Settings-field defaults (#plugins, PR-87 feedback). A manifest `default` is the + * field's effective value when nothing is stored — the settings form pre-fills it and + * the runtime resolves it (settings-defaults.ts); it was previously accepted by the + * manifest and silently dropped here. JSON-encoded so string/number/boolean round-trip. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032300_settings_field_defaults extends Migration { + override name = 'Migration20200101032300_settings_field_defaults'; + + override async up(): Promise { + await addColumnIfMissing(this, 'plugin_settings_fields', 'default_value', `default_value TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101032400_settings_form_actions_gain_a_scope.ts b/server/src/db/migrations/Migration20200101032400_settings_form_actions_gain_a_scope.ts new file mode 100644 index 000000000..6073ad8ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101032400_settings_form_actions_gain_a_scope.ts @@ -0,0 +1,21 @@ +import { addColumnIfMissing, columnNames } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 204 (`db/migrations.ts`). + * + * Settings-form actions gain a scope (#plugins): 'user' renders on the user + * Settings tab, 'instance' in the admin instance-settings dialog. Existing rows + * predate the column and were all user-tab buttons, so the default keeps them + * where they were. + */ +export class Migration20200101032400_settings_form_actions_gain_a_scope extends Migration { + override name = 'Migration20200101032400_settings_form_actions_gain_a_scope'; + + override async up(): Promise { + // An empty column list means the table does not exist on this database. + const names = await columnNames(this, 'plugin_actions'); + if (names.size === 0) return; + await addColumnIfMissing(this, 'plugin_actions', 'scope', `scope TEXT NOT NULL DEFAULT 'user'`); + } +} diff --git a/server/src/db/migrations/Migration20200101032500_a_journal_entry_that_is_not_a.ts b/server/src/db/migrations/Migration20200101032500_a_journal_entry_that_is_not_a.ts new file mode 100644 index 000000000..751ec3b77 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032500_a_journal_entry_that_is_not_a.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 205 (`db/migrations.ts`). + * + * A journal entry that is not a stop (discussion #2064). + * Studio draws its route and prints its distance from every entry that + * carries coordinates, and that is right until the journal starts at the + * home airport: the night before the flight, the stopover, the place the + * trip was planned from all become stops, and the distance counts the legs + * to and from them. The traveller knows which of those are the journey and + * which are the way there, so the switch sits on the entry. The entry stays + * in the journal; it is only left out of the arithmetic. + * DEFAULT 0: every existing entry keeps counting, which is what it did. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032500_a_journal_entry_that_is_not_a extends Migration { + override name = 'Migration20200101032500_a_journal_entry_that_is_not_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'stats_excluded', `stats_excluded INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101032600_place_shadow_log_which_search_result_a.ts b/server/src/db/migrations/Migration20200101032600_place_shadow_log_which_search_result_a.ts new file mode 100644 index 000000000..d06f4b20b --- /dev/null +++ b/server/src/db/migrations/Migration20200101032600_place_shadow_log_which_search_result_a.ts @@ -0,0 +1,40 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 206 (`db/migrations.ts`). + * + * Place shadow log: which search result a user actually picked. + * The corpus behind "would our own index have found that too". No user id, + * no trip, no session and no result list — the evaluation compares a query + * against a pick, and anything beyond that would be collecting for its own + * sake on an instance that promises not to. + * The table is created regardless of the switch: the switch decides whether + * rows are written, and a schema that appears only when a feature is + * enabled is a schema that differs between installs. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032600_place_shadow_log_which_search_result_a extends Migration { + override name = 'Migration20200101032600_place_shadow_log_which_search_result_a'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS place_shadow_picks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + query TEXT NOT NULL, + lang TEXT, + bias_lat REAL, + bias_lng REAL, + source TEXT NOT NULL, + live_rank INTEGER NOT NULL, + live_count INTEGER NOT NULL, + picked_name TEXT NOT NULL, + picked_lat REAL NOT NULL, + picked_lng REAL NOT NULL, + picked_place_id TEXT + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_place_shadow_created ON place_shadow_picks(created_at)`); + } +} diff --git a/server/src/db/migrations/Migration20200101032700_what_kind_of_stop_a_place_is.ts b/server/src/db/migrations/Migration20200101032700_what_kind_of_stop_a_place_is.ts new file mode 100644 index 000000000..c7f6c6d2c --- /dev/null +++ b/server/src/db/migrations/Migration20200101032700_what_kind_of_stop_a_place_is.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 207 (`db/migrations.ts`). + * + * What kind of stop a place is on a drive — fuel, charging, rest area, campsite. + * Deliberately NOT a `categories` row. Those are the traveller's own list, editable + * and instance-wide (`categories.service.ts` selects them without a user filter), so + * seeding four road-trip kinds there would push them into everyone's dropdown and hand + * their colour to whoever edits the list first. A refuelling stop is not a taste; it is + * a fact about the place, and the road-trip categories already own its icon and colour + * (`poiCategories.ts`). + * Free text rather than a CHECK constraint: the set grows with what the corridor + * search can look for, and SQLite cannot alter a constraint without rebuilding the + * table. NULL means an ordinary place, which is every row that exists today. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032700_what_kind_of_stop_a_place_is extends Migration { + override name = 'Migration20200101032700_what_kind_of_stop_a_place_is'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'stop_type', `stop_type TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101032800_points_a_day_s_drive_is_made.ts b/server/src/db/migrations/Migration20200101032800_points_a_day_s_drive_is_made.ts new file mode 100644 index 000000000..0c79f8750 --- /dev/null +++ b/server/src/db/migrations/Migration20200101032800_points_a_day_s_drive_is_made.ts @@ -0,0 +1,38 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 208 (`db/migrations.ts`). + * + * Points a day's drive is made to pass through, without being stops (#1797). + * The difference is the whole point: a stop is somewhere you go, and it takes a + * number in the chain, a place row, an arrival time and a line in the itinerary. A + * via is none of that — it only bends the route, which is what "take the coast road + * instead" means. Storing one as a place was the alternative, and it would have put a + * numbered stop in the middle of the day for a spot nobody stops at. + * Anchored to `after_order_index` rather than to an assignment id: a stop added + * mid-day is written with a temporary negative id and swapped for the real one moments + * later, so a foreign key to it would dangle. The index is what the routing request is + * built from anyway. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032800_points_a_day_s_drive_is_made extends Migration { + override name = 'Migration20200101032800_points_a_day_s_drive_is_made'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS roadtrip_vias ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE, + after_order_index INTEGER NOT NULL, + sequence INTEGER NOT NULL DEFAULT 0, + lat REAL NOT NULL, + lng REAL NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_roadtrip_vias_day ON roadtrip_vias(day_id, after_order_index, sequence)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101032900_which_imported_track_a_day_s_drive.ts b/server/src/db/migrations/Migration20200101032900_which_imported_track_a_day_s_drive.ts new file mode 100644 index 000000000..3e0a2e96d --- /dev/null +++ b/server/src/db/migrations/Migration20200101032900_which_imported_track_a_day_s_drive.ts @@ -0,0 +1,33 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 209 (`db/migrations.ts`). + * + * Which imported track a day's drive was fitted to. + * The vias alone already make the day follow it, so this stores nothing the drive + * needs — it stores what the traveller needs: the name of the road they chose, still + * on the day after a reload, and something to re-fit against when the stops change. + * One row per day, hence `day_id` as the key: a day follows one road or none. The + * cascade on `place_id` is the point of the foreign key — delete the imported track + * and the label goes with it, rather than leaving a day claiming to follow a line + * nobody can see any more. The vias it laid down stay, because they are the drive. + * `stray_km` is how far the fitted route still ran from the track at its worst point, + * kept so the day can say how good a fit it is without routing again. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101032900_which_imported_track_a_day_s_drive extends Migration { + override name = 'Migration20200101032900_which_imported_track_a_day_s_drive'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS roadtrip_day_tracks ( + day_id INTEGER PRIMARY KEY REFERENCES days(id) ON DELETE CASCADE, + place_id INTEGER NOT NULL REFERENCES places(id) ON DELETE CASCADE, + stray_km REAL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_roadtrip_day_tracks_place ON roadtrip_day_tracks(place_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033000_rename_the_misnamed_guangdong_province.ts b/server/src/db/migrations/Migration20200101033000_rename_the_misnamed_guangdong_province.ts new file mode 100644 index 000000000..777db83ef --- /dev/null +++ b/server/src/db/migrations/Migration20200101033000_rename_the_misnamed_guangdong_province.ts @@ -0,0 +1,45 @@ +import { Migration } from '@mikro-orm/migrations'; + +const OLD_MATCH = `(region_code = 'CN-GUANGZHOUPROVINCE' OR region_name = 'Guangzhou Province')`; + +/** + * Legacy migration step 210 (`db/migrations.ts`). + * + * Rename the misnamed Guangdong Province (shipped as "Guangzhou Province"). + * + * geoBoundaries labelled the whole province with the name of its capital, so + * every row a user collected under it carries the wrong code. All three tables + * that key on a region are moved over: the two per-user ones with an + * `UPDATE OR IGNORE` plus a `DELETE`, because a user who already holds the + * correct region would otherwise hit the unique index and keep a duplicate, and + * `place_regions` with a plain UPDATE, because place_id is its primary key and + * nothing there can collide. + */ +export class Migration20200101033000_rename_the_misnamed_guangdong_province extends Migration { + override name = 'Migration20200101033000_rename_the_misnamed_guangdong_province'; + + override up(): void { + this.addSql(` + UPDATE OR IGNORE visited_regions + SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' + WHERE UPPER(country_code) = 'CN' AND ${OLD_MATCH} + `); + this.addSql(`DELETE FROM visited_regions WHERE UPPER(country_code) = 'CN' AND ${OLD_MATCH}`); + + this.addSql(` + UPDATE OR IGNORE place_regions + SET region_code = 'CN-GUANGDONGPROVINCE', region_name = 'Guangdong Province' + WHERE UPPER(country_code) = 'CN' AND ${OLD_MATCH} + `); + + // hidden_regions is the other direction: it remembers which derived region a + // user switched off. Left behind, the tombstone stops matching and the region + // a user deliberately hid comes back. + this.addSql(` + UPDATE OR IGNORE hidden_regions + SET region_code = 'CN-GUANGDONGPROVINCE' + WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE' + `); + this.addSql(`DELETE FROM hidden_regions WHERE UPPER(country_code) = 'CN' AND region_code = 'CN-GUANGZHOUPROVINCE'`); + } +} diff --git a/server/src/db/migrations/Migration20200101033100_let_a_file_hang_off_an_expense.ts b/server/src/db/migrations/Migration20200101033100_let_a_file_hang_off_an_expense.ts new file mode 100644 index 000000000..231eb95bc --- /dev/null +++ b/server/src/db/migrations/Migration20200101033100_let_a_file_hang_off_an_expense.ts @@ -0,0 +1,38 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 211 (`db/migrations.ts`). + * + * Let a file hang off an expense, so a receipt or an invoice can be attached to + * what it paid for. + * + * A column on `file_links` rather than a table of its own: the row already ties + * one file to one thing, and every other attachment kind is a column here too. + * The unique index stops the same receipt being linked twice; SQLite treats + * NULLs as distinct, so the rows that exist today, which all carry a NULL here, + * do not collide with each other. + * + * SET NULL rather than CASCADE, unlike the three columns beside it, because + * those predate the shared row: one row can carry a place link AND a receipt + * link for the same file, and a cascade would delete the whole row when the + * expense goes, silently detaching the file from the place as well. Deleting the + * expense drops the receipt link and nothing else; the budget service removes + * the row afterwards when it carries no other link. + */ +export class Migration20200101033100_let_a_file_hang_off_an_expense extends Migration { + override name = 'Migration20200101033100_let_a_file_hang_off_an_expense'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'file_links', + 'budget_item_id', + `budget_item_id INTEGER REFERENCES budget_items(id) ON DELETE SET NULL`, + ); + await this.execute( + `CREATE UNIQUE INDEX IF NOT EXISTS idx_file_links_file_budget ON file_links(file_id, budget_item_id)`, + ); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_file_links_budget_item_id ON file_links(budget_item_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033200_which_chat_message_an_uploaded_image_belongs.ts b/server/src/db/migrations/Migration20200101033200_which_chat_message_an_uploaded_image_belongs.ts new file mode 100644 index 000000000..6a1c0e49c --- /dev/null +++ b/server/src/db/migrations/Migration20200101033200_which_chat_message_an_uploaded_image_belongs.ts @@ -0,0 +1,27 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 212 (`db/migrations.ts`). + * + * Which chat message an uploaded image belongs to. + * + * A column on `trip_files` rather than a link row, matching the two that predate + * it: a chat image is uploaded for exactly one message and dies with it, so the + * cascade is the whole relationship. Guarded through pragma_table_info like + * every other column add here, not through a caught "duplicate column name": + * that swallows the next error too. + */ +export class Migration20200101033200_which_chat_message_an_uploaded_image_belongs extends Migration { + override name = 'Migration20200101033200_which_chat_message_an_uploaded_image_belongs'; + + override async up(): Promise { + await addColumnIfMissing( + this, + 'trip_files', + 'message_id', + `message_id INTEGER REFERENCES collab_messages(id) ON DELETE CASCADE`, + ); + await this.execute(`CREATE INDEX IF NOT EXISTS idx_trip_files_message_id ON trip_files(message_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033300_links_somebody_shared_with_the_trip.ts b/server/src/db/migrations/Migration20200101033300_links_somebody_shared_with_the_trip.ts new file mode 100644 index 000000000..2bf7d0256 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033300_links_somebody_shared_with_the_trip.ts @@ -0,0 +1,32 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 213 (`db/migrations.ts`). + * + * Links somebody shared with the trip. + * Its own table rather than a note with a URL in it: a link is pinned, + * ordered and opened, and none of that is what a note does. `user_id` is who + * shared it, so the list can say so and so a member leaving takes their rows + * with them. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101033300_links_somebody_shared_with_the_trip extends Migration { + override name = 'Migration20200101033300_links_somebody_shared_with_the_trip'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS collab_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + url TEXT NOT NULL, + pinned INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_collab_links_trip ON collab_links(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033400_route_usage_counters.ts b/server/src/db/migrations/Migration20200101033400_route_usage_counters.ts new file mode 100644 index 000000000..6e3f6d4de --- /dev/null +++ b/server/src/db/migrations/Migration20200101033400_route_usage_counters.ts @@ -0,0 +1,36 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 214 (`db/migrations.ts`). + * + * Route usage counters — how much routing this instance really does. + * Daily aggregates, not a log: one row per day, profile, surface and engine + * kind, carrying totals. No query, no coordinate, no route, no user, no trip. + * The question they answer is whether TREK could host a router itself, and + * that needs volume, not itineraries. + * The table is created regardless of the switch, like the shadow log above: + * the switch decides whether rows are written, and a schema that appears only + * when a feature is on is a schema that differs between installs. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101033400_route_usage_counters extends Migration { + override name = 'Migration20200101033400_route_usage_counters'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS route_usage_daily ( + day TEXT NOT NULL, + profile TEXT NOT NULL, + surface TEXT NOT NULL, + self_hosted INTEGER NOT NULL, + requests INTEGER NOT NULL DEFAULT 0, + waypoints INTEGER NOT NULL DEFAULT 0, + km REAL NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (day, profile, surface, self_hosted) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_route_usage_day ON route_usage_daily(day)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033500_how_full_this_stop_fills_the_tank.ts b/server/src/db/migrations/Migration20200101033500_how_full_this_stop_fills_the_tank.ts new file mode 100644 index 000000000..3c3115f66 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033500_how_full_this_stop_fills_the_tank.ts @@ -0,0 +1,23 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 215 (`db/migrations.ts`). + * + * How full THIS stop fills the tank, 1 to 100 (#1797). + * Beside stop_type rather than in the traveller's settings, because it is a property + * of the stop and not of the person: a motorway rapid charger gets 80 % because the + * last fifth would cost as long again, while the one at the hotel gets 100 % because + * the car stands there all night. One figure for the whole trip cannot say both, and + * the difference between them is a leg. + * NULL means "whatever the traveller's own setting says", which is every row that + * exists today and every stop nobody has an opinion about. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101033500_how_full_this_stop_fills_the_tank extends Migration { + override name = 'Migration20200101033500_how_full_this_stop_fills_the_tank'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'fill_percent', `fill_percent INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101033600_create_school_holiday_countries.ts b/server/src/db/migrations/Migration20200101033600_create_school_holiday_countries.ts new file mode 100644 index 000000000..f02f4d097 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033600_create_school_holiday_countries.ts @@ -0,0 +1,36 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 216 (`db/migrations.ts`). + */ +export class Migration20200101033600_create_school_holiday_countries extends Migration { + override name = 'Migration20200101033600_create_school_holiday_countries'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS school_holiday_countries ( + code TEXT PRIMARY KEY, name TEXT NOT NULL + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS school_holiday_regions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + country TEXT NOT NULL REFERENCES school_holiday_countries(code), + name TEXT NOT NULL COLLATE NOCASE, revision INTEGER NOT NULL DEFAULT 1, + UNIQUE(country, name) + ) + `); + + this.addSql(` + CREATE TABLE IF NOT EXISTS school_holiday_periods ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + region_id INTEGER NOT NULL REFERENCES school_holiday_regions(id) ON DELETE CASCADE, + name TEXT NOT NULL, start_date TEXT NOT NULL, end_date TEXT NOT NULL, + CHECK (end_date >= start_date) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_school_holiday_periods_region ON school_holiday_periods(region_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101033700_day_assignments_add_end_day.ts b/server/src/db/migrations/Migration20200101033700_day_assignments_add_end_day.ts new file mode 100644 index 000000000..79a868080 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033700_day_assignments_add_end_day.ts @@ -0,0 +1,13 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 217 (`db/migrations.ts`). + */ +export class Migration20200101033700_day_assignments_add_end_day extends Migration { + override name = 'Migration20200101033700_day_assignments_add_end_day'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'end_day', `end_day INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101033800_create_roadtrip_day_boundaries.ts b/server/src/db/migrations/Migration20200101033800_create_roadtrip_day_boundaries.ts new file mode 100644 index 000000000..bfff4f9ac --- /dev/null +++ b/server/src/db/migrations/Migration20200101033800_create_roadtrip_day_boundaries.ts @@ -0,0 +1,21 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 218 (`db/migrations.ts`). + */ +export class Migration20200101033800_create_roadtrip_day_boundaries extends Migration { + override name = 'Migration20200101033800_create_roadtrip_day_boundaries'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS roadtrip_day_boundaries ( + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL CHECK (day_number BETWEEN 1 AND 366), + from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), + PRIMARY KEY (trip_id, day_number) + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101033900_create_roadtrip_preferences.ts b/server/src/db/migrations/Migration20200101033900_create_roadtrip_preferences.ts new file mode 100644 index 000000000..691c5e469 --- /dev/null +++ b/server/src/db/migrations/Migration20200101033900_create_roadtrip_preferences.ts @@ -0,0 +1,24 @@ +import { Migration } from '@mikro-orm/migrations'; +import { ROADTRIP_PREFERENCE_KEYS } from '@trek/shared'; + +/** + * Legacy migration step 219 (`db/migrations.ts`). + * + * Per-trip road-trip preferences, seeded from whatever the trip owner had set + * globally so an existing trip keeps behaving the way it did. + */ +export class Migration20200101033900_create_roadtrip_preferences extends Migration { + override name = 'Migration20200101033900_create_roadtrip_preferences'; + + override async up(): Promise { + await this.execute( + `CREATE TABLE IF NOT EXISTS roadtrip_preferences (trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (trip_id, key))`, + ); + for (const key of ROADTRIP_PREFERENCE_KEYS) { + await this.execute( + `INSERT OR IGNORE INTO roadtrip_preferences (trip_id, key, value) SELECT t.id, s.key, s.value FROM trips t JOIN settings s ON s.user_id = t.user_id WHERE s.key = ? AND s.value IS NOT NULL`, + [key], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101034000_a_settle_up_payment_s_date_was.ts b/server/src/db/migrations/Migration20200101034000_a_settle_up_payment_s_date_was.ts new file mode 100644 index 000000000..01e3bd0a8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034000_a_settle_up_payment_s_date_was.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 220 (`db/migrations.ts`). + * + * A settle-up payment's date was silently `created_at` (when it was recorded), + * not editable like a regular expense's `expense_date`. Add the same split: + * settled_at is the calendar day the transfer actually happened, independent + * of when someone got around to logging it. NULL on legacy rows and rows + * whose caller didn't set it; the read side falls back to created_at's date. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101034000_a_settle_up_payment_s_date_was extends Migration { + override name = 'Migration20200101034000_a_settle_up_payment_s_date_was'; + + override async up(): Promise { + await addColumnIfMissing(this, 'budget_settlements', 'settled_at', `settled_at TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034100_amap.ts b/server/src/db/migrations/Migration20200101034100_amap.ts new file mode 100644 index 000000000..5ff3e190b --- /dev/null +++ b/server/src/db/migrations/Migration20200101034100_amap.ts @@ -0,0 +1,29 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 221 (`db/migrations.ts`). + * + * Amap (高德) as a keyed places provider. + * + * `users.amap_api_key` has the same shape and role as maps_api_key: encrypted + * with apiKeyCrypto, and the last step of the resolver after the operator env + * var and the instance-wide app_settings row. Nullable with no default, because + * "this install does not use Amap" is the correct state for almost everybody. + * Nothing is backfilled: a Google key is not an Amap key, and the two are chosen + * by the places_provider setting, not by which column happens to be populated. + * + * `places.amap_poi_id` is the provider id a place was found by, beside + * google_place_id and osm_id. Amap ids are bare strings shaped like Google ones, + * so the column holds them with the `amap:` prefix the maps domain uses + * everywhere, and a place keeps opening against Amap after the admin switches + * provider. + */ +export class Migration20200101034100_amap extends Migration { + override name = 'Migration20200101034100_amap'; + + override async up(): Promise { + await addColumnIfMissing(this, 'users', 'amap_api_key', `amap_api_key TEXT`); + await addColumnIfMissing(this, 'places', 'amap_poi_id', `amap_poi_id TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034200_dawarich_connection.ts b/server/src/db/migrations/Migration20200101034200_dawarich_connection.ts new file mode 100644 index 000000000..af41085a3 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034200_dawarich_connection.ts @@ -0,0 +1,33 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 222 (`db/migrations.ts`). + * + * Dawarich connection (#2279). Its own table rather than more `users` columns, + * which is where the AirTrail connection lives: that was the right call while + * AirTrail was the only integration of this shape, and this is the second. It + * also carries sync state (cursor, last error, probed capabilities) that has no + * business sitting on the identity row. Credentials for a personal location + * archive stay strictly apart from anything shared on a trip. + */ +export class Migration20200101034200_dawarich_connection extends Migration { + override name = 'Migration20200101034200_dawarich_connection'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS dawarich_connections ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + url TEXT, + api_key TEXT, + allow_insecure_tls INTEGER NOT NULL DEFAULT 0, + sync_enabled INTEGER NOT NULL DEFAULT 1, + last_sync_at TEXT, + last_sync_state TEXT NOT NULL DEFAULT 'never', + last_sync_error TEXT, + capabilities TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); + } +} diff --git a/server/src/db/migrations/Migration20200101034300_reviewed_visit_suggestions.ts b/server/src/db/migrations/Migration20200101034300_reviewed_visit_suggestions.ts new file mode 100644 index 000000000..a523bedf6 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034300_reviewed_visit_suggestions.ts @@ -0,0 +1,58 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 223 (`db/migrations.ts`). + * + * Reviewed visit suggestions (#2279). One row per (user, Dawarich visit id) — + * the UNIQUE is what makes a repeated sync idempotent instead of duplicating + * everything it already imported. + * `source_hash` exists because a Dawarich visit carries no `updated_at`: a + * rename or a re-detection is only visible as a different hash of the fields + * TREK shows. `source_missing_at` exists because deleting a visit removes it + * from the API rather than tombstoning it, so absence from a full re-read of + * the same window is the only signal — and it is recorded, not acted on, + * because an entry the user already accepted and edited is theirs now. + */ +export class Migration20200101034300_reviewed_visit_suggestions extends Migration { + override name = 'Migration20200101034300_reviewed_visit_suggestions'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS dawarich_visit_suggestions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + source_visit_id TEXT NOT NULL, + trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL, + name TEXT NOT NULL, + lat REAL, + lng REAL, + started_at TEXT NOT NULL, + ended_at TEXT NOT NULL, + duration_minutes INTEGER NOT NULL DEFAULT 0, + local_date TEXT NOT NULL, + source_status TEXT NOT NULL DEFAULT 'suggested', + confidence REAL, + confidence_band TEXT, + country_code TEXT, + state TEXT NOT NULL DEFAULT 'new', + target TEXT, + accepted_place_id INTEGER REFERENCES places(id) ON DELETE SET NULL, + accepted_journal_entry_id INTEGER, + accepted_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, + matched_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL, + source_hash TEXT NOT NULL, + accepted_hash TEXT, + source_missing_at TEXT, + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, source_visit_id) + ) + `); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_user_state ON dawarich_visit_suggestions(user_id, state)`, + ); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_trip ON dawarich_visit_suggestions(trip_id)`); + } +} diff --git a/server/src/db/migrations/Migration20200101034400_bucket_list_entries_can_now_be_ticked.ts b/server/src/db/migrations/Migration20200101034400_bucket_list_entries_can_now_be_ticked.ts new file mode 100644 index 000000000..dc95e0d36 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034400_bucket_list_entries_can_now_be_ticked.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 224 (`db/migrations.ts`). + * + * Bucket-list entries can now be ticked off (#2279). `visited_source` records + * who decided — a hand-ticked wish and one confirmed from a recording read the + * same on the map otherwise, and re-running a scan must not touch the first. + */ +export class Migration20200101034400_bucket_list_entries_can_now_be_ticked extends Migration { + override name = 'Migration20200101034400_bucket_list_entries_can_now_be_ticked'; + + override async up(): Promise { + await addColumnIfMissing(this, 'bucket_list', 'visited_at', `visited_at TEXT`); + await addColumnIfMissing(this, 'bucket_list', 'visited_source', `visited_source TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034500_where_an_atlas_country_came_from.ts b/server/src/db/migrations/Migration20200101034500_where_an_atlas_country_came_from.ts new file mode 100644 index 000000000..c081309f2 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034500_where_an_atlas_country_came_from.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 225 (`db/migrations.ts`). + * + * Where an Atlas country came from (#2279). Everything that exists today was + * marked by hand, so 'manual' is the correct backfill rather than a guess; + * countries confirmed out of a recording are written as 'dawarich' and the + * Atlas can say so. Unmarking still deletes the row either way. + */ +export class Migration20200101034500_where_an_atlas_country_came_from extends Migration { + override name = 'Migration20200101034500_where_an_atlas_country_came_from'; + + override async up(): Promise { + await addColumnIfMissing(this, 'visited_countries', 'source', `source TEXT NOT NULL DEFAULT 'manual'`); + } +} diff --git a/server/src/db/migrations/Migration20200101034600_per_key_read_scopes_for_api_v1.ts b/server/src/db/migrations/Migration20200101034600_per_key_read_scopes_for_api_v1.ts new file mode 100644 index 000000000..5269436ad --- /dev/null +++ b/server/src/db/migrations/Migration20200101034600_per_key_read_scopes_for_api_v1.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 226 (`db/migrations.ts`). + * + * Per-key read scopes for /api/v1 (#2279). The `include` parameter picks which + * sections a *response* carries; it has never restricted what a key may read, + * and handing an integration a key that reads every trip because it wanted day + * notes is the thing that needed fixing. + * + * `scope_mode` is an explicit flag rather than "NULL means everything": a + * sentinel here would mean a bug that drops the scopes column silently grants + * full access. Every existing key is 'all', so nothing that works today stops + * working — the restriction is opt-in at mint time. + */ +export class Migration20200101034600_per_key_read_scopes_for_api_v1 extends Migration { + override name = 'Migration20200101034600_per_key_read_scopes_for_api_v1'; + + override async up(): Promise { + await addColumnIfMissing(this, 'mcp_tokens', 'scope_mode', `scope_mode TEXT NOT NULL DEFAULT 'all'`); + await addColumnIfMissing(this, 'mcp_tokens', 'api_scopes', `api_scopes TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034700_where_a_place_came_from_when_it.ts b/server/src/db/migrations/Migration20200101034700_where_a_place_came_from_when_it.ts new file mode 100644 index 000000000..4a1435ee8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034700_where_a_place_came_from_when_it.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 227 (`db/migrations.ts`). + * + * Where a place came from, when it did not come from a person typing it + * (#2279). Only 'dawarich' writes it today; NULL is every place anyone has + * ever added by hand, which is what it should stay. + * A column rather than a lookup through dawarich_visit_suggestions: the mark + * has to survive the suggestion being deleted, and a place list would + * otherwise join an integration's table to render a name. + */ +export class Migration20200101034700_where_a_place_came_from_when_it extends Migration { + override name = 'Migration20200101034700_where_a_place_came_from_when_it'; + + override async up(): Promise { + await addColumnIfMissing(this, 'places', 'source', `source TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101034800_provenance_for_a_day_stop_that_a.ts b/server/src/db/migrations/Migration20200101034800_provenance_for_a_day_stop_that_a.ts new file mode 100644 index 000000000..c03d60186 --- /dev/null +++ b/server/src/db/migrations/Migration20200101034800_provenance_for_a_day_stop_that_a.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 228 (`db/migrations.ts`). + * + * Provenance for a day stop that a lodging booking put there rather than the + * traveller: it carries the stay's id, so moving or deleting the booking can + * move or delete exactly that stop and never one somebody placed by hand. + * Every row that already exists stays NULL: those were planned by hand, and a + * booking must not start claiming ownership of them. The step below adds the + * missing stops instead, which is a different thing from claiming old ones. + */ +export class Migration20200101034800_provenance_for_a_day_stop_that_a extends Migration { + override name = 'Migration20200101034800_provenance_for_a_day_stop_that_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'day_assignments', 'accommodation_id', `accommodation_id INTEGER`); + } +} diff --git a/server/src/db/migrations/Migration20200101034900_give_every_stay_booked_before_this_release.ts b/server/src/db/migrations/Migration20200101034900_give_every_stay_booked_before_this_release.ts new file mode 100644 index 000000000..394387b1f --- /dev/null +++ b/server/src/db/migrations/Migration20200101034900_give_every_stay_booked_before_this_release.ts @@ -0,0 +1,26 @@ +import { attachStayStopsToCheckInDay } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 229 (`db/migrations.ts`). + * + * Give every stay booked before this release the day stop it would get today. + * + * Road trip mode builds its drive out of day_assignments alone, so a hotel + * booked in Days mode was invisible there and the traveller had to add the same + * place a second time by hand. New bookings get the stop as they are written; + * without this step the fix would only ever apply to trips planned after the + * upgrade, and the trips people already have would stay broken. + * + * Skipped on purpose: a stay whose place is gone (place_id is ON DELETE SET + * NULL, and the booking form writes stays that never had one), and a place the + * traveller already planned for that day, whose row stays theirs and unmarked. + */ +export class Migration20200101034900_give_every_stay_booked_before_this_release extends Migration { + override name = 'Migration20200101034900_give_every_stay_booked_before_this_release'; + + override async up(): Promise { + const placed = await attachStayStopsToCheckInDay(this); + if (placed > 0) console.log(`[DB] Put ${placed} booked night(s) on their check-in day`); + } +} diff --git a/server/src/db/migrations/Migration20200101035000_the_same_sweep_once_more_for_the.ts b/server/src/db/migrations/Migration20200101035000_the_same_sweep_once_more_for_the.ts new file mode 100644 index 000000000..c74345154 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035000_the_same_sweep_once_more_for_the.ts @@ -0,0 +1,27 @@ +import { attachStayStopsToCheckInDay } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 230 (`db/migrations.ts`). + * + * The same sweep once more, for the nights the first one could not have seen. + * + * A migration runs while the old container is still answering: a booking written + * in those seconds is written by code that knows nothing about the day stop, and + * lands behind the sweep that would have given it one. One did, on the test + * instance, out of ten. There is nothing to be done about that window, but there + * is something to be done about what falls into it, and a booking that never got + * its stop is invisible to the drive with no way back short of saving it again. + * + * Safe to repeat: the same NOT EXISTS decides it, so a stay that already has its + * stop is passed over, and one whose place the traveller planned by hand keeps + * that row unclaimed. + */ +export class Migration20200101035000_the_same_sweep_once_more_for_the extends Migration { + override name = 'Migration20200101035000_the_same_sweep_once_more_for_the'; + + override async up(): Promise { + const placed = await attachStayStopsToCheckInDay(this); + if (placed > 0) console.log(`[DB] Caught up ${placed} booked night(s) missed during the upgrade`); + } +} diff --git a/server/src/db/migrations/Migration20200101035100_a_suggestion_the_traveller_has_waved_away.ts b/server/src/db/migrations/Migration20200101035100_a_suggestion_the_traveller_has_waved_away.ts new file mode 100644 index 000000000..94173bda8 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035100_a_suggestion_the_traveller_has_waved_away.ts @@ -0,0 +1,20 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 231 (`db/migrations.ts`). + * + * A suggestion the traveller has waved away. + * Skeletons are real rows, and the trip sync decides what to create by asking + * which source places already have one. Deleting a dismissed suggestion would + * therefore bring it straight back on the next sync. So it stays, marked, and + * drops out of every read instead — which also leaves a way back. + * Appended LAST: the array is index-addressed against schema_version. + */ +export class Migration20200101035100_a_suggestion_the_traveller_has_waved_away extends Migration { + override name = 'Migration20200101035100_a_suggestion_the_traveller_has_waved_away'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'dismissed', `dismissed INTEGER NOT NULL DEFAULT 0`); + } +} diff --git a/server/src/db/migrations/Migration20200101035200_the_country_an_entry_happened_in_resolved.ts b/server/src/db/migrations/Migration20200101035200_the_country_an_entry_happened_in_resolved.ts new file mode 100644 index 000000000..cc2f92d7c --- /dev/null +++ b/server/src/db/migrations/Migration20200101035200_the_country_an_entry_happened_in_resolved.ts @@ -0,0 +1,18 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 232 (`db/migrations.ts`). + * + * The country an entry happened in, resolved once from its coordinates. + * For the flag on the timeline card. Resolved on write rather than on read + * because the answer never changes and the polygon test should not run on + * every render of every entry. + */ +export class Migration20200101035200_the_country_an_entry_happened_in_resolved extends Migration { + override name = 'Migration20200101035200_the_country_an_entry_happened_in_resolved'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'country_code', `country_code TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101035300_which_of_the_optional_entry_fields_a.ts b/server/src/db/migrations/Migration20200101035300_which_of_the_optional_entry_fields_a.ts new file mode 100644 index 000000000..494f30d2e --- /dev/null +++ b/server/src/db/migrations/Migration20200101035300_which_of_the_optional_entry_fields_a.ts @@ -0,0 +1,24 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 233 (`db/migrations.ts`). + * + * Which of the optional entry fields a journey uses. + * + * Mood, weather and the pros/cons list are the three things that make the editor + * feel like a form. Not everybody journals that way, and a journey kept by one + * person for their family should be able to put them away without the fields + * being taken from everybody else (discussion #2299). + * + * DEFAULT 1: every existing journey keeps all three, which is what it had. + */ +export class Migration20200101035300_which_of_the_optional_entry_fields_a extends Migration { + override name = 'Migration20200101035300_which_of_the_optional_entry_fields_a'; + + override async up(): Promise { + for (const column of ['show_verdict', 'show_mood', 'show_weather']) { + await addColumnIfMissing(this, 'journeys', column, `${column} INTEGER NOT NULL DEFAULT 1`); + } + } +} diff --git a/server/src/db/migrations/Migration20200101035400_let_go_of_a_booking_the_day.ts b/server/src/db/migrations/Migration20200101035400_let_go_of_a_booking_the_day.ts new file mode 100644 index 000000000..f809b0a53 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035400_let_go_of_a_booking_the_day.ts @@ -0,0 +1,39 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 234 (`db/migrations.ts`). + * + * Let go of a booking the day stop can no longer reach. + * day_assignments.accommodation_id was added as a bare INTEGER, and the only + * code that ever clears it looks the stay up by id. A stay can also vanish + * without anybody asking: day_accommodations.end_day_id is ON DELETE CASCADE, + * so shortening a trip past a booking's last night deletes the booking while + * the stop on its first night survives, now pointing at nothing. Days hides any + * stop that carries an accommodation_id, so the hotel drops out of the day list + * on every surface while the route still drives to it, and the id is + * AUTOINCREMENT, so nothing will ever come along and free the row. + * A trigger rather than a column rebuild: day_assignments is referenced by two + * cascading tables of its own, and SQLite fires this even when the stay went + * down with a foreign-key cascade. + */ +export class Migration20200101035400_let_go_of_a_booking_the_day extends Migration { + override name = 'Migration20200101035400_let_go_of_a_booking_the_day'; + + override up(): void { + this.addSql(` + UPDATE day_assignments SET accommodation_id = NULL + WHERE accommodation_id IS NOT NULL + AND accommodation_id NOT IN (SELECT id FROM day_accommodations) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_day_assignments_accommodation_id ON day_assignments(accommodation_id)`); + + this.addSql(` + CREATE TRIGGER IF NOT EXISTS trg_release_stop_on_stay_delete + AFTER DELETE ON day_accommodations + BEGIN + UPDATE day_assignments SET accommodation_id = NULL WHERE accommodation_id = OLD.id; + END + `); + } +} diff --git a/server/src/db/migrations/Migration20200101035500_a_journal_skeleton_belongs_to_a_day.ts b/server/src/db/migrations/Migration20200101035500_a_journal_skeleton_belongs_to_a_day.ts new file mode 100644 index 000000000..a7e8650db --- /dev/null +++ b/server/src/db/migrations/Migration20200101035500_a_journal_skeleton_belongs_to_a_day.ts @@ -0,0 +1,55 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 235 (`db/migrations.ts`). + * + * A journal skeleton belongs to a day, not just to a place (#2329). + * + * The sync engine keyed a skeleton by `source_place_id` alone, so the same place + * standing on two days — the city you land in at dusk and walk through the next + * morning, the hotel you sleep in three nights — produced one entry on the first + * of them and nothing on the rest. There was nowhere to put the second evening's + * photographs but the first evening's entry. + * + * The assignment row, not the day, is the missing half of that key: moving a + * stop to another day updates `day_assignments.day_id` in place, so keying on + * the assignment lets an entry follow the move the way it always has, while + * still telling two days of the same place apart. + * + * Deliberately no REFERENCES clause. Unassigning a stop has to leave the id + * stale rather than NULL, because reconciliation reads a key that no longer + * matches as "this stop left the plan" — which is what happened — and a NULL + * would be indistinguishable from a row this migration could not resolve. The id + * is safe to dangle: `day_assignments.id` is AUTOINCREMENT, so it is never + * handed out twice. + * + * Existing rows are backfilled to the place's earliest assignment, which is the + * one the old engine would have picked, so reconciliation matches what is + * already there instead of writing a duplicate beside it. + */ +export class Migration20200101035500_a_journal_skeleton_belongs_to_a_day extends Migration { + override name = 'Migration20200101035500_a_journal_skeleton_belongs_to_a_day'; + + override async up(): Promise { + await addColumnIfMissing(this, 'journey_entries', 'source_assignment_id', `source_assignment_id INTEGER`); + + // `source_assignment_id IS NULL` keeps the replay from re-resolving a row + // that has since followed its stop to another day. + await this.execute(` + UPDATE journey_entries + SET source_assignment_id = ( + SELECT da.id + FROM day_assignments da + JOIN days d ON d.id = da.day_id + WHERE da.place_id = journey_entries.source_place_id + ORDER BY d.day_number ASC, d.date ASC, da.order_index ASC, da.id ASC + LIMIT 1 + ) + WHERE source_place_id IS NOT NULL AND source_assignment_id IS NULL + `); + await this.execute( + `CREATE INDEX IF NOT EXISTS idx_journey_entries_source_assignment ON journey_entries(source_place_id, source_assignment_id)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101035600_document_providers_part_1_of_3_the.ts b/server/src/db/migrations/Migration20200101035600_document_providers_part_1_of_3_the.ts new file mode 100644 index 000000000..aca1edbcd --- /dev/null +++ b/server/src/db/migrations/Migration20200101035600_document_providers_part_1_of_3_the.ts @@ -0,0 +1,69 @@ +import { DOCUMENT_PROVIDERS, DOCUMENT_PROVIDER_FIELDS } from '../document-provider-seed'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 236 (`db/migrations.ts`). + * + * Document providers, part 1 of 3: the registry (#214). + * + * Deliberately its own pair of tables rather than a `kind` column on + * `photo_providers`. The client filters on `type === 'photo_provider'`, the + * Journey cascade runs `UPDATE photo_providers SET enabled = 0` with no WHERE + * clause, and migrations are append-only. Reusing those tables would change the + * behaviour of three existing paths, which is exactly what the + * no-breaking-changes rule forbids. The field columns follow + * `photo_provider_fields` except for `settings_key` and `payload_key`. Those map + * a photo field onto a settings key and a request key; a document field is + * stored under its own `field_key` in one JSON column, and the connect form + * takes its fields from /docsync/providers rather than from the generic settings + * form, so nothing would ever read them. + * + * The rows come from the same constants `seeds.ts` uses, so a fresh install and + * an upgraded one agree. + */ +export class Migration20200101035600_document_providers_part_1_of_3_the extends Migration { + override name = 'Migration20200101035600_document_providers_part_1_of_3_the'; + + override async up(): Promise { + await this.execute(` + CREATE TABLE IF NOT EXISTS document_providers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + icon TEXT DEFAULT 'FileText', + enabled INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0 + ) + `); + await this.execute(` + CREATE TABLE IF NOT EXISTS document_provider_fields ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE, + field_key TEXT NOT NULL, + label TEXT NOT NULL, + input_type TEXT NOT NULL DEFAULT 'text', + placeholder TEXT, + hint TEXT, + required INTEGER DEFAULT 0, + secret INTEGER DEFAULT 0, + sort_order INTEGER DEFAULT 0, + UNIQUE(provider_id, field_key) + ) + `); + + for (const p of DOCUMENT_PROVIDERS) { + await this.execute( + `INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)`, + [p.id, p.name, p.description, p.icon, p.sort_order], + ); + } + for (const f of DOCUMENT_PROVIDER_FIELDS) { + await this.execute( + `INSERT OR IGNORE INTO document_provider_fields + (provider_id, field_key, label, input_type, placeholder, hint, required, secret, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.sort_order], + ); + } + } +} diff --git a/server/src/db/migrations/Migration20200101035700_document_providers_part_2_of_3_the.ts b/server/src/db/migrations/Migration20200101035700_document_providers_part_2_of_3_the.ts new file mode 100644 index 000000000..19c8c43e7 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035700_document_providers_part_2_of_3_the.ts @@ -0,0 +1,93 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 237 (`db/migrations.ts`). + * + * Document providers, part 2 of 3: the connection, and the trip binding. + * The connection carries a `trip_id`, and that is the one place this design + * departs from every integration already in the repo. Immich, Synology + * Photos, AirTrail and Dawarich all hang off a user, and even + * `trip_album_links` carries a `user_id`; photos become visible to the rest + * of a trip only through an opt-in `shared` flag. None of that can satisfy + * "everyone on the trip sees the same documents": it would make a + * document's visibility depend on whose credentials fetched it. So the trip + * admin binds the trip once, the server talks to the provider under that + * single identity, and TREK's own membership decides who sees what. + * `owner_user_id` stays separate from `trip_id` so the credential holder is + * always explicit: when that person leaves the trip the binding goes to + * `orphaned` rather than silently continuing to use an ex-member's token. + * Secrets live in one encrypted JSON blob instead of per-provider columns: + * a sixth provider then needs no migration, and the key rotation in + * scripts/migrate-encryption.ts stays one line instead of a field list that + * someone will forget, and a forgotten column does not survive a rotation. + */ +export class Migration20200101035700_document_providers_part_2_of_3_the extends Migration { + override name = 'Migration20200101035700_document_providers_part_2_of_3_the'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS document_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL REFERENCES document_providers(id) ON DELETE CASCADE, + owner_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + base_url TEXT NOT NULL, + secrets TEXT, + settings TEXT NOT NULL DEFAULT '{}', + allow_insecure_tls INTEGER NOT NULL DEFAULT 0, + capabilities TEXT, + last_probe_at TEXT, + last_probe_state TEXT NOT NULL DEFAULT 'never', + last_probe_error TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, provider_id) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_document_connections_trip ON document_connections(trip_id)`); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_document_connections_owner ON document_connections(owner_user_id)`); + + this.addSql(` + CREATE TABLE IF NOT EXISTS trip_document_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + connection_id INTEGER NOT NULL REFERENCES document_connections(id) ON DELETE CASCADE, + provider_id TEXT NOT NULL, + remote_scope_key TEXT NOT NULL, + remote_root_id TEXT, + remote_root_path TEXT, + remote_label TEXT NOT NULL DEFAULT '', + direction TEXT NOT NULL DEFAULT 'both', + delete_policy TEXT NOT NULL DEFAULT 'unlink', + conflict_policy TEXT NOT NULL DEFAULT 'manual', + sync_enabled INTEGER NOT NULL DEFAULT 1, + webhook_token TEXT, + webhook_secret TEXT, + webhook_subscription_id TEXT, + remote_cursor TEXT, + last_sync_at TEXT, + last_sync_state TEXT NOT NULL DEFAULT 'never', + last_sync_error TEXT, + failure_count INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT, + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(trip_id, connection_id, remote_scope_key) + ) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_trip_document_links_trip ON trip_document_links(trip_id)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_trip_document_links_due ON trip_document_links(sync_enabled, next_attempt_at)`, + ); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_trip_document_links_token + ON trip_document_links(webhook_token) WHERE webhook_token IS NOT NULL + `); + } +} diff --git a/server/src/db/migrations/Migration20200101035800_document_providers_part_3_of_3_the.ts b/server/src/db/migrations/Migration20200101035800_document_providers_part_3_of_3_the.ts new file mode 100644 index 000000000..e957e2a00 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035800_document_providers_part_3_of_3_the.ts @@ -0,0 +1,74 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 238 (`db/migrations.ts`). + * + * Document providers, part 3 of 3: the pairing and its sync state. + * `content_sha256` and `pushed_sha256` are two columns on purpose. The + * first is the bytes both sides last agreed on, the second is what TREK + * itself last uploaded. Collapsing them into one is precisely the mistake + * that builds an echo loop: a webhook fires for TREK's own write, the core + * cannot tell it from a stranger's edit, and the file bounces. + * `remote_missing_at` records that something vanished upstream instead of + * acting on it, the rule Dawarich already follows with `source_missing_at`. + * An unmounted share answers with an empty listing, and reading that as + * "everything was deleted" would empty a trip. + * `file_id ON DELETE SET NULL` keeps a tombstone behind after a document is + * permanently deleted in TREK, so the next run does not cheerfully download + * it again. + */ +export class Migration20200101035800_document_providers_part_3_of_3_the extends Migration { + override name = 'Migration20200101035800_document_providers_part_3_of_3_the'; + + override up(): void { + this.addSql(` + CREATE TABLE IF NOT EXISTS document_sync_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + link_id INTEGER NOT NULL REFERENCES trip_document_links(id) ON DELETE CASCADE, + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + file_id INTEGER REFERENCES trip_files(id) ON DELETE SET NULL, + trek_doc_uid TEXT NOT NULL, + remote_id TEXT, + remote_name TEXT, + remote_version TEXT, + remote_size INTEGER, + remote_modified_at TEXT, + content_sha256 TEXT, + pushed_sha256 TEXT, + state TEXT NOT NULL DEFAULT 'pending', + error_code TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + next_attempt_at TEXT, + remote_missing_at TEXT, + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + synced_at TEXT + ) + `); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_remote + ON document_sync_items(link_id, remote_id) WHERE remote_id IS NOT NULL + `); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_file + ON document_sync_items(link_id, file_id) WHERE file_id IS NOT NULL + `); + + this.addSql(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_sync_items_uid + ON document_sync_items(link_id, trek_doc_uid) + `); + + this.addSql(`CREATE INDEX IF NOT EXISTS idx_document_sync_items_trip_state ON document_sync_items(trip_id, state)`); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_document_sync_items_hash ON document_sync_items(link_id, content_sha256)`, + ); + + this.addSql( + `CREATE INDEX IF NOT EXISTS idx_document_sync_items_due ON document_sync_items(link_id, next_attempt_at)`, + ); + } +} diff --git a/server/src/db/migrations/Migration20200101035900_document_providers_when_trek_itself_put_a.ts b/server/src/db/migrations/Migration20200101035900_document_providers_when_trek_itself_put_a.ts new file mode 100644 index 000000000..a7f21dc09 --- /dev/null +++ b/server/src/db/migrations/Migration20200101035900_document_providers_when_trek_itself_put_a.ts @@ -0,0 +1,25 @@ +import { addColumnIfMissing } from '../migration-utils'; +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 239 (`db/migrations.ts`). + * + * Document providers: when TREK itself put a provider copy in the bin. + * + * A file deleted in TREK under the `trash` policy takes its provider copy with + * it. Taken back out of TREK's trash, it has to go up again; a copy somebody + * else deleted in the meantime must not. Both leave the same gap in a listing, + * so the difference is written down when TREK acts rather than guessed at later. + * + * No backfill from the binding's current policy: that policy may not be the one + * the deletion ran under, and reading it back is the retroactive mistake this + * column exists to avoid. A row left NULL is treated like a copy somebody else + * deleted, which flags it instead of uploading it. + */ +export class Migration20200101035900_document_providers_when_trek_itself_put_a extends Migration { + override name = 'Migration20200101035900_document_providers_when_trek_itself_put_a'; + + override async up(): Promise { + await addColumnIfMissing(this, 'document_sync_items', 'remote_trashed_at', `remote_trashed_at TEXT`); + } +} diff --git a/server/src/db/migrations/Migration20200101040000_trips_longer_than_a_year_lost_every.ts b/server/src/db/migrations/Migration20200101040000_trips_longer_than_a_year_lost_every.ts new file mode 100644 index 000000000..a71a694e9 --- /dev/null +++ b/server/src/db/migrations/Migration20200101040000_trips_longer_than_a_year_lost_every.ts @@ -0,0 +1,74 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** A trip whose day rows were clipped at the old 365-day limit. */ +interface ClippedTrip { + id: number; + start_date: string; + end_date: string; + span: number; +} + +/** The date `n` days after `start`, as YYYY-MM-DD. */ +function dayAfter(start: string, n: number): string { + const [y, m, d] = start.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d) + n * 86400000).toISOString().slice(0, 10); +} + +/** + * Legacy migration step 240 (`db/migrations.ts`). + * + * Trips longer than a year lost every day past the 365th: generateDays clipped + * the day rows at the old limit while the trip kept its full end date, so the + * last days had a date but nothing to plan on (#2403). The limit is 999 now, and + * this gives the affected trips their missing days. + * + * Only a range whose dated days still run unbroken from the start date is + * extended; a trip that was re-dated by hand or lost a day in the middle is left + * as it is. Dateless days that still hold content stay behind the dated ones, + * where generateDays keeps them. The two-phase renumbering is the same dance + * generateDays does around UNIQUE(trip_id, day_number). + */ +export class Migration20200101040000_trips_longer_than_a_year_lost_every extends Migration { + override name = 'Migration20200101040000_trips_longer_than_a_year_lost_every'; + + override async up(): Promise { + const trips = (await this.execute(` + SELECT id, start_date, end_date, + CAST(julianday(end_date) - julianday(start_date) + 1 AS INTEGER) AS span + FROM trips + WHERE start_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + AND end_date GLOB '[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]' + AND julianday(end_date) - julianday(start_date) + 1 BETWEEN 366 AND 999 + `)) as ClippedTrip[]; + + for (const trip of trips) { + const rows = (await this.execute(`SELECT id, date FROM days WHERE trip_id = ? ORDER BY day_number`, [ + trip.id, + ])) as { id: number; date: string | null }[]; + + const dated = rows.filter((r) => r.date); + if (dated.length >= trip.span) continue; + if (dated.some((r, i) => r.date !== dayAfter(trip.start_date, i))) continue; + const dateless = rows.filter((r) => !r.date); + + // Phase one: park every row on a negative number so the renumbering below + // cannot collide with UNIQUE(trip_id, day_number). + for (const [i, r] of rows.entries()) { + await this.execute(`UPDATE days SET day_number = ? WHERE id = ?`, [-(i + 1), r.id]); + } + for (const [i, r] of dated.entries()) { + await this.execute(`UPDATE days SET day_number = ? WHERE id = ?`, [i + 1, r.id]); + } + for (let i = dated.length; i < trip.span; i++) { + await this.execute(`INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)`, [ + trip.id, + i + 1, + dayAfter(trip.start_date, i), + ]); + } + for (const [i, r] of dateless.entries()) { + await this.execute(`UPDATE days SET day_number = ? WHERE id = ?`, [trip.span + i + 1, r.id]); + } + } + } +} diff --git a/server/src/db/migrations/Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts b/server/src/db/migrations/Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts new file mode 100644 index 000000000..384993a6b --- /dev/null +++ b/server/src/db/migrations/Migration20200101040100_the_road_trip_day_boundaries_carried_the.ts @@ -0,0 +1,35 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * Legacy migration step 241 (`db/migrations.ts`). + * + * The road-trip day boundaries carried the old trip limit as a CHECK on + * day_number. The limit lives in the contract now (MAX_TRIP_DAYS), so the + * table keeps only the floor. SQLite cannot alter a CHECK, hence the + * rebuild; nothing references the table, so the rows are simply copied. + */ +export class Migration20200101040100_the_road_trip_day_boundaries_carried_the extends Migration { + override name = 'Migration20200101040100_the_road_trip_day_boundaries_carried_the'; + + override up(): void { + this.addSql(` + CREATE TABLE roadtrip_day_boundaries_new ( + trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE, + day_number INTEGER NOT NULL CHECK (day_number >= 1), + from_assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE, + to_assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE CASCADE, + fraction REAL NOT NULL CHECK (fraction BETWEEN 0 AND 1), + PRIMARY KEY (trip_id, day_number) + ) + `); + + this.addSql(` + INSERT INTO roadtrip_day_boundaries_new + SELECT trip_id, day_number, from_assignment_id, to_assignment_id, fraction FROM roadtrip_day_boundaries + `); + + this.addSql(`DROP TABLE roadtrip_day_boundaries`); + + this.addSql(`ALTER TABLE roadtrip_day_boundaries_new RENAME TO roadtrip_day_boundaries`); + } +} diff --git a/server/src/db/orm-driver.ts b/server/src/db/orm-driver.ts new file mode 100644 index 000000000..a1ebf729f --- /dev/null +++ b/server/src/db/orm-driver.ts @@ -0,0 +1,42 @@ +import { getRawConnection } from './database'; +import type { Configuration } from '@mikro-orm/core'; +import { SqliteConnection, SqliteDriver } from '@mikro-orm/sqlite'; + +import { SqliteDialect } from 'kysely'; + +/** + * A SQLite driver that reuses the connection `db/database.ts` already owns + * instead of opening a second one. + * + * This is not a preference — it is forced by the test harness. `database.ts` + * hands each vitest worker `:memory:`, and in better-sqlite3 an in-memory + * database belongs to its *connection*: two connections to `:memory:` are two + * unrelated databases. MikroORM's stock `SqliteConnection.createKyselyDialect()` + * always does `new Database(dbName)`, so it would migrate a database nothing + * else in the process can see. + * + * Sharing it also keeps the single-writer property the app has always had: one + * handle, one WAL, no two-writers-on-one-file question in production. + */ +class SharedSqliteConnection extends SqliteConnection { + /** + * Resolved lazily, on every (re)connect rather than once at import: a backup + * restore closes the handle and opens a new one, and Kysely caches whatever it + * is given for the life of its client. `reinitialize()` closes this connection + * so that the next `connect()` lands here again and picks up the new handle. + */ + override createKyselyDialect(): SqliteDialect { + return new SqliteDialect({ database: getRawConnection() }); + } +} + +export class SharedSqliteDriver extends SqliteDriver { + constructor(config: Configuration) { + super(config); + // The base constructor already built a stock SqliteConnection; replace it + // before anything connects. `connection` is protected on AbstractSqlDriver, + // and there is no supported hook for supplying the class from a subclass of + // SqliteDriver (its own constructor hardcodes SqliteConnection). + (this as unknown as { connection: SqliteConnection }).connection = new SharedSqliteConnection(config); + } +} diff --git a/server/src/db/orm.ts b/server/src/db/orm.ts new file mode 100644 index 000000000..07b55682c --- /dev/null +++ b/server/src/db/orm.ts @@ -0,0 +1,83 @@ +import mikroOrmConfig from '../mikro-orm.config'; +import { registerReinitializeHook, runDemoSeed } from './database'; +import type { AnyEntity, EntityClass, EntityManager, EntitySchema, IDatabaseDriver, MikroORM } from '@mikro-orm/core'; +import type { Migrator } from '@mikro-orm/migrations'; +import { MikroORM as SqliteMikroORM } from '@mikro-orm/sqlite'; + +/** + * Schema ownership at startup. + * + * This replaces `createTables()` → `runMigrations()` → `runSeeds()`, which used + * to run synchronously inside `database.ts::initDb()` at module load. The + * migrator is async, so the work moved to `buildApp()`, which is the first place + * in the boot with an await and still runs before anything reads the database + * (`bootstrap.ts` resolves SettingsService right after). + */ + +/** + * Whatever MikroORM instance this process holds. + * + * Widened on the entity list because Nest's `MikroORM` token resolves to the + * class's own `readonly` form while a direct `init()` yields the mutable one; + * nothing here reads that parameter. + */ +type AnyOrm = MikroORM< + IDatabaseDriver, + EntityManager, + readonly (string | EntityClass | EntitySchema)[] +>; + +/** Migrate to head, then seed. Safe to call again — both halves are idempotent. */ +export async function runSchemaBootstrap(orm: AnyOrm): Promise { + const migrator = orm.config.getExtension('@mikro-orm/migrator') as Migrator; + const pending = await migrator.getPending(); + if (pending.length > 0) { + console.log(`[DB] Applying ${pending.length} pending migration(s)`); + await migrator.up(); + } + + const defaultSeeder = orm.config.get('seeder').defaultSeeder; + if (defaultSeeder) await orm.seeder.seedString(defaultSeeder); + + // Demo data depends on the seeded categories (hard-coded ids under an FK), so + // it can only run once the seeders above have. + runDemoSeed(); +} + +/** + * Binds the ORM to this process's connection lifecycle. + * + * A restore swaps the SQLite file and reopens the raw handle; Kysely caches + * whatever handle it was given, so without this the ORM would keep talking to a + * closed connection. Closing and reconnecting sends the driver back through + * `createKyselyDialect()`, which picks up the new handle — and the restored file + * may be an older backup, so it gets migrated forward too. + */ +export function attachOrm(orm: AnyOrm): void { + registerReinitializeHook(async () => { + const connection = orm.em.getConnection(); + // The old handle is already closed by this point; better-sqlite3's close() + // is idempotent, so letting Kysely destroy it again is harmless. + await connection.close(true); + await connection.connect(); + await runSchemaBootstrap(orm); + }); +} + +/** + * An ORM instance outside Nest, for the vitest global setup that builds the + * schema snapshot. Production uses the container-owned one from + * `MikroOrmModule.forRoot()` instead of creating a second. + */ +export function createStandaloneOrm(): Promise { + return SqliteMikroORM.init({ + ...mikroOrmConfig, + // Entity discovery is skipped deliberately. Migrations and seeders are raw + // SQL and need no metadata, and the global setup that calls this runs as ESM + // in vitest's main process, where discovering `*.entity.ts` fails on Node's + // extensionless import resolution. + entities: [], + entitiesTs: [], + discovery: { warnWhenNoEntities: false }, + }); +} diff --git a/server/src/db/schema-snapshot.ts b/server/src/db/schema-snapshot.ts new file mode 100644 index 000000000..ea2720e83 --- /dev/null +++ b/server/src/db/schema-snapshot.ts @@ -0,0 +1,47 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +/** + * A migrated-but-empty database, serialised once per test run. + * + * Migrations are async and `database.ts::initDb()` is not — it runs at module + * load, long before anything can await. Most suites do not care (they mock the + * module or build their own database), but a handful use the singleton directly + * and need a schema to be there the moment they import it. + * + * So the vitest global setup migrates one database for the whole run and writes + * it here; every worker opens a copy. `better-sqlite3` deserialises a 1.5 MiB + * snapshot in well under a millisecond, against ~800ms to replay 242 migrations, + * so this is also what keeps the suite fast. + * + * Production never reads this — there `buildApp()` provides the await point. + */ + +/** Keyed by the migration set, so a snapshot from another branch is never reused. */ +function snapshotKey(): string { + const dir = path.join(__dirname, 'migrations'); + // A missing directory (e.g. a partial dist) degrades to a constant key; the + // global setup rewrites the file either way. + const names = fs.existsSync(dir) ? fs.readdirSync(dir).sort() : []; + return crypto.createHash('sha256').update(names.join('\n')).digest('hex').slice(0, 16); +} + +/** Outside the repo on purpose: nothing here belongs in a working tree or an image. */ +export function schemaSnapshotPath(): string { + return path.join(os.tmpdir(), `trek-schema-${snapshotKey()}.db`); +} + +export function writeSchemaSnapshot(snapshot: Buffer): void { + fs.writeFileSync(schemaSnapshotPath(), snapshot); +} + +/** The snapshot bytes, or null when no run has produced one. */ +export function readSchemaSnapshot(): Buffer | null { + try { + return fs.readFileSync(schemaSnapshotPath()); + } catch { + return null; + } +} diff --git a/server/src/db/seeders/AddonSeeder.ts b/server/src/db/seeders/AddonSeeder.ts new file mode 100644 index 000000000..cb7488365 --- /dev/null +++ b/server/src/db/seeders/AddonSeeder.ts @@ -0,0 +1,163 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +interface AddonRow { + id: string; + name: string; + description: string; + type: string; + icon: string; + enabled: 0 | 1; + sort_order: number; +} + +const DEFAULT_ADDONS: AddonRow[] = [ + { + id: 'packing', + name: 'Lists', + description: 'Packing lists and to-do tasks for your trips', + type: 'trip', + icon: 'ListChecks', + enabled: 1, + sort_order: 0, + }, + { + id: 'budget', + name: 'Costs', + description: 'Track and split trip expenses', + type: 'trip', + icon: 'Wallet', + enabled: 1, + sort_order: 1, + }, + { + id: 'documents', + name: 'Documents', + description: 'Store and manage travel documents', + type: 'trip', + icon: 'FileText', + enabled: 1, + sort_order: 2, + }, + { + id: 'vacay', + name: 'Vacay', + description: 'Personal vacation day planner with calendar view', + type: 'global', + icon: 'CalendarDays', + enabled: 1, + sort_order: 10, + }, + { + id: 'atlas', + name: 'Atlas', + description: 'World map of your visited countries with travel stats', + type: 'global', + icon: 'Globe', + enabled: 1, + sort_order: 11, + }, + { + id: 'mcp', + name: 'MCP', + description: 'Model Context Protocol for AI assistant integration', + type: 'integration', + icon: 'Terminal', + enabled: 0, + sort_order: 12, + }, + { + id: 'naver_list_import', + name: 'Naver List Import', + description: 'Import places from a shared Naver Maps list', + type: 'integration', + icon: 'Link2', + enabled: 1, + sort_order: 13, + }, + { + id: 'collab', + name: 'Collab', + description: 'Notes, polls, and live chat for trip collaboration', + type: 'trip', + icon: 'Users', + enabled: 1, + sort_order: 6, + }, + { + id: 'roadtrip', + name: 'Road trip', + description: 'Drives with stops along the route, driving times, and arrival times that update themselves', + type: 'trip', + icon: 'Route', + enabled: 0, + sort_order: 7, + }, + { + id: 'journey', + name: 'Journey', + description: 'Trip tracking & travel journal — check-ins, photos, daily stories', + type: 'global', + icon: 'Compass', + enabled: 0, + sort_order: 35, + }, + { + id: 'airtrail', + name: 'AirTrail', + description: 'Sync flights from your AirTrail instance', + type: 'integration', + icon: 'Plane', + enabled: 0, + sort_order: 14, + }, + { + id: 'dawarich', + name: 'Dawarich', + description: + 'Read visits and recorded routes from your Dawarich instance — suggested journal entries, places and countries you confirm yourself', + type: 'integration', + icon: 'Dawarich', + enabled: 0, + sort_order: 17, + }, + { + id: 'llm_parsing', + name: 'AI Parsing', + description: 'LLM fallback for booking imports kitinerary cannot read', + type: 'integration', + icon: 'Sparkles', + enabled: 0, + sort_order: 15, + }, + { + id: 'collections', + name: 'Collections', + description: + 'Personal place library — save places across trips into named lists, copy into any trip, share with others', + type: 'global', + icon: 'Bookmark', + enabled: 0, + sort_order: 16, + }, +]; + +/** + * Seeds the addon shelf. + * + * `INSERT OR IGNORE` per row, not a table-level emptiness check: an operator who + * has toggled an addon keeps their choice, and a release that adds a new addon + * still gets its tile on an existing install. + */ +export class AddonSeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + for (const addon of DEFAULT_ADDONS) { + await connection.execute( + 'INSERT OR IGNORE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)', + [addon.id, addon.name, addon.description, addon.type, addon.icon, addon.enabled, addon.sort_order], + ); + } + console.log('Default addons seeded'); + } +} diff --git a/server/src/db/seeders/AdminSeeder.ts b/server/src/db/seeders/AdminSeeder.ts new file mode 100644 index 000000000..61be9b4c2 --- /dev/null +++ b/server/src/db/seeders/AdminSeeder.ts @@ -0,0 +1,104 @@ +import { readEnv } from '../../app-config'; +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +import bcrypt from 'bcryptjs'; +import crypto from 'crypto'; + +/** bcrypt cost factor for the seeded admin password — kept in sync with authService. */ +const BCRYPT_COST = 12; + +/** + * Seeds run before the DB admin panel can be used, so only env vars are checked + * here. The granular password_login/password_registration DB toggles are only + * relevant after the first user exists; by then this seeder has already skipped + * via the userCount guard. + */ +function isOidcOnlyConfigured(): boolean { + const oidc = readEnv().oidc; + if (!oidc.only) return false; + return !!(oidc.issuer && oidc.clientId); +} + +/** + * Creates the first-run admin account. + * + * Only ever on an empty `users` table — `ADMIN_EMAIL`/`ADMIN_PASSWORD` are a + * first-run bootstrap, not an ongoing setting, and a later run says so out loud + * rather than silently ignoring them. + */ +export class AdminSeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + try { + const env_admin_email = readEnv().adminBootstrap.email; + const env_admin_pw = readEnv().adminBootstrap.password; + const adminEnvProvided = !!(env_admin_email || env_admin_pw); + + const rows = (await connection.execute('SELECT COUNT(*) as count FROM users')) as { count: number }[]; + if (rows[0].count > 0) { + // A common trip-up: people add the vars after the fact, restart, nothing + // changes, and there is no hint why. Say so instead of staying silent. + if (adminEnvProvided) { + console.warn( + '[admin] ADMIN_EMAIL/ADMIN_PASSWORD are set, but users already exist — these only apply on first run (empty database) and are being ignored.', + ); + console.warn( + '[admin] Change an existing password from Settings after signing in, reset the admin (see the Troubleshooting wiki), or start with an empty data volume to re-run setup.', + ); + } + return; + } + + // Demo mode seeds its own admin (admin@trek.app, username 'admin') right + // after this. Creating a first-run admin here would grab username 'admin' + // first and make the demo seeder fail on the UNIQUE(username) constraint. + if (readEnv().demo.enabled) return; + + if (isOidcOnlyConfigured()) { + console.log(''); + console.log('╔══════════════════════════════════════════════╗'); + console.log('║ TREK — OIDC-Only Mode ║'); + console.log('║ First SSO login will become admin. ║'); + console.log('╚══════════════════════════════════════════════╝'); + console.log(''); + return; + } + + let password: string; + let email: string; + if (env_admin_email && env_admin_pw) { + password = env_admin_pw; + email = env_admin_email; + } else { + // A partial config (only one of the two) is an easy mistake: neither + // value is used and a generated password is created instead. Flag it so + // the chosen credentials silently not working isn't a surprise. + if (adminEnvProvided) { + console.warn( + '[admin] Only one of ADMIN_EMAIL/ADMIN_PASSWORD is set — both are required for a custom admin. Falling back to admin@trek.local with a generated password (shown below).', + ); + } + password = crypto.randomBytes(12).toString('base64url'); + email = 'admin@trek.local'; + } + + const hash = bcrypt.hashSync(password, BCRYPT_COST); + await connection.execute( + 'INSERT INTO users (username, email, password_hash, role, must_change_password) VALUES (?, ?, ?, ?, 1)', + ['admin', email, hash, 'admin'], + ); + + console.log(''); + console.log('╔══════════════════════════════════════════════╗'); + console.log('║ TREK — First Run: Admin Account Created ║'); + console.log('╠══════════════════════════════════════════════╣'); + console.log(`║ Email: ${email.padEnd(33)}║`); + console.log(`║ Password: ${password.padEnd(33)}║`); + console.log('╚══════════════════════════════════════════════╝'); + console.log(''); + } catch (err: unknown) { + console.error('[ERROR] Error seeding admin account:', err instanceof Error ? err.message : err); + } + } +} diff --git a/server/src/db/seeders/CategorySeeder.ts b/server/src/db/seeders/CategorySeeder.ts new file mode 100644 index 000000000..527f89c11 --- /dev/null +++ b/server/src/db/seeders/CategorySeeder.ts @@ -0,0 +1,40 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +/** The starter categories a fresh install gets, in display order. */ +const DEFAULT_CATEGORIES: { name: string; color: string; icon: string }[] = [ + { name: 'Hotel', color: '#3b82f6', icon: '🏨' }, + { name: 'Restaurant', color: '#ef4444', icon: '🍽️' }, + { name: 'Attraction', color: '#8b5cf6', icon: '🏛️' }, + { name: 'Shopping', color: '#f59e0b', icon: '🛍️' }, + { name: 'Transport', color: '#6b7280', icon: '🚌' }, + { name: 'Activity', color: '#10b981', icon: '🎯' }, + { name: 'Bar/Cafe', color: '#f97316', icon: '☕' }, + { name: 'Beach', color: '#06b6d4', icon: '🏖️' }, + { name: 'Nature', color: '#84cc16', icon: '🌿' }, + { name: 'Other', color: '#6366f1', icon: '📍' }, +]; + +/** + * Seeds the default place categories. + * + * All-or-nothing on an empty table, exactly as `seeds.ts` did: once anything + * exists the operator owns the list, and re-adding a category they deleted would + * be the seeder overruling them. + */ +export class CategorySeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + const existing = (await connection.execute('SELECT COUNT(*) AS count FROM categories')) as { count: number }[]; + if (existing[0].count > 0) return; + + for (const category of DEFAULT_CATEGORIES) { + await connection.execute('INSERT INTO categories (name, color, icon) VALUES (?, ?, ?)', [ + category.name, + category.color, + category.icon, + ]); + } + console.log('Default categories seeded'); + } +} diff --git a/server/src/db/seeders/DatabaseSeeder.ts b/server/src/db/seeders/DatabaseSeeder.ts new file mode 100644 index 000000000..b54d68ad2 --- /dev/null +++ b/server/src/db/seeders/DatabaseSeeder.ts @@ -0,0 +1,21 @@ +import { AddonSeeder } from './AddonSeeder'; +import { AdminSeeder } from './AdminSeeder'; +import { CategorySeeder } from './CategorySeeder'; +import { DocumentProviderSeeder } from './DocumentProviderSeeder'; +import { PhotoProviderSeeder } from './PhotoProviderSeeder'; +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; +import { SchemaVersionSeeder } from './SchemaVersionSeeder'; + +/** + * The default seeder, replacing `db/seeds.ts::runSeeds()`. + * + * Order matters in one place only: `AdminSeeder` runs first because it is the + * one that keys off an empty `users` table, and the provider seeders run after + * `AddonSeeder` so the shelf exists before anything hangs off it. + */ +export class DatabaseSeeder extends Seeder { + async run(em: EntityManager): Promise { + await this.call(em, [AdminSeeder, CategorySeeder, AddonSeeder, PhotoProviderSeeder, DocumentProviderSeeder, SchemaVersionSeeder]); + } +} diff --git a/server/src/db/seeders/DocumentProviderSeeder.ts b/server/src/db/seeders/DocumentProviderSeeder.ts new file mode 100644 index 000000000..f49aeeb7b --- /dev/null +++ b/server/src/db/seeders/DocumentProviderSeeder.ts @@ -0,0 +1,32 @@ +import { DOCUMENT_PROVIDERS, DOCUMENT_PROVIDER_FIELDS } from '../document-provider-seed'; +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +/** + * Seeds the document providers. + * + * The rows come from `document-provider-seed.ts`, the same constants the + * migration that introduces the tables uses, so a fresh install and an upgraded + * one end up with identical rows. + */ +export class DocumentProviderSeeder extends Seeder { + async run(em: EntityManager): Promise { + const connection = em.getConnection(); + + for (const p of DOCUMENT_PROVIDERS) { + await connection.execute( + 'INSERT OR IGNORE INTO document_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, 0, ?)', + [p.id, p.name, p.description, p.icon, p.sort_order], + ); + } + + for (const f of DOCUMENT_PROVIDER_FIELDS) { + await connection.execute( + `INSERT OR IGNORE INTO document_provider_fields + (provider_id, field_key, label, input_type, placeholder, hint, required, secret, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [f.provider_id, f.field_key, f.label, f.input_type, f.placeholder, f.hint, f.required, f.secret, f.sort_order], + ); + } + } +} diff --git a/server/src/db/seeders/PhotoProviderSeeder.ts b/server/src/db/seeders/PhotoProviderSeeder.ts new file mode 100644 index 000000000..9459570ce --- /dev/null +++ b/server/src/db/seeders/PhotoProviderSeeder.ts @@ -0,0 +1,167 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Seeder } from '@mikro-orm/seeder'; + +interface PhotoProviderRow { + id: string; + name: string; + description: string; + icon: string; + enabled: 0 | 1; + sort_order: number; +} + +interface PhotoProviderFieldRow { + provider_id: string; + field_key: string; + label: string; + input_type: string; + placeholder: string | null; + hint: string | null; + required: 0 | 1; + secret: 0 | 1; + settings_key: string | null; + payload_key: string; + sort_order: number; +} + +const PROVIDERS: PhotoProviderRow[] = [ + { id: 'immich', name: 'Immich', description: 'Immich photo provider', icon: 'Image', enabled: 0, sort_order: 0 }, + { + id: 'synologyphotos', + name: 'Synology Photos', + description: 'Synology Photos integration with separate account settings', + icon: 'Image', + enabled: 0, + sort_order: 1, + }, +]; + +/** `label` and `hint` are i18n key suffixes the client resolves as `memories.