Initial scaffold: React/Vite/TS PWA + Express/Prisma/Postgres backend
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:
@@ -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"
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -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.' });
|
||||
}
|
||||
});
|
||||
@@ -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.' });
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user