v1.3: Serverseitige Datenbank (PostgreSQL + Node-API) ersetzt localStorage

- server/: Express-API (Auth, Bootstrap, Tickets, Threads, Benutzerverwaltung, Codes)
  mit pg-Schema (11 Tabellen), idempotem Seed (50 Wohnungen, 552 Buchungen)
- mp-db (PostgreSQL 16, Volume mp_pgdata) + mp-api im Compose-Stack,
  nginx proxiet /api/ an mp-api; deploy.sh baut API-Image auf dem Server
- Frontend: Store laedt /api/bootstrap, Schreibzugriff async, kein localStorage
  mehr (ausser Theme), Ladebildschirm beim Session-Check, vite-Proxy fuer Dev
- Rollenrechte serverseitig: Mieter nur eigene Wohnung (getestet, 403-Verweis)
- Mail-Benachrichtigungen komplett serverseitig (server/src/mail.js)
- beispieldaten.py schreibt server/seed/generated.json; Verwaltungs-Mail = axel@hocks.eu
- E2E live verifiziert: Login, Scoping, Ticket + Zustellung (status=sent),
  Registrierung, Codes; Testdaten bereinigt; Doku nachgefuehrt
This commit is contained in:
2026-10-02 12:54:44 +02:00
parent 5503ce5fc5
commit 311153b3c3
26 changed files with 8873 additions and 894 deletions
+20
View File
@@ -0,0 +1,20 @@
# Hinterstein Mieterportal – API-Container (Node + PostgreSQL-Client)
# Build: docker compose build mp-api
# Die Datenbank läuft als eigener Service (mp-db) im selben Compose-Projekt.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev --no-audit --no-fund
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
COPY seed ./seed
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
CMD wget -qO- http://localhost:3000/api/healthz >/dev/null 2>&1 || exit 1
CMD ["node", "src/index.js"]
+982
View File
@@ -0,0 +1,982 @@
{
"name": "mieterportal-api",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mieterportal-api",
"version": "1.3.0",
"dependencies": {
"express": "^4.21.2",
"pg": "^8.13.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
"integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.16.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
"integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.13",
"proxy-addr": "~2.0.7",
"qs": "~6.16.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.23.1",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
"integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.1",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.1",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.1"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
"integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
"integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
"integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "mieterportal-api",
"version": "1.3.0",
"private": true,
"description": "Node-API + PostgreSQL für das Hinterstein Mieterportal",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"express": "^4.21.2",
"pg": "^8.13.1"
}
}
File diff suppressed because it is too large Load Diff
+129
View File
@@ -0,0 +1,129 @@
/** PostgreSQL-Verbindung + Schema für das Mieterportal.
*
* Tabellen werden beim Start idempotent angelegt (CREATE TABLE IF NOT EXISTS),
* danach lädt seed.js die Demo-Daten, falls die Datenbank leer ist.
*/
const { Pool } = require('pg')
const pool = new Pool({
connectionString:
process.env.DATABASE_URL || 'postgres://mp:mp@localhost:5432/mieterportal',
max: 10,
})
const SCHEMA = `
CREATE TABLE IF NOT EXISTS units (
id TEXT PRIMARY KEY,
building TEXT NOT NULL,
number TEXT NOT NULL,
floor INT NOT NULL,
size NUMERIC(8,2) NOT NULL,
cold_rent NUMERIC(10,2) NOT NULL,
warm_rent NUMERIC(10,2) NOT NULL,
tenant TEXT,
status TEXT NOT NULL CHECK (status IN ('vermietet','frei'))
);
CREATE TABLE IF NOT EXISTS transactions (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL REFERENCES units(id),
tenant TEXT NOT NULL,
month TEXT NOT NULL,
year INT NOT NULL,
amount NUMERIC(10,2) NOT NULL,
type TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('bezahlt','offen','überfällig')),
paid_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_transactions_unit ON transactions(unit_id);
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL REFERENCES units(id),
tenant TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
category TEXT NOT NULL,
priority TEXT NOT NULL CHECK (priority IN ('niedrig','mittel','hoch')),
status TEXT NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen','in Bearbeitung','erledigt')),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tickets_unit ON tickets(unit_id);
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
unit_id TEXT REFERENCES units(id), -- NULL = Hausdokument für alle
title TEXT NOT NULL,
category TEXT NOT NULL,
size TEXT NOT NULL,
date TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
unit_id TEXT NOT NULL REFERENCES units(id),
tenant TEXT NOT NULL,
subject TEXT NOT NULL,
unread_by_admin BOOLEAN NOT NULL DEFAULT FALSE,
unread_by_tenant BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_threads_unit ON threads(unit_id);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
from_role TEXT NOT NULL CHECK (from_role IN ('tenant','admin')),
author TEXT NOT NULL,
text TEXT NOT NULL,
at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id);
CREATE TABLE IF NOT EXISTS announcements (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
text TEXT NOT NULL,
date TEXT NOT NULL,
tag TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
benutzername TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
passwort_hash TEXT NOT NULL,
rolle TEXT NOT NULL CHECK (rolle IN ('verwaltung','mieter')),
email TEXT NOT NULL,
einheit_id TEXT REFERENCES units(id),
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
erstellt_am TEXT NOT NULL,
letzter_login TEXT
);
CREATE TABLE IF NOT EXISTS registration_codes (
code TEXT PRIMARY KEY,
einheit_id TEXT REFERENCES units(id),
erstellt_am TEXT NOT NULL,
verwendet_am TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
`
async function init() {
await pool.query(SCHEMA)
}
/** Aufräumen abgelaufener Sessions (gelegentlich aufgerufen). */
async function sessionsAufraeumen() {
await pool.query('DELETE FROM sessions WHERE expires < now()')
}
module.exports = { pool, init, sessionsAufraeumen }
+474
View File
@@ -0,0 +1,474 @@
/** Hinterstein Mieterportal – Node-API (Express + PostgreSQL).
*
* Authentifizierung: Session-Cookie mp_session (HttpOnly, 8 h), Passwörter als
* SHA-256-Hash (wie bisher im Browser berechnet, Vergleich serverseitig).
* Rollenrechte: Mieter sehen nur Daten ihrer Wohnung, Verwaltung alles.
* Benachrichtigungen (Ticket/Nachricht) werden serverseitig per Mail verschickt.
*/
const express = require('express')
const crypto = require('crypto')
const { pool, init, sessionsAufraeumen } = require('./db')
const { seed } = require('./seed')
const mail = require('./mail')
const PORT = Number(process.env.PORT || 3000)
const SESSION_DAUER_MS = 8 * 60 * 60 * 1000
const COOKIE_NAME = 'mp_session'
const app = express()
app.use(express.json({ limit: '256kb' }))
app.set('trust proxy', true)
// ── Cookie-Helfer (bewusst ohne Dependency) ──────────────────
function cookieLesen(req, name) {
const raw = req.headers.cookie || ''
for (const teil of raw.split(';')) {
const i = teil.indexOf('=')
if (i > -1 && teil.slice(0, i).trim() === name) return decodeURIComponent(teil.slice(i + 1).trim())
}
return null
}
function cookieSetzen(res, token, maxAgeMs, secure) {
res.append(
'Set-Cookie',
`${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; ` +
`Max-Age=${Math.floor(maxAgeMs / 1000)}${secure ? '; Secure' : ''}`,
)
}
// ── Auth-Middleware ──────────────────────────────────────────
async function auth(req, res, next) {
try {
const token = cookieLesen(req, COOKIE_NAME)
if (token) {
const { rows } = await pool.query(
`SELECT u.* FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = $1 AND s.expires > now() AND u.aktiv`,
[token],
)
if (rows.length) {
req.user = rows[0]
req.sessionToken = token
return next()
}
}
res.status(401).json({ fehler: 'Nicht angemeldet.' })
} catch (e) {
next(e)
}
}
function adminOnly(req, res, next) {
if (req.user.rolle !== 'verwaltung') return res.status(403).json({ fehler: 'Nur für die Verwaltung.' })
next()
}
const oeffentlicherBenutzer = (b) => ({
id: b.id,
benutzername: b.benutzername,
name: b.name,
rolle: b.rolle,
email: b.email,
einheitId: b.einheit_id,
aktiv: b.aktiv,
erstelltAm: b.erstellt_am,
letzterLogin: b.letzter_login ?? undefined,
})
async function sessionAnlegen(res, userId, req) {
const token = crypto.randomBytes(32).toString('hex')
await pool.query(
'INSERT INTO sessions (token, user_id, expires) VALUES ($1, $2, now() + $3 * interval \'1 millisecond\')',
[token, userId, SESSION_DAUER_MS],
)
cookieSetzen(res, token, SESSION_DAUER_MS, req.secure)
}
// ── Mapping-Helfer (snake_case → camelCase) ──────────────────
const mapUnit = (u) => ({
id: u.id, building: u.building, number: u.number, floor: u.floor,
size: Number(u.size), coldRent: Number(u.cold_rent), warmRent: Number(u.warm_rent),
tenant: u.tenant, status: u.status,
})
const mapTransaction = (t) => ({
id: t.id, unitId: t.unit_id, tenant: t.tenant, month: t.month, year: t.year,
amount: Number(t.amount), type: t.type, status: t.status, paidAt: t.paid_at ?? undefined,
})
const mapTicket = (t) => ({
id: t.id, unitId: t.unit_id, tenant: t.tenant, title: t.title,
description: t.description, category: t.category, priority: t.priority,
status: t.status, createdAt: t.created_at,
})
const mapDocument = (d) => ({
id: d.id, unitId: d.unit_id, title: d.title, category: d.category, size: d.size, date: d.date,
})
const mapMessage = (m) => ({ id: m.id, from: m.from_role, author: m.author, text: m.text, at: m.at })
const mapThread = (t) => ({
id: t.id, unitId: t.unit_id, tenant: t.tenant, subject: t.subject,
unreadByAdmin: t.unread_by_admin, unreadByTenant: t.unread_by_tenant,
})
const mapCode = (c) => ({ code: c.code, einheitId: c.einheit_id, erstelltAm: c.erstellt_am, verwendetAm: c.verwendet_am ?? undefined })
const mapAnnouncement = (a) => ({ id: a.id, title: a.title, text: a.text, date: a.date, tag: a.tag })
const deutschesDatum = () => new Date().toLocaleDateString('de-DE')
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
// ── Health ───────────────────────────────────────────────────
app.get('/api/healthz', async (_req, res) => {
try {
await pool.query('SELECT 1')
res.json({ ok: true, dienst: 'mp-api', datenbank: true })
} catch {
res.status(503).json({ ok: false, datenbank: false })
}
})
// ── Auth ─────────────────────────────────────────────────────
app.post('/api/auth/login', async (req, res, next) => {
try {
const { benutzername, passwortHash } = req.body || {}
const { rows } = await pool.query(
'SELECT * FROM users WHERE lower(benutzername) = lower($1)',
[String(benutzername || '').trim()],
)
const b = rows[0]
if (!b || String(passwortHash || '') !== b.passwort_hash)
return res.status(401).json({ fehler: 'Benutzername oder Passwort falsch.' })
if (!b.aktiv)
return res.status(403).json({ fehler: 'Dieses Konto ist deaktiviert – bitte die Verwaltung kontaktieren.' })
await pool.query('UPDATE users SET letzter_login = $1 WHERE id = $2', [deutschesDatumZeit(), b.id])
await sessionAnlegen(res, b.id, req)
res.json({ benutzer: oeffentlicherBenutzer(b) })
} catch (e) { next(e) }
})
app.post('/api/auth/logout', async (req, res) => {
const token = cookieLesen(req, COOKIE_NAME)
if (token) await pool.query('DELETE FROM sessions WHERE token = $1', [token])
cookieSetzen(res, '', 0, req.secure)
res.json({ ok: true })
})
app.get('/api/auth/me', auth, (req, res) => {
res.json({ benutzer: oeffentlicherBenutzer(req.user) })
})
app.post('/api/auth/register', async (req, res, next) => {
try {
const { code, benutzername, name, email, passwortHash } = req.body || {}
const codeNorm = String(code || '').trim().toUpperCase()
const benutzernameNorm = String(benutzername || '').trim()
// Erst Benutzername prüfen, dann Code atomar verbrauchen (sonst wäre der
// Code bei einem vergebenen Benutzernamen trotzdem verbraucht)
const vorhanden = await pool.query('SELECT 1 FROM users WHERE lower(benutzername) = lower($1)', [benutzernameNorm])
if (vorhanden.rows.length)
return res.status(400).json({ fehler: 'Dieser Benutzername ist bereits vergeben.' })
const { rows: codeRows } = await pool.query(
`UPDATE registration_codes SET verwendet_am = $1
WHERE code = $2 AND verwendet_am IS NULL
RETURNING einheit_id`,
[deutschesDatum(), codeNorm],
)
if (!codeRows.length)
return res.status(400).json({ fehler: 'Der Registrierungscode ist ungültig oder bereits verwendet.' })
const id = `b-${Date.now()}`
const { rows } = await pool.query(
`INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am)
VALUES ($1,$2,$3,$4,'mieter',$5,$6,TRUE,$7) RETURNING *`,
[id, benutzernameNorm, String(name || '').trim(), String(passwortHash || ''),
String(email || '').trim(), codeRows[0].einheit_id, deutschesDatum()],
)
await sessionAnlegen(res, id, req)
res.json({ benutzer: oeffentlicherBenutzer(rows[0]) })
} catch (e) { next(e) }
})
// ── Bootstrap (alle Stammdaten in einem Rutsch) ──────────────
app.get('/api/bootstrap', auth, async (req, res, next) => {
try {
const istAdmin = req.user.rolle === 'verwaltung'
const einheit = req.user.einheit_id
const [units, transactions, documents, tickets, threads, messages, announcements, users, codes] =
await Promise.all([
pool.query('SELECT * FROM units ORDER BY id'),
istAdmin
? pool.query('SELECT * FROM transactions ')
: pool.query('SELECT * FROM transactions WHERE unit_id = $1 ', [einheit]),
istAdmin
? pool.query('SELECT * FROM documents ORDER BY date DESC')
: pool.query('SELECT * FROM documents WHERE unit_id = $1 OR unit_id IS NULL ORDER BY date DESC', [einheit]),
istAdmin
? pool.query('SELECT * FROM tickets ORDER BY created_at DESC')
: pool.query('SELECT * FROM tickets WHERE unit_id = $1 ORDER BY created_at DESC', [einheit]),
istAdmin
? pool.query('SELECT * FROM threads ORDER BY id DESC')
: pool.query('SELECT * FROM threads WHERE unit_id = $1 ORDER BY id DESC', [einheit]),
istAdmin
? pool.query('SELECT * FROM messages')
: pool.query('SELECT m.* FROM messages m JOIN threads t ON t.id = m.thread_id WHERE t.unit_id = $1', [einheit]),
pool.query('SELECT * FROM announcements ORDER BY date DESC'),
istAdmin
? pool.query('SELECT * FROM users ORDER BY name')
: { rows: [] },
istAdmin
? pool.query('SELECT * FROM registration_codes ORDER BY erstellt_am DESC')
: { rows: [] },
])
// Threads mit ihren Nachrichten zusammenführen
const nachrichtenProThread = {}
for (const m of messages.rows) {
;(nachrichtenProThread[m.thread_id] ??= []).push(mapMessage(m))
}
const threadListe = threads.rows.map((t) => ({
...mapThread(t),
messages: nachrichtenProThread[t.id] ?? [],
}))
res.json({
units: units.rows.map(mapUnit),
transactions: transactions.rows.map(mapTransaction),
documents: documents.rows.map(mapDocument),
tickets: tickets.rows.map(mapTicket),
threads: threadListe,
announcements: announcements.rows.map(mapAnnouncement),
benutzer: users.rows.map(oeffentlicherBenutzer),
codes: codes.rows.map(mapCode),
})
} catch (e) { next(e) }
})
// ── Tickets ──────────────────────────────────────────────────
app.post('/api/tickets', auth, async (req, res, next) => {
try {
const { title, description, category, priority } = req.body || {}
if (!String(title || '').trim()) return res.status(400).json({ fehler: 'Titel fehlt.' })
const unitId = req.user.rolle === 'verwaltung'
? String((req.body || {}).unitId || 'hs-1')
: req.user.einheit_id
const { rows: unitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId])
if (!unitRows.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' })
const id = `t-${Date.now()}`
const mieterName = req.user.rolle === 'verwaltung' ? 'Hinterstein Immobilien (intern)' : req.user.name
const { rows } = await pool.query(
`INSERT INTO tickets (id, unit_id, tenant, title, description, category, priority, status, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,'offen',$8) RETURNING *`,
[id, unitId, mieterName, String(title).trim(), String(description || '–').trim(),
String(category || 'Sonstiges'), ['niedrig', 'mittel', 'hoch'].includes(priority) ? priority : 'mittel',
deutschesDatum()],
)
const label = mail.einheitLabel(mapUnit(unitRows[0]))
mail.benachrichtigeVerwaltung(
`Neue Schadensmeldung: ${String(title).trim()} (${label})`,
`Im Mieterportal wurde eine neue Schadensmeldung erstellt.\n\n` +
`Wohnung: ${label}\nMeldung von: ${mieterName}\nKategorie: ${String(category || 'Sonstiges')}\n\n` +
`${String(title).trim()}\n${String(description || '–').trim()}`,
)
res.json({ ticket: mapTicket(rows[0]) })
} catch (e) { next(e) }
})
app.patch('/api/tickets/:id', auth, adminOnly, async (req, res, next) => {
try {
const status = String((req.body || {}).status || '')
if (!['offen', 'in Bearbeitung', 'erledigt'].includes(status))
return res.status(400).json({ fehler: 'Ungültiger Status.' })
const { rows } = await pool.query(
'UPDATE tickets SET status = $1 WHERE id = $2 RETURNING *', [status, req.params.id],
)
if (!rows.length) return res.status(404).json({ fehler: 'Ticket nicht gefunden.' })
res.json({ ticket: mapTicket(rows[0]) })
} catch (e) { next(e) }
})
// ── Nachrichten (Threads) ────────────────────────────────────
app.post('/api/threads', auth, async (req, res, next) => {
try {
const { subject, text, unitId: gewuenschteEinheit } = req.body || {}
if (!String(subject || '').trim() || !String(text || '').trim())
return res.status(400).json({ fehler: 'Betreff und Nachricht fehlen.' })
const unitId = req.user.rolle === 'verwaltung'
? String(gewuenschteEinheit || 'hs-1')
: req.user.einheit_id
const { rows: unitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [unitId])
if (!unitRows.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' })
const id = `m-${Date.now()}`
const vonRolle = req.user.rolle === 'verwaltung' ? 'admin' : 'tenant'
const autor = req.user.name
await pool.query(
`INSERT INTO threads (id, unit_id, tenant, subject, unread_by_admin, unread_by_tenant)
VALUES ($1,$2,$3,$4,$5,$6)`,
[id, unitId, req.user.rolle === 'verwaltung' ? autor : (unitRows[0].tenant ?? autor),
String(subject).trim(), vonRolle === 'tenant', vonRolle === 'admin'],
)
await pool.query(
`INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`,
[`${id}-1`, id, vonRolle, autor, String(text).trim(), deutschesDatumZeit()],
)
const betreff = vonRolle === 'tenant'
? `Neue Nachricht im Mieterportal: ${String(subject).trim()}`
: `Nachricht der Verwaltung: ${String(subject).trim()}`
const inhalt = `Neue Unterhaltung von ${autor}:\n\n„${String(text).trim()}“`
if (vonRolle === 'tenant') mail.benachrichtigeVerwaltung(betreff, inhalt)
else mail.benachrichtigeMieter(unitId, betreff, inhalt)
const { rows } = await pool.query('SELECT * FROM threads WHERE id = $1', [id])
const { rows: msgRows } = await pool.query('SELECT * FROM messages WHERE thread_id = $1 ORDER BY at', [id])
res.json({ thread: { ...mapThread(rows[0]), messages: msgRows.map(mapMessage) } })
} catch (e) { next(e) }
})
app.post('/api/threads/:id/messages', auth, async (req, res, next) => {
try {
const text = String((req.body || {}).text || '').trim()
if (!text) return res.status(400).json({ fehler: 'Nachricht fehlt.' })
const { rows: threadRows } = await pool.query('SELECT * FROM threads WHERE id = $1', [req.params.id])
const th = threadRows[0]
if (!th) return res.status(404).json({ fehler: 'Unterhaltung nicht gefunden.' })
if (req.user.rolle === 'mieter' && th.unit_id !== req.user.einheit_id)
return res.status(403).json({ fehler: 'Kein Zugriff auf diese Unterhaltung.' })
const vonRolle = req.user.rolle === 'verwaltung' ? 'admin' : 'tenant'
const autor = req.user.name
const msgId = `${th.id}-${Date.now()}`
await pool.query(
`INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`,
[msgId, th.id, vonRolle, autor, text, deutschesDatumZeit()],
)
await pool.query(
`UPDATE threads SET unread_by_admin = $1, unread_by_tenant = $2 WHERE id = $3`,
[vonRolle === 'tenant', vonRolle === 'admin', th.id],
)
const betreff = vonRolle === 'tenant'
? `Neue Antwort im Mieterportal: ${th.subject}`
: `Antwort der Verwaltung: ${th.subject}`
const inhalt = `Neue Nachricht von ${autor} in der Unterhaltung „${th.subject}“:\n\n„${text}“`
if (vonRolle === 'tenant') mail.benachrichtigeVerwaltung(betreff, inhalt)
else mail.benachrichtigeMieter(th.unit_id, betreff, inhalt)
const { rows: msgRows } = await pool.query('SELECT * FROM messages WHERE thread_id = $1 ORDER BY at', [th.id])
const { rows } = await pool.query('SELECT * FROM threads WHERE id = $1', [th.id])
res.json({ thread: { ...mapThread(rows[0]), messages: msgRows.map(mapMessage) } })
} catch (e) { next(e) }
})
app.post('/api/threads/:id/read', auth, async (req, res, next) => {
try {
const { rows: threadRows } = await pool.query('SELECT unit_id FROM threads WHERE id = $1', [req.params.id])
const th = threadRows[0]
if (!th) return res.status(404).json({ fehler: 'Unterhaltung nicht gefunden.' })
if (req.user.rolle === 'mieter' && th.unit_id !== req.user.einheit_id)
return res.status(403).json({ fehler: 'Kein Zugriff auf diese Unterhaltung.' })
if (req.user.rolle === 'verwaltung')
await pool.query('UPDATE threads SET unread_by_admin = FALSE WHERE id = $1', [req.params.id])
else
await pool.query('UPDATE threads SET unread_by_tenant = FALSE WHERE id = $1', [req.params.id])
res.json({ ok: true })
} catch (e) { next(e) }
})
// ── Benutzerverwaltung (nur Verwaltung) ──────────────────────
app.get('/api/users', auth, adminOnly, async (_req, res, next) => {
try {
const { rows } = await pool.query('SELECT * FROM users ORDER BY name')
res.json({ benutzer: rows.map(oeffentlicherBenutzer) })
} catch (e) { next(e) }
})
app.post('/api/users', auth, adminOnly, async (req, res, next) => {
try {
const { benutzername, name, rolle, email, passwortHash, einheitId } = req.body || {}
if (!['verwaltung', 'mieter'].includes(rolle)) return res.status(400).json({ fehler: 'Ungültige Rolle.' })
const benutzernameNorm = String(benutzername || '').trim()
const vorhanden = await pool.query('SELECT 1 FROM users WHERE lower(benutzername) = lower($1)', [benutzernameNorm])
if (vorhanden.rows.length) return res.status(400).json({ fehler: 'Dieser Benutzername ist bereits vergeben.' })
const id = `b-${Date.now()}`
const { rows } = await pool.query(
`INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am)
VALUES ($1,$2,$3,$4,$5,$6,$7,TRUE,$8) RETURNING *`,
[id, benutzernameNorm, String(name || '').trim(), String(passwortHash || ''), rolle,
String(email || '').trim(), rolle === 'mieter' ? (einheitId || null) : null, deutschesDatum()],
)
res.json({ benutzer: oeffentlicherBenutzer(rows[0]) })
} catch (e) { next(e) }
})
app.patch('/api/users/:id', auth, adminOnly, async (req, res, next) => {
try {
const b = req.body || {}
const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])
if (!rows.length) return res.status(404).json({ fehler: 'Benutzer nicht gefunden.' })
if (b.passwortHash !== undefined)
await pool.query('UPDATE users SET passwort_hash = $1 WHERE id = $2', [String(b.passwortHash), req.params.id])
if (b.aktiv !== undefined)
await pool.query('UPDATE users SET aktiv = $1 WHERE id = $2', [Boolean(b.aktiv), req.params.id])
if (b.einheitId !== undefined)
await pool.query('UPDATE users SET einheit_id = $1 WHERE id = $2', [b.einheitId || null, req.params.id])
const { rows: neu } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])
res.json({ benutzer: oeffentlicherBenutzer(neu[0]) })
} catch (e) { next(e) }
})
// ── Registrierungscodes (nur Verwaltung) ─────────────────────
app.get('/api/codes', auth, adminOnly, async (_req, res, next) => {
try {
const { rows } = await pool.query('SELECT * FROM registration_codes ORDER BY erstellt_am DESC')
res.json({ codes: rows.map(mapCode) })
} catch (e) { next(e) }
})
app.post('/api/codes', auth, adminOnly, async (req, res, next) => {
try {
const anzahl = Math.min(Math.max(parseInt((req.body || {}).anzahl, 10) || 1, 1), 50)
const einheitId = (req.body || {}).einheitId || null
if (einheitId) {
const u = await pool.query('SELECT 1 FROM units WHERE id = $1', [einheitId])
if (!u.rows.length) return res.status(400).json({ fehler: 'Wohnung unbekannt.' })
}
const teil = () =>
Array.from(crypto.randomBytes(2)).map((b) => b.toString(16).padStart(2, '0')).join('').toUpperCase()
const neu = []
for (let i = 0; i < anzahl; i++) {
const code = `MP-${teil()}-${teil()}`
await pool.query(
'INSERT INTO registration_codes (code, einheit_id, erstellt_am) VALUES ($1,$2,$3)',
[code, einheitId, deutschesDatum()],
)
neu.push({ code, einheitId, erstelltAm: deutschesDatum() })
}
res.json({ codes: neu })
} catch (e) { next(e) }
})
// ── Fehlerbehandlung ─────────────────────────────────────────
app.use((err, _req, res, _next) => {
console.error('[api]', err)
res.status(500).json({ fehler: 'Interner Serverfehler.' })
})
// ── Start ────────────────────────────────────────────────────
async function warteAufDatenbank() {
let letzterFehler = null
for (let versuch = 1; versuch <= 30; versuch++) {
try {
await init()
return
} catch (e) {
letzterFehler = e
console.log(`[api] Datenbank nicht bereit (Versuch ${versuch}/30) …`)
await new Promise((r) => setTimeout(r, 2000))
}
}
throw letzterFehler
}
async function start() {
// Warten, bis die Datenbank erreichbar ist (Container-Start-Reihenfolge)
await warteAufDatenbank()
const geseedet = await seed()
if (geseedet) console.log('[api] Demo-Daten eingespielt (50 Wohnungen, 552 Buchungen, …)')
setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref()
app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`))
}
start().catch((e) => {
console.error('[api] Start fehlgeschlagen:', e)
process.exit(1)
})
+49
View File
@@ -0,0 +1,49 @@
/** Serverseitiger E-Mail-Versand über die Portal-Mail-API (mp-mail-api).
*
* Fire-and-forget: Ein Fehler (Mail-API down, Relay-Fehler) beeinträchtigt
* die auslösende Portal-Aktion nie. Die Mail-API selbst puffert im Fehlerfall
* in ihrer Outbox und versucht es erneut.
*/
const MAIL_API_URL = process.env.MAIL_API_URL || '' // z. B. http://mp-mail-api:8080
const MAIL_API_TOKEN = process.env.MAIL_API_TOKEN || ''
const FUSSZEILE =
'\n\n— Automatische Benachrichtigung aus dem Mieterportal der Hinterstein Immobilien'
async function sendeMail(an, betreff, text) {
if (!MAIL_API_URL || !MAIL_API_TOKEN || !an) return
try {
const res = await fetch(`${MAIL_API_URL}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Api-Token': MAIL_API_TOKEN },
body: JSON.stringify({ to: an, subject: betreff, text: `${text}${FUSSZEILE}` }),
})
if (!res.ok) console.warn(`[mail] Versand an ${an} fehlgeschlagen: HTTP ${res.status}`)
} catch (e) {
console.warn(`[mail] Versand an ${an} fehlgeschlagen:`, e.message)
}
}
/** Alle aktiven Verwaltungsnutzer benachrichtigen. */
async function benachrichtigeVerwaltung(betreff, text) {
const { rows } = await require('./db').pool.query(
`SELECT email FROM users WHERE rolle = 'verwaltung' AND aktiv AND email <> ''`,
)
for (const r of rows) sendeMail(r.email, betreff, text)
}
/** Mieter einer bestimmten Wohnung benachrichtigen. */
async function benachrichtigeMieter(einheitId, betreff, text) {
const { rows } = await require('./db').pool.query(
`SELECT email FROM users WHERE rolle = 'mieter' AND aktiv AND einheit_id = $1 AND email <> ''`,
[einheitId],
)
for (const r of rows) sendeMail(r.email, betreff, text)
}
function einheitLabel(u) {
return u ? `${u.building}, Whg. ${u.number}` : ''
}
module.exports = { sendeMail, benachrichtigeVerwaltung, benachrichtigeMieter, einheitLabel }
+144
View File
@@ -0,0 +1,144 @@
/** Handgeschriebene Demo-Daten (vormals src/data/seed.ts).
*
* units + transactions kommen maschinell aus seed/generated.json
* (beispieldaten.py). Die Verwaltungs-E-Mail ist bewusst ein echtes,
* zustellbares Postfach (axel@hocks.eu), damit Benachrichtigungen ankommen;
* im Portal änderbar (Benutzerverwaltung).
*/
const DEMO_HASH = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815")
const users = [
{
id: 'b-joseph',
benutzername: 'j.hinterstein',
name: 'Joseph Hinterstein',
passwort_hash: DEMO_HASH,
rolle: 'verwaltung',
email: 'axel@hocks.eu',
einheit_id: null,
aktiv: true,
erstellt_am: '01.03.2024',
},
{
id: 'b-anna',
benutzername: 'a.berger',
name: 'Anna Berger',
passwort_hash: DEMO_HASH,
rolle: 'mieter',
email: 'anna.berger@example.de',
einheit_id: 'hs-7',
aktiv: true,
erstellt_am: '01.03.2024',
letzter_login: '02.10.2026, 09:12',
},
{
id: 'b-jonas',
benutzername: 'm.wolf',
name: 'Jonas Klein',
passwort_hash: DEMO_HASH,
rolle: 'mieter',
email: 'jonas.klein@example.de',
einheit_id: 'rw-4',
aktiv: true,
erstellt_am: '15.08.2025',
},
]
const registrationCodes = [
{ code: 'MP-2026-0815', einheit_id: 'ap-2', erstellt_am: '02.10.2026', verwendet_am: null },
]
const tickets = [
{
id: 't-1', unit_id: 'hs-7', tenant: 'Anna Berger',
title: 'Wasserhahn im Bad tropft',
description: 'Der Wasserhahn am Waschbecken tropft seit einigen Tagen permanent, auch bei ganz zugedrehtem Ventil.',
category: 'Sanitär', priority: 'mittel', status: 'in Bearbeitung', created_at: '28.09.2026',
},
{
id: 't-2', unit_id: 'hs-7', tenant: 'Anna Berger',
title: 'Briefkastenschlüssel defekt',
description: 'Der Schlüssel für den Briefkasten lässt sich nicht mehr drehen, vermutlich Schloss verklebt.',
category: 'Schlüssel', priority: 'niedrig', status: 'offen', created_at: '30.09.2026',
},
{
id: 't-3', unit_id: 'rw-4', tenant: 'Jonas Klein',
title: 'Heizkörper wird nicht warm',
description: 'Der Heizkörper im Wohnzimmer bleibt kalt, obwohl das Thermostat voll aufgedreht ist.',
category: 'Heizung', priority: 'hoch', status: 'offen', created_at: '01.10.2026',
},
{
id: 't-4', unit_id: 'ap-2', tenant: 'Anja Roth',
title: 'Rollladen im Schlafzimmer klemmt',
description: 'Der elektrische Rollladen fährt nur noch zur Hälfte hoch.',
category: 'Fenster / Rollladen', priority: 'mittel', status: 'in Bearbeitung', created_at: '25.09.2026',
},
{
id: 't-5', unit_id: 'hs-14', tenant: 'Markus Lange',
title: 'Flurbeleuchtung im Treppenhaus defekt',
description: 'Im 3. OG ist die Bewegungsmelder-Leuchte ausgefallen.',
category: 'Elektrik', priority: 'hoch', status: 'erledigt', created_at: '18.09.2026',
},
{
id: 't-6', unit_id: 'rw-9', tenant: 'Sandra Weiß',
title: 'Schimmel im Bad – Lüftungsproblem?',
description: 'In der Fensterfalz des Bades sammelt sich Kondenswasser, bitte um Prüfung der Lüftung.',
category: 'Sanitär', priority: 'hoch', status: 'in Bearbeitung', created_at: '22.09.2026',
},
{
id: 't-7', unit_id: 'ap-11', tenant: 'Thomas Neumann',
title: 'Markise auf dem Balkon lose',
description: 'Die Befestigung der Markise wackelt bei Wind deutlich.',
category: 'Balkon / Außenanlage', priority: 'hoch', status: 'offen', created_at: '29.09.2026',
},
{
id: 't-8', unit_id: 'hs-3', tenant: 'Petra Hoffmann',
title: 'Spülmaschinen-Anschluss undicht',
description: 'Unter der Spüle steht Wasser, vermutlich kommt es vom Geräteanschluss.',
category: 'Sanitär', priority: 'mittel', status: 'erledigt', created_at: '10.09.2026',
},
]
// Mieter-Namen für tickets der fremden Wohnungen stammen aus generated.json;
// seed.js ersetzt leere/Platzhalter-Namen beim Einfügen aus der units-Tabelle.
const documents = [
{ id: 'd-1', unit_id: 'hs-7', title: 'Mietvertrag Anna Berger – Hauptstraße 12, Whg. 7', category: 'Mietvertrag', size: '1,8 MB', date: '01.03.2024' },
{ id: 'd-2', unit_id: 'hs-7', title: 'Nebenkostenabrechnung 2025', category: 'Nebenkostenabrechnung', size: '640 KB', date: '31.05.2026' },
{ id: 'd-3', unit_id: 'hs-7', title: 'Heizkostenabrechnung 2025', category: 'Nebenkostenabrechnung', size: '410 KB', date: '31.05.2026' },
{ id: 'd-4', unit_id: null, title: 'Hausordnung – gültige Fassung 2026', category: 'Hausordnung', size: '220 KB', date: '01.01.2026' },
{ id: 'd-5', unit_id: null, title: 'Protokoll Hausversammlung 14.06.2026', category: 'Protokoll', size: '350 KB', date: '14.06.2026' },
{ id: 'd-6', unit_id: null, title: 'Mülltonnen-Belegungsplan & Abfuhrtermine 2026', category: 'Sonstiges', size: '180 KB', date: '02.01.2026' },
{ id: 'd-7', unit_id: 'rw-4', title: 'Mietvertrag – Ringweg 3, Whg. 4', category: 'Mietvertrag', size: '1,6 MB', date: '15.08.2025' },
{ id: 'd-8', unit_id: 'ap-2', title: 'Mietvertrag – Am Park 5, Whg. 2', category: 'Mietvertrag', size: '1,7 MB', date: '01.06.2024' },
]
const threads = [
{
id: 'm-1', unit_id: 'hs-7', tenant: 'Anna Berger',
subject: 'Mietkaution – Rückzahlungsnachweis',
unread_by_admin: false, unread_by_tenant: true,
messages: [
{ id: 'm-1-a', from_role: 'tenant', author: 'Anna Berger', text: 'Guten Tag, könnten Sie mir bitte den aktuellen Stand meiner Mietkaution mit Nachweis der Verzinsung zusenden?', at: '27.09.2026, 10:12' },
{ id: 'm-1-b', from_role: 'admin', author: 'Joseph Hinterstein', text: 'Guten Tag Frau Berger, die Kaution wurde mit 1.500,00 € auf dem Treuhandkonto verbucht. Die Verzinsung für 2025 beträgt 18,40 € – den Nachweis finden Sie ab sofort auch unter Dokumente.', at: '29.09.2026, 09:03' },
],
},
{
id: 'm-2', unit_id: 'rw-9', tenant: 'Sandra Weiß',
subject: 'Terminabstimmung Wohnungsbegehung (Schimmel)',
unread_by_admin: true, unread_by_tenant: false,
messages: [
{ id: 'm-2-a', from_role: 'tenant', author: 'Sandra Weiß', text: 'Aufgrund der Schadensmeldung schlage ich einen Begehungstermin nächste Woche vor. Passt Mittwoch oder Freitag Nachmittag?', at: '01.10.2026, 08:45' },
],
},
]
const announcements = [
{ id: 'a-1', title: 'Wasserabschaltung Hauptstraße 12', text: 'Am Donnerstag, 08.10.2026, 09:00–13:00 Uhr findet eine Druckerhöhungs-Pumpen-Wartung statt. In dieser Zeit steht kein Wasser zur Verfügung.', date: '02.10.2026', tag: 'Technik' },
{ id: 'a-2', title: 'Herbst-Hausbegehung mit Hausmeister', text: 'Am Samstag, 17.10.2026, 10:00 Uhr geht es gemeinsam durch alle Häuser. Anschließend gibt es Kaffee im Hof von Am Park 5.', date: '28.09.2026', tag: 'Termin' },
{ id: 'a-3', title: 'Tiefgarage Ringweg 3: Reinigung', text: 'Die Tiefgarage wird am 21.10.2026 gereinigt. Bitte Fahrzeuge bis 07:30 Uhr entfernen, sonst erfolgt ein kostenpflichtiges Versetzen.', date: '20.09.2026', tag: 'Hinweis' },
{ id: 'a-4', title: 'Nebenkostenabrechnungen 2025 versandt', text: 'Alle Abrechnungen liegen digital im Dokumentenbereich vor. Nachzahlungen sind bis 31.10.2026 überwiesen.', date: '31.05.2026', tag: 'Abrechnung' },
]
module.exports = { users, registrationCodes, tickets, documents, threads, announcements }
+100
View File
@@ -0,0 +1,100 @@
/** Idempotenter Seed: füllt die Datenbank beim ersten Start mit den
* Demo-Daten (50 Wohnungen, 552 Buchungen, Tickets, Dokumente, Threads,
* Ankündigungen, Demo-Benutzer, Registrierungscode). Läuft nur, wenn die
* users-Tabelle leer ist – bestehende Daten werden nie überschrieben.
*/
const { pool } = require('./db')
const { users, registrationCodes, tickets, documents, threads, announcements } = require('./seed-data')
const generated = require('../seed/generated.json')
async function seed() {
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM users')
if (rows[0].n > 0) return false
const client = await pool.connect()
try {
await client.query('BEGIN')
for (const u of generated.units) {
await client.query(
`INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[u.id, u.building, u.number, u.floor, u.size, u.coldRent, u.warmRent, u.tenant, u.status],
)
}
for (const t of generated.transactions) {
await client.query(
`INSERT INTO transactions (id, unit_id, tenant, month, year, amount, type, status, paid_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[t.id, t.unitId, t.tenant, t.month, t.year, t.amount, t.type, t.status, t.paidAt ?? null],
)
}
// Mieter-Namen in Tickets/Threads aus der units-Tabelle nachziehen
const { rows: unitRows } = await client.query('SELECT id, tenant FROM units')
const mieterVon = Object.fromEntries(unitRows.map((u) => [u.id, u.tenant]))
for (const t of tickets) {
await client.query(
`INSERT INTO tickets (id, unit_id, tenant, title, description, category, priority, status, created_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
[t.id, t.unit_id, mieterVon[t.unit_id] ?? t.tenant, t.title, t.description, t.category, t.priority, t.status, t.created_at],
)
}
for (const d of documents) {
await client.query(
`INSERT INTO documents (id, unit_id, title, category, size, date) VALUES ($1,$2,$3,$4,$5,$6)`,
[d.id, d.unit_id, d.title, d.category, d.size, d.date],
)
}
for (const th of threads) {
await client.query(
`INSERT INTO threads (id, unit_id, tenant, subject, unread_by_admin, unread_by_tenant)
VALUES ($1,$2,$3,$4,$5,$6)`,
[th.id, th.unit_id, mieterVon[th.unit_id] ?? th.tenant, th.subject, th.unread_by_admin, th.unread_by_tenant],
)
for (const m of th.messages) {
await client.query(
`INSERT INTO messages (id, thread_id, from_role, author, text, at) VALUES ($1,$2,$3,$4,$5,$6)`,
[m.id, th.id, m.from_role, m.author, m.text, m.at],
)
}
}
for (const a of announcements) {
await client.query(
`INSERT INTO announcements (id, title, text, date, tag) VALUES ($1,$2,$3,$4,$5)`,
[a.id, a.title, a.text, a.date, a.tag],
)
}
for (const b of users) {
await client.query(
`INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am, letzter_login)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[b.id, b.benutzername, b.name, b.passwort_hash, b.rolle, b.email, b.einheit_id, b.aktiv, b.erstellt_am, b.letzter_login ?? null],
)
}
for (const c of registrationCodes) {
await client.query(
`INSERT INTO registration_codes (code, einheit_id, erstellt_am, verwendet_am) VALUES ($1,$2,$3,$4)`,
[c.code, c.einheit_id, c.erstellt_am, c.verwendet_am],
)
}
await client.query('COMMIT')
return true
} catch (e) {
await client.query('ROLLBACK')
throw e
} finally {
client.release()
}
}
module.exports = { seed }