Initial scaffold: React/Vite/TS PWA + Express/Prisma/Postgres backend
Build and Deploy / version (push) Successful in 1s
Build and Deploy / build (push) Failing after 1s
Build and Deploy / deploy (push) Skipped

Materialschein & Kabelrechner für BergVOLT Elektrotechnik, portiert von der
vorherigen Single-File-HTML-Version. Microsoft-Entra-ID-Login (MSAL), Projekt-
Dashboard statt einer Datei pro Kunde, Retour-Buchen-Zusammenführung, 111
Kabeltypen aus den offiziellen Meinhart-Datenblättern. gitops/ nach
PROX/deploy-standard: git-backed Portainer-Stack, versionierte Docker-Images,
CI-Commit-back auf gitops/stack.yml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fidelis Huber
2026-08-21 17:24:38 +02:00
co-authored by Claude Sonnet 5
commit 13cef3c96d
39 changed files with 1798 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
{
"name": "materialschein-server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev"
},
"dependencies": {
"@prisma/client": "^5.20.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"jwks-rsa": "^3.1.0",
"jsonwebtoken": "^9.0.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.7.4",
"prisma": "^5.20.0",
"tsx": "^4.19.1",
"typescript": "^5.6.2"
}
}
@@ -0,0 +1,38 @@
-- CreateEnum
CREATE TYPE "ProjektStatus" AS ENUM ('Aktiv', 'Abgeschlossen');
-- CreateTable
CREATE TABLE "projekte" (
"id" TEXT NOT NULL,
"titel" TEXT NOT NULL,
"auftraggeber" TEXT,
"datum" DATE,
"monteur" TEXT,
"notiz" TEXT,
"status" "ProjektStatus" NOT NULL DEFAULT 'Aktiv',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "projekte_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "material" (
"id" TEXT NOT NULL,
"projektId" TEXT NOT NULL,
"bezeichnung" TEXT NOT NULL,
"artNr" TEXT,
"menge" DECIMAL(10,2) NOT NULL,
"einheit" TEXT NOT NULL,
"notiz" TEXT,
"erstelltVon" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "material_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "material_projektId_idx" ON "material"("projektId");
-- AddForeignKey
ALTER TABLE "material" ADD CONSTRAINT "material_projektId_fkey" FOREIGN KEY ("projektId") REFERENCES "projekte"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+44
View File
@@ -0,0 +1,44 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum ProjektStatus {
Aktiv
Abgeschlossen
}
model Projekt {
id String @id @default(cuid())
titel String
auftraggeber String?
datum DateTime? @db.Date
monteur String?
notiz String?
status ProjektStatus @default(Aktiv)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
material Material[]
@@map("projekte")
}
model Material {
id String @id @default(cuid())
projektId String
projekt Projekt @relation(fields: [projektId], references: [id], onDelete: Cascade)
bezeichnung String
artNr String?
menge Decimal @db.Decimal(10, 2)
einheit String
notiz String?
erstelltVon String?
createdAt DateTime @default(now())
@@index([projektId])
@@map("material")
}
+54
View File
@@ -0,0 +1,54 @@
import type { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
const CLIENT_ID = process.env.MSAL_CLIENT_ID ?? '';
const TENANT_ID = process.env.MSAL_TENANT_ID ?? '';
const jwks = jwksClient({
jwksUri: `https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`,
cache: true,
cacheMaxAge: 12 * 60 * 60 * 1000
});
function getKey(header: jwt.JwtHeader, callback: (err: Error | null, key?: string) => void) {
if (!header.kid) { callback(new Error('Kein kid im Token-Header')); return; }
jwks.getSigningKey(header.kid, (err, key) => {
if (err || !key) { callback(err ?? new Error('Kein Signing-Key gefunden')); return; }
callback(null, key.getPublicKey());
});
}
export interface AuthedRequest extends Request {
user?: { email: string; name: string; oid: string };
}
// Verifiziert das MSAL-Access-/ID-Token (Entra ID) im Authorization-Header.
// Prüft Signatur, Tenant (tid) und dass das Token für unsere App ausgestellt wurde (aud).
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Kein Token übermittelt.' });
return;
}
const token = header.slice('Bearer '.length);
jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => {
if (err || !decoded || typeof decoded === 'string') {
res.status(401).json({ error: 'Token ungültig: ' + (err?.message ?? 'unbekannt') });
return;
}
const claims = decoded as jwt.JwtPayload & { tid?: string; aud?: string; preferred_username?: string; upn?: string; name?: string; oid?: string };
if (claims.tid !== TENANT_ID) {
res.status(401).json({ error: 'Falscher Tenant.' });
return;
}
if (claims.aud !== CLIENT_ID) {
res.status(401).json({ error: 'Token nicht für diese App ausgestellt.' });
return;
}
const email = claims.preferred_username ?? claims.upn ?? '';
req.user = { email, name: claims.name ?? email, oid: claims.oid ?? '' };
next();
});
}
+32
View File
@@ -0,0 +1,32 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import cors from 'cors';
import { requireAuth } from './auth.js';
import { projekteRouter } from './routes/projekte.js';
import { materialRouter } from './routes/material.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT ?? 80);
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const app = express();
app.use(cors());
app.use(express.json());
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.use('/api/projekte', requireAuth, projekteRouter);
app.use('/api/material', requireAuth, materialRouter);
// Statisch gebautes React/Vite-Frontend ausliefern.
app.use(express.static(PUBLIC_DIR));
// SPA-Fallback für client-seitiges Routing (react-router) alles außer /api/*.
app.get(/^(?!\/api\/).*/, (_req, res) => {
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
});
app.listen(PORT, () => {
console.log(`Materialschein-Server läuft auf Port ${PORT}`);
});
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient();
+60
View File
@@ -0,0 +1,60 @@
import { Router } from 'express';
import { prisma } from '../prisma.js';
import type { AuthedRequest } from '../auth.js';
export const materialRouter = Router();
// GET /api/material?projektId=xxx
materialRouter.get('/', async (req, res) => {
const projektId = String(req.query.projektId ?? '');
if (!projektId) { res.status(400).json({ error: 'projektId erforderlich.' }); return; }
const items = await prisma.material.findMany({
where: { projektId },
orderBy: { createdAt: 'asc' }
});
res.json(items);
});
materialRouter.post('/', async (req: AuthedRequest, res) => {
const { projektId, bezeichnung, artNr, menge, einheit, notiz } = req.body ?? {};
if (!projektId || !bezeichnung || menge === undefined || !einheit) {
res.status(400).json({ error: 'projektId, bezeichnung, menge und einheit sind erforderlich.' });
return;
}
const item = await prisma.material.create({
data: {
projektId,
bezeichnung,
artNr: artNr || null,
menge,
einheit,
notiz: notiz || null,
erstelltVon: req.user?.email ?? null
}
});
res.status(201).json(item);
});
materialRouter.patch('/:id', async (req, res) => {
const { id } = req.params;
const { menge, notiz } = req.body ?? {};
const data: Record<string, unknown> = {};
if (menge !== undefined) data.menge = menge;
if (notiz !== undefined) data.notiz = notiz || null;
try {
const item = await prisma.material.update({ where: { id }, data });
res.json(item);
} catch {
res.status(404).json({ error: 'Position nicht gefunden.' });
}
});
materialRouter.delete('/:id', async (req, res) => {
const { id } = req.params;
try {
await prisma.material.delete({ where: { id } });
res.status(204).end();
} catch {
res.status(404).json({ error: 'Position nicht gefunden.' });
}
});
+46
View File
@@ -0,0 +1,46 @@
import { Router } from 'express';
import { prisma } from '../prisma.js';
import type { AuthedRequest } from '../auth.js';
export const projekteRouter = Router();
projekteRouter.get('/', async (_req, res) => {
const projekte = await prisma.projekt.findMany({ orderBy: { createdAt: 'desc' } });
res.json(projekte);
});
projekteRouter.post('/', async (req: AuthedRequest, res) => {
const { titel, auftraggeber, datum, monteur, notiz } = req.body ?? {};
if (!titel || typeof titel !== 'string' || !titel.trim()) {
res.status(400).json({ error: 'Titel (Projekt/Baustelle) ist erforderlich.' });
return;
}
const projekt = await prisma.projekt.create({
data: {
titel: titel.trim(),
auftraggeber: auftraggeber || null,
datum: datum ? new Date(datum) : null,
monteur: monteur || null,
notiz: notiz || null
}
});
res.status(201).json(projekt);
});
projekteRouter.patch('/:id', async (req, res) => {
const { id } = req.params;
const { titel, auftraggeber, datum, monteur, notiz, status } = req.body ?? {};
const data: Record<string, unknown> = {};
if (titel !== undefined) data.titel = titel;
if (auftraggeber !== undefined) data.auftraggeber = auftraggeber || null;
if (datum !== undefined) data.datum = datum ? new Date(datum) : null;
if (monteur !== undefined) data.monteur = monteur || null;
if (notiz !== undefined) data.notiz = notiz || null;
if (status !== undefined) data.status = status;
try {
const projekt = await prisma.projekt.update({ where: { id }, data });
res.json(projekt);
} catch {
res.status(404).json({ error: 'Projekt nicht gefunden.' });
}
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}