Compare commits
No commits in common. "906aa751c716a93b317a374456586079fd5ae9b0" and "a17e2e3f4b479e480b316f6abdfe69634c91c961" have entirely different histories.
906aa751c7
...
a17e2e3f4b
11 changed files with 67 additions and 412 deletions
|
|
@ -1,17 +1,10 @@
|
||||||
FROM node:24
|
FROM node:22
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# next build writes the rewrite destinations from next.config.mjs into
|
|
||||||
# .next/routes-manifest.json, so "next start" cannot pick up a different
|
|
||||||
# API URL later. The value has to be known here, not just at runtime.
|
|
||||||
ARG API_INTERNAL_URL=http://localhost:3000
|
|
||||||
ENV API_INTERNAL_URL=$API_INTERNAL_URL
|
|
||||||
|
|
||||||
RUN npm run build:api && npm run build:web
|
RUN npm run build:api && npm run build:web
|
||||||
|
|
||||||
RUN mkdir -p data && chmod +x scripts/docker-start.sh
|
RUN mkdir -p data && chmod +x scripts/docker-start.sh
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -62,29 +62,15 @@ docker compose logs --follow
|
||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
|
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
|
||||||
ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in
|
Produktionsdeployment. Details stehen in
|
||||||
[Deployment und Betrieb](docs/deployment.md).
|
[Deployment und Betrieb](docs/deployment.md).
|
||||||
|
|
||||||
Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit
|
|
||||||
Quellcode-Mounts und Watchern:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
docker compose -f compose.dev.yaml up --build --detach
|
|
||||||
docker compose -f compose.dev.yaml logs --follow
|
|
||||||
docker compose -f compose.dev.yaml down
|
|
||||||
```
|
|
||||||
|
|
||||||
Die Watcher darin laufen im Polling-Modus, weil Bind-Mounts unter Windows und
|
|
||||||
macOS keine inotify-Events durchreichen. Das kostet dauerhaft CPU, auch wenn
|
|
||||||
niemand die Anwendung benutzt — deshalb gehört dieser Stack nicht auf einen
|
|
||||||
Server.
|
|
||||||
|
|
||||||
## Direkte lokale Entwicklung
|
## Direkte lokale Entwicklung
|
||||||
|
|
||||||
Voraussetzungen:
|
Voraussetzungen:
|
||||||
|
|
||||||
- Node.js 24
|
- Node.js 22
|
||||||
- npm
|
- npm
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
# Development stack: source mounts, watching dev servers, hot reload.
|
|
||||||
# docker compose -f compose.dev.yaml up --build
|
|
||||||
#
|
|
||||||
# The polling watchers below are needed for bind mounts on Windows and
|
|
||||||
# macOS, where inotify events do not cross the VM boundary. They cost
|
|
||||||
# continuous CPU, which is why the production stack in compose.yaml does
|
|
||||||
# not run watchers at all.
|
|
||||||
name: leistungsbilanz-dev
|
|
||||||
|
|
||||||
x-logging: &logging
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "20m"
|
|
||||||
max-file: "10"
|
|
||||||
|
|
||||||
services:
|
|
||||||
api:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
command:
|
|
||||||
- sh
|
|
||||||
- -c
|
|
||||||
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
|
|
||||||
environment:
|
|
||||||
PORT: "3000"
|
|
||||||
CHOKIDAR_USEPOLLING: "true"
|
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
|
||||||
init: true
|
|
||||||
restart: unless-stopped
|
|
||||||
logging: *logging
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
|
||||||
- ./src:/app/src
|
|
||||||
- ./scripts:/app/scripts
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
|
|
||||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD
|
|
||||||
- node
|
|
||||||
- -e
|
|
||||||
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
|
||||||
interval: 30s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
|
|
||||||
web:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
command:
|
|
||||||
- npm
|
|
||||||
- run
|
|
||||||
- dev:web
|
|
||||||
- --
|
|
||||||
- --hostname
|
|
||||||
- 0.0.0.0
|
|
||||||
environment:
|
|
||||||
API_INTERNAL_URL: http://api:3000
|
|
||||||
WATCHPACK_POLLING: "true"
|
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-debug}"
|
|
||||||
init: true
|
|
||||||
restart: unless-stopped
|
|
||||||
logging: *logging
|
|
||||||
depends_on:
|
|
||||||
api:
|
|
||||||
condition: service_healthy
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
volumes:
|
|
||||||
- ./src:/app/src
|
|
||||||
- ./next.config.mjs:/app/next.config.mjs:ro
|
|
||||||
- ./tsconfig.json:/app/tsconfig.json:ro
|
|
||||||
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
|
|
||||||
healthcheck:
|
|
||||||
test:
|
|
||||||
- CMD
|
|
||||||
- node
|
|
||||||
- -e
|
|
||||||
- fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
|
||||||
interval: 30s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
65
compose.yaml
65
compose.yaml
|
|
@ -1,73 +1,82 @@
|
||||||
name: leistungsbilanz
|
name: leistungsbilanz
|
||||||
|
|
||||||
x-build: &build
|
|
||||||
context: .
|
|
||||||
args:
|
|
||||||
# Baked into .next/routes-manifest.json by next build; see Dockerfile.
|
|
||||||
API_INTERNAL_URL: http://api:3000
|
|
||||||
|
|
||||||
x-logging: &logging
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: "20m"
|
|
||||||
max-file: "10"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
build: *build
|
build:
|
||||||
|
context: .
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js
|
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
|
||||||
PORT: "3000"
|
PORT: "3000"
|
||||||
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
init: true
|
init: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *logging
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "10"
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./src:/app/src
|
||||||
|
- ./scripts:/app/scripts
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
|
||||||
|
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 30s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 12
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: *build
|
build:
|
||||||
|
context: .
|
||||||
command:
|
command:
|
||||||
- node_modules/.bin/next
|
- npm
|
||||||
- start
|
- run
|
||||||
- -p
|
- dev:web
|
||||||
- "3001"
|
- --
|
||||||
|
- --hostname
|
||||||
|
- 0.0.0.0
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
|
||||||
API_INTERNAL_URL: http://api:3000
|
API_INTERNAL_URL: http://api:3000
|
||||||
|
WATCHPACK_POLLING: "true"
|
||||||
NEXT_TELEMETRY_DISABLED: "1"
|
NEXT_TELEMETRY_DISABLED: "1"
|
||||||
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
LOG_LEVEL: "${LOG_LEVEL:-info}"
|
||||||
init: true
|
init: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging: *logging
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "20m"
|
||||||
|
max-file: "10"
|
||||||
depends_on:
|
depends_on:
|
||||||
api:
|
api:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
|
volumes:
|
||||||
|
- ./src:/app/src
|
||||||
|
- ./next.config.mjs:/app/next.config.mjs:ro
|
||||||
|
- ./tsconfig.json:/app/tsconfig.json:ro
|
||||||
|
- ./tsconfig.next.json:/app/tsconfig.next.json:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
- CMD
|
- CMD
|
||||||
- node
|
- node
|
||||||
- -e
|
- -e
|
||||||
- fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||||
interval: 30s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 12
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,19 @@
|
||||||
|
|
||||||
## Aktueller Status
|
## Aktueller Status
|
||||||
|
|
||||||
Es gibt zwei Compose-Stacks.
|
Es gibt derzeit kein unterstütztes Produktionsdeployment.
|
||||||
|
|
||||||
`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
|
`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet
|
||||||
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
|
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
|
||||||
für einen Server.
|
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes
|
||||||
|
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
|
||||||
|
öffentlich erreichbar gemacht werden.
|
||||||
|
|
||||||
`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom
|
## Entwicklungs-Topologie
|
||||||
Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows
|
|
||||||
und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
|
|
||||||
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
|
|
||||||
Entwicklungsrechner.
|
|
||||||
|
|
||||||
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
|
|
||||||
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
|
|
||||||
erreichbar gemacht werden.
|
|
||||||
|
|
||||||
## Topologie
|
|
||||||
|
|
||||||
| Komponente | Port | Healthcheck | Persistenz |
|
| Komponente | Port | Healthcheck | Persistenz |
|
||||||
| --- | ---: | --- | --- |
|
| --- | ---: | --- | --- |
|
||||||
| Next.js Web | 3001 | `GET /web-health` | keine |
|
| Next.js Web | 3001 | `GET /` | keine |
|
||||||
| Express API | 3000 | `GET /health` | `./data:/app/data` |
|
| Express API | 3000 | `GET /health` | `./data:/app/data` |
|
||||||
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
|
||||||
|
|
||||||
|
|
@ -32,22 +24,15 @@ Verwendete Umgebungsvariablen:
|
||||||
- `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz
|
- `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz
|
||||||
`http://api:3000`
|
`http://api:3000`
|
||||||
- `NEXT_TELEMETRY_DISABLED=1`
|
- `NEXT_TELEMETRY_DISABLED=1`
|
||||||
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in
|
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale
|
||||||
`compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg
|
Dateibeobachtung in Docker
|
||||||
- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten
|
- `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten
|
||||||
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
|
||||||
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
Setzbar über eine `.env`-Datei neben `compose.yaml` oder
|
||||||
`LOG_LEVEL=verbose docker compose up`.
|
`LOG_LEVEL=verbose docker compose up`.
|
||||||
|
|
||||||
Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
|
Beim API-Start laufen zuerst `npm run db:migrate` und
|
||||||
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im
|
`npm run db:verify:circuit-schema`.
|
||||||
Entwicklungsstack über `npm run db:migrate` und
|
|
||||||
`npm run db:verify:circuit-schema`).
|
|
||||||
|
|
||||||
`API_INTERNAL_URL` wirkt für `next start` zur **Build-Zeit**: `next build`
|
|
||||||
schreibt die Rewrite-Ziele aus `next.config.mjs` fest in
|
|
||||||
`.next/routes-manifest.json`. `compose.yaml` reicht den Wert deshalb als
|
|
||||||
Build-Argument an das Image durch, nicht nur als Laufzeit-Variable.
|
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
|
|
|
||||||
23
package-lock.json
generated
23
package-lock.json
generated
|
|
@ -22,15 +22,12 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "24.x"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@drizzle-team/brocli": {
|
"node_modules/@drizzle-team/brocli": {
|
||||||
|
|
@ -1517,13 +1514,12 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.3",
|
"version": "25.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
|
|
@ -3692,11 +3688,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
"devOptional": true,
|
"devOptional": true
|
||||||
"license": "MIT"
|
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,6 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
|
"description": "Spreadsheet-style circuit list editor for electrical distribution planning",
|
||||||
"main": "dist/server/index.js",
|
"main": "dist/server/index.js",
|
||||||
"engines": {
|
|
||||||
"node": "24.x"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev:api",
|
"dev": "npm run dev:api",
|
||||||
"dev:api": "tsx watch src/server/index.ts",
|
"dev:api": "tsx watch src/server/index.ts",
|
||||||
|
|
@ -13,9 +10,6 @@
|
||||||
"docker:up": "docker compose up --build --detach",
|
"docker:up": "docker compose up --build --detach",
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"docker:logs": "docker compose logs --follow",
|
"docker:logs": "docker compose logs --follow",
|
||||||
"docker:dev:up": "docker compose -f compose.dev.yaml up --build --detach",
|
|
||||||
"docker:dev:down": "docker compose -f compose.dev.yaml down",
|
|
||||||
"docker:dev:logs": "docker compose -f compose.dev.yaml logs --follow",
|
|
||||||
"build": "npm run build:api",
|
"build": "npm run build:api",
|
||||||
"build:api": "tsc -p tsconfig.json",
|
"build:api": "tsc -p tsconfig.json",
|
||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
|
|
@ -46,7 +40,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^25.6.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
// Liveness probe for the web container itself. The "/health" path is
|
|
||||||
// rewritten to the API in next.config.mjs, so it cannot answer for this
|
|
||||||
// process. Kept as a route handler so a probe does not render a page.
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export function GET() {
|
|
||||||
return NextResponse.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
@ -14,7 +14,6 @@ import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
|
@ -120,32 +119,9 @@ export class ProjectDeviceRowSyncProjectCommandRepository
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const assignment of input.command.payload.rows) {
|
for (const assignment of input.command.payload.rows) {
|
||||||
const values: typeof assignment.target & {
|
|
||||||
manualQuantity?: number;
|
|
||||||
} = { ...assignment.target };
|
|
||||||
if (assignment.target.quantity !== assignment.expected.quantity) {
|
|
||||||
const externalTotal = tx
|
|
||||||
.select({ planningValues: externalModelObjects.planningValues })
|
|
||||||
.from(externalModelObjects)
|
|
||||||
.where(
|
|
||||||
eq(externalModelObjects.circuitDeviceRowId, assignment.rowId)
|
|
||||||
)
|
|
||||||
.all()
|
|
||||||
.reduce(
|
|
||||||
(sum, object) => sum + object.planningValues.effectiveQuantity,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
const manualQuantity = assignment.target.quantity - externalTotal;
|
|
||||||
if (manualQuantity < 0) {
|
|
||||||
throw new Error(
|
|
||||||
"Synchronized quantity is below the total quantity of linked external objects."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
values.manualQuantity = manualQuantity;
|
|
||||||
}
|
|
||||||
const updated = tx
|
const updated = tx
|
||||||
.update(circuitDeviceRows)
|
.update(circuitDeviceRows)
|
||||||
.set(values)
|
.set(assignment.target)
|
||||||
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
.where(eq(circuitDeviceRows.id, assignment.rowId))
|
||||||
.run();
|
.run();
|
||||||
if (updated.changes !== 1) {
|
if (updated.changes !== 1) {
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,8 @@ import { createLogger } from "./shared/logging/logger";
|
||||||
const logger = createLogger("web:navigation");
|
const logger = createLogger("web:navigation");
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
// Probes hit "/web-health" and are excluded by the matcher below. The
|
// The Docker healthcheck hits "/" every few seconds with no User-Agent
|
||||||
// User-Agent guard stays as a fallback for anything else that polls
|
// header; skip it so real navigation isn't drowned out in the logs.
|
||||||
// without one, so real navigation isn't drowned out in the logs.
|
|
||||||
if (request.headers.get("user-agent")) {
|
if (request.headers.get("user-agent")) {
|
||||||
logger.info("page request", {
|
logger.info("page request", {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
|
|
@ -18,7 +17,5 @@ export function proxy(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
|
||||||
"/((?!_next/static|_next/image|favicon.ico|api|web-health).*)",
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,9 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
import { externalImportBatches } from "../src/db/schema/external-import-batches.js";
|
|
||||||
import { externalModelObjects } from "../src/db/schema/external-model-objects.js";
|
|
||||||
import { externalModelSources } from "../src/db/schema/external-model-sources.js";
|
|
||||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
|
|
||||||
import {
|
import {
|
||||||
createProjectDeviceRowSyncProjectCommand,
|
createProjectDeviceRowSyncProjectCommand,
|
||||||
type ProjectDeviceSyncRowSnapshot,
|
type ProjectDeviceSyncRowSnapshot,
|
||||||
|
|
@ -167,79 +163,6 @@ function getRow(context: DatabaseContext, rowId: string) {
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkExternalObject(
|
|
||||||
context: DatabaseContext,
|
|
||||||
rowId: string,
|
|
||||||
effectiveQuantity: number
|
|
||||||
) {
|
|
||||||
context.db
|
|
||||||
.insert(externalModelSources)
|
|
||||||
.values({
|
|
||||||
id: "source-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "Revit",
|
|
||||||
sourceType: "revit_csv",
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
context.db
|
|
||||||
.insert(externalImportBatches)
|
|
||||||
.values({
|
|
||||||
id: "batch-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
importKind: "initial",
|
|
||||||
importedAtIso: "2026-08-02T16:00:00.000Z",
|
|
||||||
fileName: "revit.csv",
|
|
||||||
sha256: "a".repeat(64),
|
|
||||||
appliedProjectRevision: 0,
|
|
||||||
configurationVersion: 1,
|
|
||||||
configurationSnapshot: externalCsvTestConfiguration,
|
|
||||||
originalBytes: Buffer.from("test"),
|
|
||||||
document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] },
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
context.db
|
|
||||||
.insert(externalModelObjects)
|
|
||||||
.values({
|
|
||||||
id: "object-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
sourceId: "source-1",
|
|
||||||
ifcGuid: "ifc-1",
|
|
||||||
lastSeenImportBatchId: "batch-1",
|
|
||||||
lastAcceptedImportBatchId: "batch-1",
|
|
||||||
acceptedSourceValues: {
|
|
||||||
rowNumber: 2,
|
|
||||||
roomNumber: "101",
|
|
||||||
roomName: "Büro",
|
|
||||||
familyAndType: "Leuchte: Standard",
|
|
||||||
selectionMarker: "Leuchte",
|
|
||||||
circuitIdentifier: "-1F1",
|
|
||||||
power: "30",
|
|
||||||
quantity: String(effectiveQuantity),
|
|
||||||
additionalSourceValues: {},
|
|
||||||
},
|
|
||||||
planningValues: {
|
|
||||||
displayName: "Leuchte",
|
|
||||||
internalDeviceType: "luminaire",
|
|
||||||
category: "single_phase",
|
|
||||||
connectionKind: "fixed",
|
|
||||||
effectiveQuantity,
|
|
||||||
powerPerUnitW: 30,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: null,
|
|
||||||
costGroup: null,
|
|
||||||
remark: null,
|
|
||||||
},
|
|
||||||
overriddenFields: [],
|
|
||||||
externalRoomMappingId: null,
|
|
||||||
distributionBoardId: null,
|
|
||||||
linkedProjectDeviceId: null,
|
|
||||||
circuitDeviceRowId: rowId,
|
|
||||||
presenceStatus: "present",
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshot(
|
function snapshot(
|
||||||
context: DatabaseContext,
|
context: DatabaseContext,
|
||||||
rowId: string
|
rowId: string
|
||||||
|
|
@ -551,112 +474,6 @@ describe("project-device row sync project-command repository", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps manualQuantity from exceeding quantity when a synced quantity shrinks", () => {
|
|
||||||
const fixture = createTestDatabase();
|
|
||||||
try {
|
|
||||||
fixture.context.db
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set({ quantity: 5, manualQuantity: 5 })
|
|
||||||
.where(eq(circuitDeviceRows.id, "row-1"))
|
|
||||||
.run();
|
|
||||||
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
|
||||||
fixture.context.db
|
|
||||||
);
|
|
||||||
const expected = snapshot(fixture.context, "row-1");
|
|
||||||
store.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createProjectDeviceRowSyncProjectCommand(
|
|
||||||
"project-device-1",
|
|
||||||
"synchronize",
|
|
||||||
[
|
|
||||||
{
|
|
||||||
rowId: "row-1",
|
|
||||||
expected,
|
|
||||||
target: { ...expected, quantity: 2 },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const row = getRow(fixture.context, "row-1");
|
|
||||||
assert.equal(row.quantity, 2);
|
|
||||||
assert.equal(row.manualQuantity, 2);
|
|
||||||
} finally {
|
|
||||||
fixture.context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("subtracts linked external objects when a synced quantity shrinks", () => {
|
|
||||||
const fixture = createTestDatabase();
|
|
||||||
try {
|
|
||||||
fixture.context.db
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set({ quantity: 5, manualQuantity: 2 })
|
|
||||||
.where(eq(circuitDeviceRows.id, "row-1"))
|
|
||||||
.run();
|
|
||||||
linkExternalObject(fixture.context, "row-1", 3);
|
|
||||||
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
|
||||||
fixture.context.db
|
|
||||||
);
|
|
||||||
const expected = snapshot(fixture.context, "row-1");
|
|
||||||
store.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createProjectDeviceRowSyncProjectCommand(
|
|
||||||
"project-device-1",
|
|
||||||
"synchronize",
|
|
||||||
[{ rowId: "row-1", expected, target: { ...expected, quantity: 4 } }]
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const row = getRow(fixture.context, "row-1");
|
|
||||||
assert.equal(row.quantity, 4);
|
|
||||||
assert.equal(row.manualQuantity, 1);
|
|
||||||
} finally {
|
|
||||||
fixture.context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a synced quantity below the linked external total", () => {
|
|
||||||
const fixture = createTestDatabase();
|
|
||||||
try {
|
|
||||||
fixture.context.db
|
|
||||||
.update(circuitDeviceRows)
|
|
||||||
.set({ quantity: 5, manualQuantity: 2 })
|
|
||||||
.where(eq(circuitDeviceRows.id, "row-1"))
|
|
||||||
.run();
|
|
||||||
linkExternalObject(fixture.context, "row-1", 3);
|
|
||||||
const store = new ProjectDeviceRowSyncProjectCommandRepository(
|
|
||||||
fixture.context.db
|
|
||||||
);
|
|
||||||
const expected = snapshot(fixture.context, "row-1");
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
store.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: 0,
|
|
||||||
source: "user",
|
|
||||||
command: createProjectDeviceRowSyncProjectCommand(
|
|
||||||
"project-device-1",
|
|
||||||
"synchronize",
|
|
||||||
[{ rowId: "row-1", expected, target: { ...expected, quantity: 2 } }]
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
/below the total quantity of linked external objects/
|
|
||||||
);
|
|
||||||
const row = getRow(fixture.context, "row-1");
|
|
||||||
assert.equal(row.quantity, 5);
|
|
||||||
assert.equal(row.manualQuantity, 2);
|
|
||||||
assert.equal(
|
|
||||||
fixture.context.db.select().from(projectRevisions).all().length,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
fixture.context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rolls back synchronized rows for a stale project revision", () => {
|
it("rolls back synchronized rows for a stale project revision", () => {
|
||||||
const fixture = createTestDatabase();
|
const fixture = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue