diff --git a/.env.example b/.env.example index e62dd5b..3bdae21 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,13 @@ # Produktion: VITE_API_URL leer lassen (nginx Reverse-Proxy leitet /api weiter) # ============================================================================= +# --- Umgebung --- +# Produktion: NODE_ENV=production -> KEIN TypeORM-synchronize (Schema via Migrationen), +# Migrationen werden beim Start automatisch ausgeführt (migrationsRun). +# Aktiviert zudem CORS-Schutz (siehe CORS_ORIGIN weiter unten). +# Entwicklung: NODE_ENV leer lassen -> synchronize ON (Schema folgt den Entities). +NODE_ENV=production + # --- Ports --- BACKEND_PORT=7601 FRONTEND_PORT=7600 diff --git a/CLAUDE.md b/CLAUDE.md index 2f95805..b3558cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,11 +78,37 @@ Use `@Public()` to bypass auth guards entirely. ### Database -- TypeORM with MySQL 8+, UTF8MB4, `synchronize: true` (schema auto-migrates) +- TypeORM with MySQL/MariaDB, UTF8MB4 +- Connection config lives in `src/database/data-source.ts` (single source of truth, + shared by the NestJS runtime and the TypeORM CLI). `database.module.ts` consumes it. +- **Schema strategy depends on `NODE_ENV`:** + - Dev (`NODE_ENV` unset): `synchronize: true` — schema auto-migrates from entities. + - Production (`NODE_ENV=production`): `synchronize: false` + `migrationsRun: true` — + pending migrations in `src/database/migrations/` are applied automatically on boot. + **Never run `synchronize` against the production DB** — on MariaDB it issues + destructive `ADD`/`DROP COLUMN` churn every boot (see caveat below). - 23 entities in `src/database/entities/` - JSON columns use transformers to normalize empty arrays/objects to `null` - Key entities: `InboxDocument`, `Task`, `Email`, `Attachment`, `Postprocessing`, `BarcodeTemplate`, `LabelPrintJob`, `ApiKey`, `Setting` +#### Migrations workflow + +```bash +npm run migration:generate -- src/database/migrations/ # diff entities → DB +npm run migration:run # apply pending +npm run migration:revert # roll back last +``` + +The existing production schema is the implicit baseline (no baseline migration); only +future changes get migration files. + +**Caveat — MariaDB reports `json` as `longtext`:** every `@Column({ type: 'json' })` +is stored as `longtext ... CHECK (json_valid(...))`, so `migration:generate` always +emits spurious no-op `CHANGE` statements (json↔longtext, nullable/default re-declares). +**Hand-trim generated migrations** down to the real change before committing. (This same +false diff is exactly why `synchronize` must stay off in production — left unchecked it +accumulates `ALGORITHM=INSTANT` drops until a table trips the 8126-byte row-size limit.) + ### Document Processing Pipeline **Preprocessing** (`preprocessing/document-pipeline.service.ts`): diff --git a/docker-compose.yml b/docker-compose.yml index 11c008d..1d37bea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: ports: - "${BACKEND_PORT:-7601}:3100" environment: + - NODE_ENV=production + - CORS_ORIGIN=${CORS_ORIGIN:-} - PORT=3100 - DB_HOST=${DB_HOST:-db} - DB_PORT=${DB_PORT:-3306} diff --git a/paperless-backend/package-lock.json b/paperless-backend/package-lock.json index 21116f7..004178f 100644 --- a/paperless-backend/package-lock.json +++ b/paperless-backend/package-lock.json @@ -24,6 +24,7 @@ "axios": "^1.14.0", "basic-ftp": "^5.2.1", "chokidar": "^4.0.3", + "dotenv": "^17.4.2", "form-data": "^4.0.5", "imapflow": "^1.3.2", "jsqr": "^1.4.0", @@ -3023,6 +3024,18 @@ "rxjs": "^7.1.0" } }, + "node_modules/@nestjs/config/node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/@nestjs/core": { "version": "11.1.17", "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.17.tgz", @@ -6291,9 +6304,9 @@ } }, "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", "license": "BSD-2-Clause", "engines": { "node": ">=12" diff --git a/paperless-backend/package.json b/paperless-backend/package.json index 5f4d168..9ec86d9 100644 --- a/paperless-backend/package.json +++ b/paperless-backend/package.json @@ -17,7 +17,11 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "typeorm": "typeorm-ts-node-commonjs -d ./src/database/data-source.ts", + "migration:generate": "npm run typeorm -- migration:generate", + "migration:run": "npm run typeorm -- migration:run", + "migration:revert": "npm run typeorm -- migration:revert" }, "dependencies": { "@nestjs/common": "^11.0.1", @@ -35,6 +39,7 @@ "axios": "^1.14.0", "basic-ftp": "^5.2.1", "chokidar": "^4.0.3", + "dotenv": "^17.4.2", "form-data": "^4.0.5", "imapflow": "^1.3.2", "jsqr": "^1.4.0", diff --git a/paperless-backend/src/database/data-source.ts b/paperless-backend/src/database/data-source.ts new file mode 100644 index 0000000..aed7b4d --- /dev/null +++ b/paperless-backend/src/database/data-source.ts @@ -0,0 +1,81 @@ +import 'reflect-metadata'; +import { join } from 'path'; +import { config as loadEnv } from 'dotenv'; +import { DataSource, DataSourceOptions } from 'typeorm'; +import { + Client, + DocumentType, + DocumentField, + Task, + Postprocessing, + PostprocessingAction, + PostprocessingLog, + ExportTarget, + Setting, + Kontonummer, + Document, + UserClient, + Email, + Attachment, + Content, + ApiKey, + CorrespondentSetting, + BarcodeTemplate, + InboxDocument, + InboxPostprocessingAction, + CorrespondentEmailMapping, + UserSettings, + LabelPrintJob, +} from './entities'; + +// CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker, +// dotenv ist dort ein No-Op). dotenv überschreibt bereits gesetzte Variablen nicht. +loadEnv(); +loadEnv({ path: join(process.cwd(), '..', '.env') }); + +export const entities = [ + Client, + DocumentType, + DocumentField, + Task, + Postprocessing, + PostprocessingAction, + PostprocessingLog, + ExportTarget, + Setting, + Kontonummer, + Document, + UserClient, + Email, + Attachment, + Content, + ApiKey, + CorrespondentSetting, + BarcodeTemplate, + InboxDocument, + InboxPostprocessingAction, + CorrespondentEmailMapping, + UserSettings, + LabelPrintJob, +]; + +const isProduction = process.env.NODE_ENV === 'production'; + +export const dataSourceOptions: DataSourceOptions = { + type: 'mysql', + host: process.env.DB_HOST ?? 'localhost', + port: Number(process.env.DB_PORT ?? 3306), + username: process.env.DB_USERNAME ?? 'root', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_DATABASE ?? 'paperlessadd', + charset: 'utf8mb4', + entities, + migrations: [join(__dirname, 'migrations', '*.{ts,js}')], + // In Produktion: kein synchronize (zerstörerischer ALTER-Churn), Migrationen + // werden beim Start automatisch ausgeführt. In Dev: synchronize wie bisher. + synchronize: !isProduction, + migrationsRun: isProduction, +}; + +// Wird von der TypeORM-CLI verwendet (migration:generate / :run / :revert). +export default new DataSource(dataSourceOptions); diff --git a/paperless-backend/src/database/database.module.ts b/paperless-backend/src/database/database.module.ts index a3d730b..8ca67f7 100644 --- a/paperless-backend/src/database/database.module.ts +++ b/paperless-backend/src/database/database.module.ts @@ -1,75 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { - Client, - DocumentType, - DocumentField, - Task, - Postprocessing, - PostprocessingAction, - PostprocessingLog, - ExportTarget, - Setting, - Kontonummer, - Document, - UserClient, - Email, - Attachment, - Content, - ApiKey, - CorrespondentSetting, - BarcodeTemplate, - InboxDocument, - InboxPostprocessingAction, - CorrespondentEmailMapping, - UserSettings, - LabelPrintJob, -} from './entities'; - -const entities = [ - Client, - DocumentType, - DocumentField, - Task, - Postprocessing, - PostprocessingAction, - PostprocessingLog, - ExportTarget, - Setting, - Kontonummer, - Document, - UserClient, - Email, - Attachment, - Content, - ApiKey, - CorrespondentSetting, - BarcodeTemplate, - InboxDocument, - InboxPostprocessingAction, - CorrespondentEmailMapping, - UserSettings, - LabelPrintJob, -]; +import { dataSourceOptions, entities } from './data-source'; @Module({ imports: [ - TypeOrmModule.forRootAsync({ - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (config: ConfigService) => ({ - type: 'mysql' as const, - host: config.get('DB_HOST', 'localhost'), - port: config.get('DB_PORT', 3306), - username: config.get('DB_USERNAME', 'root'), - password: config.get('DB_PASSWORD', ''), - database: config.get('DB_DATABASE', 'paperlessadd'), - entities, - synchronize: config.get('NODE_ENV') !== 'production', - charset: 'utf8mb4', - }), - }), + // Eine einzige Konfigurationsquelle (data-source.ts), geteilt zwischen + // NestJS-Laufzeit und TypeORM-CLI (Migrationen). + TypeOrmModule.forRoot(dataSourceOptions), TypeOrmModule.forFeature(entities), ], exports: [TypeOrmModule], diff --git a/paperless-backend/src/database/migrations/.gitkeep b/paperless-backend/src/database/migrations/.gitkeep new file mode 100644 index 0000000..e69de29