🚀 Backend komplett implementiert
Features: - Auth mit JWT + Argon2 (Login, Register, Refresh) - Rollen-System (Chef/Disponent/Mitarbeiter) - User Management mit Berechtigungen - Aufträge mit Zuweisungen - Verfügbarkeitsplanung - Stundenzettel mit Foto-Upload Support - Modulares System mit Config - Entwickler-Panel Endpoints Tech: - Deno + Oak - PostgreSQL - CORS enabled
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL=postgres://secu:SeCu2026!SecureDB@localhost:5434/secu
|
||||||
|
|
||||||
|
# JWT Secret (change in production!)
|
||||||
|
JWT_SECRET=secu-super-secret-key-change-in-production
|
||||||
|
|
||||||
|
# Server Port
|
||||||
|
PORT=8004
|
||||||
|
|
||||||
|
# CORS Origins (comma-separated)
|
||||||
|
CORS_ORIGINS=http://localhost:3006,https://secu.kronos-soulution.de
|
||||||
134
README.md
134
README.md
@@ -1,3 +1,133 @@
|
|||||||
# secu-backend
|
# SeCu Backend
|
||||||
|
|
||||||
SeCu Backend - Deno + Oak + PostgreSQL
|
Deno + Oak Backend für die SeCu Mitarbeiterverwaltung.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Runtime:** Deno
|
||||||
|
- **Framework:** Oak
|
||||||
|
- **Database:** PostgreSQL
|
||||||
|
- **Auth:** JWT + Argon2
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Datenbank einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# PostgreSQL Container starten
|
||||||
|
docker run -d \
|
||||||
|
--name secu-db \
|
||||||
|
-e POSTGRES_USER=secu \
|
||||||
|
-e POSTGRES_PASSWORD=SeCu2026!SecureDB \
|
||||||
|
-e POSTGRES_DB=secu \
|
||||||
|
-p 5434:5432 \
|
||||||
|
postgres:16
|
||||||
|
|
||||||
|
# Migrations ausführen
|
||||||
|
psql -h localhost -p 5434 -U secu -d secu -f ../secu/db/migrations/001_initial_schema.sql
|
||||||
|
psql -h localhost -p 5434 -U secu -d secu -f ../secu/db/migrations/002_seed_modules.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# JWT_SECRET ändern!
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development (mit Watch)
|
||||||
|
deno task dev
|
||||||
|
|
||||||
|
# Production
|
||||||
|
deno task start
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Auth (`/api/auth`)
|
||||||
|
|
||||||
|
| Method | Endpoint | Beschreibung |
|
||||||
|
|--------|----------|--------------|
|
||||||
|
| POST | /register | Registrierung (erster User = Chef) |
|
||||||
|
| POST | /login | Login |
|
||||||
|
| POST | /refresh | Token erneuern |
|
||||||
|
| POST | /logout | Logout |
|
||||||
|
| GET | /me | Aktueller User |
|
||||||
|
| POST | /change-password | Passwort ändern |
|
||||||
|
|
||||||
|
### Users (`/api/users`)
|
||||||
|
|
||||||
|
| Method | Endpoint | Beschreibung | Rolle |
|
||||||
|
|--------|----------|--------------|-------|
|
||||||
|
| GET | / | Alle User | Chef: alle, Disponent: eigene MA |
|
||||||
|
| GET | /:id | User Details | - |
|
||||||
|
| POST | / | User anlegen | Chef/Disponent |
|
||||||
|
| PUT | /:id | User bearbeiten | - |
|
||||||
|
| DELETE | /:id | User deaktivieren | Chef/Disponent |
|
||||||
|
|
||||||
|
### Orders (`/api/orders`)
|
||||||
|
|
||||||
|
| Method | Endpoint | Beschreibung | Rolle |
|
||||||
|
|--------|----------|--------------|-------|
|
||||||
|
| GET | / | Alle Aufträge | MA: nur zugewiesene |
|
||||||
|
| GET | /:id | Auftrag Details | - |
|
||||||
|
| POST | / | Auftrag erstellen | Chef/Disponent |
|
||||||
|
| PUT | /:id | Auftrag bearbeiten | Chef/Disponent |
|
||||||
|
| DELETE | /:id | Auftrag löschen | Chef/Disponent |
|
||||||
|
| POST | /:id/assign | MA zuweisen | Chef/Disponent |
|
||||||
|
| DELETE | /:id/assign/:userId | Zuweisung entfernen | Chef/Disponent |
|
||||||
|
| PUT | /:id/assignment | Bestätigen/Ablehnen | MA |
|
||||||
|
|
||||||
|
### Availability (`/api/availability`)
|
||||||
|
|
||||||
|
| Method | Endpoint | Beschreibung |
|
||||||
|
|--------|----------|--------------|
|
||||||
|
| GET | / | Verfügbarkeiten |
|
||||||
|
| GET | /calendar | Kalender-Übersicht |
|
||||||
|
| POST | / | Verfügbarkeit melden |
|
||||||
|
| POST | /bulk | Mehrere Tage |
|
||||||
|
| DELETE | /:id | Eintrag löschen |
|
||||||
|
|
||||||
|
### Timesheets (`/api/timesheets`)
|
||||||
|
|
||||||
|
| Method | Endpoint | Beschreibung | Rolle |
|
||||||
|
|--------|----------|--------------|-------|
|
||||||
|
| GET | / | Stundenzettel | MA: nur eigene |
|
||||||
|
| GET | /:id | Details | - |
|
||||||
|
| POST | / | Einreichen | Alle |
|
||||||
|
| PUT | /:id | Bearbeiten | Nur pending + eigene |
|
||||||
|
| POST | /:id/review | Genehmigen/Ablehnen | Chef/Disponent |
|
||||||
|
| DELETE | /:id | Löschen | - |
|
||||||
|
| GET | /summary/:userId | Zusammenfassung | Chef/Disponent |
|
||||||
|
| POST | /upload | Foto hochladen | Alle |
|
||||||
|
|
||||||
|
### Modules (`/api/modules`)
|
||||||
|
|
||||||
|
| Method | Endpoint | Beschreibung | Rolle |
|
||||||
|
|--------|----------|--------------|-------|
|
||||||
|
| GET | / | Alle Module | Alle |
|
||||||
|
| GET | /org | Org-Konfiguration | Alle |
|
||||||
|
| POST | /:id/toggle | Ein/Ausschalten | Chef |
|
||||||
|
| PUT | /:id/config | Konfigurieren | Chef |
|
||||||
|
| GET | /check/:name | Status prüfen | Alle |
|
||||||
|
| GET | /developer/status | System-Status | Chef + Dev-Modul |
|
||||||
|
| GET | /developer/logs | Audit-Logs | Chef + Dev-Modul |
|
||||||
|
|
||||||
|
## Rollen
|
||||||
|
|
||||||
|
| Rolle | Kürzel | Berechtigungen |
|
||||||
|
|-------|--------|----------------|
|
||||||
|
| Chef | `chef` | Vollzugriff |
|
||||||
|
| Disponent | `disponent` | MA verwalten, Aufträge, Stundenzettel |
|
||||||
|
| Mitarbeiter | `mitarbeiter` | Eigene Aufträge/Verfügbarkeit/Stundenzettel |
|
||||||
|
|
||||||
|
## Port
|
||||||
|
|
||||||
|
Standard: `8004`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*SeCu Backend v1.0.0*
|
||||||
|
|||||||
13
deno.json
Normal file
13
deno.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": "deno run --watch --allow-net --allow-env --allow-read src/main.ts",
|
||||||
|
"start": "deno run --allow-net --allow-env --allow-read src/main.ts",
|
||||||
|
"check": "deno check src/main.ts"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@oak/oak": "jsr:@oak/oak@^17"
|
||||||
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/db/postgres.ts
Normal file
54
src/db/postgres.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
|
||||||
|
|
||||||
|
const DATABASE_URL = Deno.env.get("DATABASE_URL") ||
|
||||||
|
"postgres://secu:SeCu2026!SecureDB@localhost:5434/secu";
|
||||||
|
|
||||||
|
let pool: Pool;
|
||||||
|
|
||||||
|
export async function initDB(): Promise<void> {
|
||||||
|
pool = new Pool(DATABASE_URL, 10);
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const result = await client.queryObject`SELECT NOW()`;
|
||||||
|
console.log("✅ Database connected:", result.rows[0]);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPool(): Pool {
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function query<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const result = await client.queryObject<T>({
|
||||||
|
text: sql,
|
||||||
|
args: params || [],
|
||||||
|
});
|
||||||
|
return result.rows;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queryOne<T>(sql: string, params?: unknown[]): Promise<T | null> {
|
||||||
|
const rows = await query<T>(sql, params);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function execute(sql: string, params?: unknown[]): Promise<number> {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const result = await client.queryObject({
|
||||||
|
text: sql,
|
||||||
|
args: params || [],
|
||||||
|
});
|
||||||
|
return result.rowCount || 0;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/main.ts
Normal file
53
src/main.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Application } from "@oak/oak";
|
||||||
|
import { oakCors } from "https://deno.land/x/cors@v1.2.2/mod.ts";
|
||||||
|
import { authRouter } from "./routes/auth.ts";
|
||||||
|
import { usersRouter } from "./routes/users.ts";
|
||||||
|
import { ordersRouter } from "./routes/orders.ts";
|
||||||
|
import { availabilityRouter } from "./routes/availability.ts";
|
||||||
|
import { timesheetsRouter } from "./routes/timesheets.ts";
|
||||||
|
import { modulesRouter } from "./routes/modules.ts";
|
||||||
|
import { errorHandler } from "./middleware/error.ts";
|
||||||
|
import { requestLogger } from "./middleware/logger.ts";
|
||||||
|
import { initDB } from "./db/postgres.ts";
|
||||||
|
|
||||||
|
const app = new Application();
|
||||||
|
const PORT = parseInt(Deno.env.get("PORT") || "8004");
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
await initDB();
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(errorHandler);
|
||||||
|
app.use(requestLogger);
|
||||||
|
app.use(oakCors({
|
||||||
|
origin: [
|
||||||
|
"http://localhost:3006",
|
||||||
|
"http://localhost:5173",
|
||||||
|
"https://secu.kronos-soulution.de",
|
||||||
|
],
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use(authRouter.routes());
|
||||||
|
app.use(authRouter.allowedMethods());
|
||||||
|
app.use(usersRouter.routes());
|
||||||
|
app.use(usersRouter.allowedMethods());
|
||||||
|
app.use(ordersRouter.routes());
|
||||||
|
app.use(ordersRouter.allowedMethods());
|
||||||
|
app.use(availabilityRouter.routes());
|
||||||
|
app.use(availabilityRouter.allowedMethods());
|
||||||
|
app.use(timesheetsRouter.routes());
|
||||||
|
app.use(timesheetsRouter.allowedMethods());
|
||||||
|
app.use(modulesRouter.routes());
|
||||||
|
app.use(modulesRouter.allowedMethods());
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.use((ctx) => {
|
||||||
|
if (ctx.request.url.pathname === "/health") {
|
||||||
|
ctx.response.body = { status: "ok", service: "secu-backend", version: "1.0.0" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🔐 SeCu Backend running on http://localhost:${PORT}`);
|
||||||
|
await app.listen({ port: PORT });
|
||||||
62
src/middleware/auth.ts
Normal file
62
src/middleware/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { Context, Next } from "@oak/oak";
|
||||||
|
import { verifyToken } from "../utils/auth.ts";
|
||||||
|
import { AppError } from "./error.ts";
|
||||||
|
import type { UserRole, AuthContext } from "../types/index.ts";
|
||||||
|
|
||||||
|
// Extend Oak context with auth
|
||||||
|
declare module "@oak/oak" {
|
||||||
|
interface State {
|
||||||
|
auth: AuthContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth middleware - requires valid JWT
|
||||||
|
export async function authMiddleware(ctx: Context, next: Next): Promise<void> {
|
||||||
|
const authHeader = ctx.request.headers.get("Authorization");
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
throw new AppError("No token provided", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
const payload = await verifyToken(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
throw new AppError("Invalid or expired token", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.state.auth = {
|
||||||
|
user: {
|
||||||
|
id: payload.sub,
|
||||||
|
org_id: payload.org,
|
||||||
|
role: payload.role,
|
||||||
|
email: payload.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role-based access control middleware
|
||||||
|
export function requireRole(...allowedRoles: UserRole[]) {
|
||||||
|
return async (ctx: Context, next: Next): Promise<void> => {
|
||||||
|
await authMiddleware(ctx, async () => {
|
||||||
|
const userRole = ctx.state.auth.user.role;
|
||||||
|
|
||||||
|
if (!allowedRoles.includes(userRole)) {
|
||||||
|
throw new AppError("Insufficient permissions", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chef only
|
||||||
|
export const requireChef = requireRole("chef");
|
||||||
|
|
||||||
|
// Chef or Disponent
|
||||||
|
export const requireDisponentOrHigher = requireRole("chef", "disponent");
|
||||||
|
|
||||||
|
// Any authenticated user
|
||||||
|
export const requireAuth = authMiddleware;
|
||||||
28
src/middleware/error.ts
Normal file
28
src/middleware/error.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Context, Next } from "@oak/oak";
|
||||||
|
|
||||||
|
export class AppError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status = 500) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function errorHandler(ctx: Context, next: Next): Promise<void> {
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
ctx.response.status = err.status;
|
||||||
|
ctx.response.body = { error: err.message };
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
console.error("Unhandled error:", err);
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Internal server error" };
|
||||||
|
} else {
|
||||||
|
ctx.response.status = 500;
|
||||||
|
ctx.response.body = { error: "Unknown error" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/middleware/logger.ts
Normal file
17
src/middleware/logger.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Context, Next } from "@oak/oak";
|
||||||
|
|
||||||
|
export async function requestLogger(ctx: Context, next: Next): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
await next();
|
||||||
|
|
||||||
|
const ms = Date.now() - start;
|
||||||
|
const status = ctx.response.status;
|
||||||
|
const method = ctx.request.method;
|
||||||
|
const url = ctx.request.url.pathname;
|
||||||
|
|
||||||
|
const color = status >= 500 ? "\x1b[31m" : status >= 400 ? "\x1b[33m" : "\x1b[32m";
|
||||||
|
const reset = "\x1b[0m";
|
||||||
|
|
||||||
|
console.log(`${color}${status}${reset} ${method} ${url} - ${ms}ms`);
|
||||||
|
}
|
||||||
239
src/routes/auth.ts
Normal file
239
src/routes/auth.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { query, queryOne, execute } from "../db/postgres.ts";
|
||||||
|
import {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
generateAccessToken,
|
||||||
|
generateRefreshToken,
|
||||||
|
generateRandomToken,
|
||||||
|
verifyToken
|
||||||
|
} from "../utils/auth.ts";
|
||||||
|
import { AppError } from "../middleware/error.ts";
|
||||||
|
import { authMiddleware } from "../middleware/auth.ts";
|
||||||
|
import type { User } from "../types/index.ts";
|
||||||
|
|
||||||
|
export const authRouter = new Router({ prefix: "/api/auth" });
|
||||||
|
|
||||||
|
// Register (first user becomes chef, or must be created by higher role)
|
||||||
|
authRouter.post("/register", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { email, password, first_name, last_name, phone, org_slug } = body;
|
||||||
|
|
||||||
|
if (!email || !password || !first_name || !last_name || !org_slug) {
|
||||||
|
throw new AppError("Missing required fields", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find organization
|
||||||
|
const org = await queryOne<{ id: string }>(
|
||||||
|
"SELECT id FROM organizations WHERE slug = $1",
|
||||||
|
[org_slug]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
throw new AppError("Organization not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if email already exists
|
||||||
|
const existing = await queryOne<{ id: string }>(
|
||||||
|
"SELECT id FROM users WHERE org_id = $1 AND email = $2",
|
||||||
|
[org.id, email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new AppError("Email already registered", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is the first user (becomes chef)
|
||||||
|
const userCount = await queryOne<{ count: string }>(
|
||||||
|
"SELECT COUNT(*) as count FROM users WHERE org_id = $1",
|
||||||
|
[org.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFirstUser = parseInt(userCount?.count || "0") === 0;
|
||||||
|
const role = isFirstUser ? "chef" : "mitarbeiter";
|
||||||
|
|
||||||
|
// Hash password and create user
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
const result = await queryOne<{ id: string }>(
|
||||||
|
`INSERT INTO users (org_id, email, password_hash, role, first_name, last_name, phone, active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
||||||
|
RETURNING id`,
|
||||||
|
[org.id, email, passwordHash, role, first_name, last_name, phone || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = {
|
||||||
|
message: "Registration successful",
|
||||||
|
userId: result?.id,
|
||||||
|
role,
|
||||||
|
isFirstUser
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
authRouter.post("/login", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { email, password, org_slug } = body;
|
||||||
|
|
||||||
|
if (!email || !password || !org_slug) {
|
||||||
|
throw new AppError("Email, password and organization required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user with org
|
||||||
|
const user = await queryOne<User & { org_id: string }>(
|
||||||
|
`SELECT u.*, o.slug as org_slug
|
||||||
|
FROM users u
|
||||||
|
JOIN organizations o ON u.org_id = o.id
|
||||||
|
WHERE u.email = $1 AND o.slug = $2 AND u.active = true`,
|
||||||
|
[email, org_slug]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError("Invalid credentials", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const valid = await verifyPassword(password, user.password_hash || "");
|
||||||
|
if (!valid) {
|
||||||
|
throw new AppError("Invalid credentials", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const accessToken = await generateAccessToken(user.id, user.org_id, user.role, user.email);
|
||||||
|
const refreshToken = await generateRefreshToken(user.id);
|
||||||
|
|
||||||
|
// Store refresh token hash
|
||||||
|
const tokenHash = await hashPassword(refreshToken);
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`,
|
||||||
|
[user.id, tokenHash, expiresAt]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
await execute(
|
||||||
|
`UPDATE users SET last_login = NOW() WHERE id = $1`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
first_name: user.first_name,
|
||||||
|
last_name: user.last_name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh token
|
||||||
|
authRouter.post("/refresh", async (ctx) => {
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { refreshToken } = body;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new AppError("Refresh token required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify JWT structure
|
||||||
|
const payload = await verifyToken(refreshToken);
|
||||||
|
if (!payload || (payload as unknown as { type?: string }).type !== "refresh") {
|
||||||
|
throw new AppError("Invalid refresh token", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user
|
||||||
|
const user = await queryOne<User>(
|
||||||
|
`SELECT * FROM users WHERE id = $1 AND active = true`,
|
||||||
|
[payload.sub]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new access token
|
||||||
|
const accessToken = await generateAccessToken(user.id, user.org_id, user.role, user.email);
|
||||||
|
|
||||||
|
ctx.response.body = { accessToken };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout (invalidate refresh tokens)
|
||||||
|
authRouter.post("/logout", authMiddleware, async (ctx) => {
|
||||||
|
const userId = ctx.state.auth.user.id;
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`DELETE FROM refresh_tokens WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Logged out successfully" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
authRouter.get("/me", authMiddleware, async (ctx) => {
|
||||||
|
const userId = ctx.state.auth.user.id;
|
||||||
|
|
||||||
|
const user = await queryOne<User>(
|
||||||
|
`SELECT id, org_id, email, role, first_name, last_name, phone, avatar_url,
|
||||||
|
created_at, last_login
|
||||||
|
FROM users WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.body = { user };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change password
|
||||||
|
authRouter.post("/change-password", authMiddleware, async (ctx) => {
|
||||||
|
const userId = ctx.state.auth.user.id;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { currentPassword, newPassword } = body;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
throw new AppError("Current and new password required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 8) {
|
||||||
|
throw new AppError("Password must be at least 8 characters", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current password hash
|
||||||
|
const user = await queryOne<{ password_hash: string }>(
|
||||||
|
`SELECT password_hash FROM users WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password
|
||||||
|
const valid = await verifyPassword(currentPassword, user.password_hash);
|
||||||
|
if (!valid) {
|
||||||
|
throw new AppError("Current password is incorrect", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash and update new password
|
||||||
|
const newHash = await hashPassword(newPassword);
|
||||||
|
await execute(
|
||||||
|
`UPDATE users SET password_hash = $1 WHERE id = $2`,
|
||||||
|
[newHash, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invalidate all refresh tokens
|
||||||
|
await execute(
|
||||||
|
`DELETE FROM refresh_tokens WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Password changed successfully" };
|
||||||
|
});
|
||||||
149
src/routes/availability.ts
Normal file
149
src/routes/availability.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { query, queryOne, execute } from "../db/postgres.ts";
|
||||||
|
import { AppError } from "../middleware/error.ts";
|
||||||
|
import { authMiddleware, requireDisponentOrHigher } from "../middleware/auth.ts";
|
||||||
|
import type { Availability } from "../types/index.ts";
|
||||||
|
|
||||||
|
export const availabilityRouter = new Router({ prefix: "/api/availability" });
|
||||||
|
|
||||||
|
// Get availability (own or all for Disponent/Chef)
|
||||||
|
availabilityRouter.get("/", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const targetUserId = ctx.request.url.searchParams.get("user_id");
|
||||||
|
const fromDate = ctx.request.url.searchParams.get("from");
|
||||||
|
const toDate = ctx.request.url.searchParams.get("to");
|
||||||
|
|
||||||
|
const params: unknown[] = [orgId];
|
||||||
|
let whereClause = "WHERE u.org_id = $1";
|
||||||
|
|
||||||
|
if (role === "mitarbeiter") {
|
||||||
|
// Mitarbeiter can only see own availability
|
||||||
|
whereClause += ` AND a.user_id = $${params.length + 1}`;
|
||||||
|
params.push(userId);
|
||||||
|
} else if (targetUserId) {
|
||||||
|
// Filter by specific user
|
||||||
|
whereClause += ` AND a.user_id = $${params.length + 1}`;
|
||||||
|
params.push(targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromDate) {
|
||||||
|
whereClause += ` AND a.date >= $${params.length + 1}`;
|
||||||
|
params.push(fromDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDate) {
|
||||||
|
whereClause += ` AND a.date <= $${params.length + 1}`;
|
||||||
|
params.push(toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const availability = await query<Availability & { user_name: string }>(
|
||||||
|
`SELECT a.*, u.first_name || ' ' || u.last_name as user_name
|
||||||
|
FROM availability a
|
||||||
|
JOIN users u ON a.user_id = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY a.date, u.last_name`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { availability };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get availability calendar (aggregated view)
|
||||||
|
availabilityRouter.get("/calendar", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const fromDate = ctx.request.url.searchParams.get("from");
|
||||||
|
const toDate = ctx.request.url.searchParams.get("to");
|
||||||
|
|
||||||
|
if (!fromDate || !toDate) {
|
||||||
|
throw new AppError("from and to dates required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendar = await query<{ date: string; available_count: number; unavailable_count: number }>(
|
||||||
|
`SELECT
|
||||||
|
a.date::text,
|
||||||
|
COUNT(*) FILTER (WHERE a.available = true) as available_count,
|
||||||
|
COUNT(*) FILTER (WHERE a.available = false) as unavailable_count
|
||||||
|
FROM availability a
|
||||||
|
JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE u.org_id = $1 AND a.date BETWEEN $2 AND $3
|
||||||
|
GROUP BY a.date
|
||||||
|
ORDER BY a.date`,
|
||||||
|
[orgId, fromDate, toDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { calendar };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set availability (own)
|
||||||
|
availabilityRouter.post("/", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId } = ctx.state.auth.user;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { date, available, time_from, time_to, note } = body;
|
||||||
|
|
||||||
|
if (!date || available === undefined) {
|
||||||
|
throw new AppError("Date and available status required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert availability
|
||||||
|
await execute(
|
||||||
|
`INSERT INTO availability (user_id, date, available, time_from, time_to, note)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (user_id, date)
|
||||||
|
DO UPDATE SET available = $3, time_from = $4, time_to = $5, note = $6`,
|
||||||
|
[userId, date, available, time_from || null, time_to || null, note || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Availability updated" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk set availability (multiple dates)
|
||||||
|
availabilityRouter.post("/bulk", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId } = ctx.state.auth.user;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { entries } = body;
|
||||||
|
|
||||||
|
if (!entries || !Array.isArray(entries)) {
|
||||||
|
throw new AppError("Entries array required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const { date, available, time_from, time_to, note } = entry;
|
||||||
|
|
||||||
|
if (!date || available === undefined) continue;
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`INSERT INTO availability (user_id, date, available, time_from, time_to, note)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (user_id, date)
|
||||||
|
DO UPDATE SET available = $3, time_from = $4, time_to = $5, note = $6`,
|
||||||
|
[userId, date, available, time_from || null, time_to || null, note || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Availability updated", count: entries.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete availability entry
|
||||||
|
availabilityRouter.delete("/:id", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, role } = ctx.state.auth.user;
|
||||||
|
const availabilityId = ctx.params.id;
|
||||||
|
|
||||||
|
// Get the availability entry
|
||||||
|
const entry = await queryOne<Availability>(
|
||||||
|
`SELECT * FROM availability WHERE id = $1`,
|
||||||
|
[availabilityId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw new AppError("Availability entry not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permission
|
||||||
|
if (role === "mitarbeiter" && entry.user_id !== userId) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute(`DELETE FROM availability WHERE id = $1`, [availabilityId]);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Availability deleted" };
|
||||||
|
});
|
||||||
211
src/routes/modules.ts
Normal file
211
src/routes/modules.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { query, queryOne, execute } from "../db/postgres.ts";
|
||||||
|
import { AppError } from "../middleware/error.ts";
|
||||||
|
import { authMiddleware, requireChef } from "../middleware/auth.ts";
|
||||||
|
import type { Module, OrganizationModule } from "../types/index.ts";
|
||||||
|
|
||||||
|
export const modulesRouter = new Router({ prefix: "/api/modules" });
|
||||||
|
|
||||||
|
// Get all available modules
|
||||||
|
modulesRouter.get("/", authMiddleware, async (ctx) => {
|
||||||
|
const modules = await query<Module>(
|
||||||
|
`SELECT * FROM modules ORDER BY is_core DESC, name`
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { modules };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get organization's module configuration
|
||||||
|
modulesRouter.get("/org", authMiddleware, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
|
||||||
|
const modules = await query<Module & { enabled: boolean; config: Record<string, unknown> }>(
|
||||||
|
`SELECT m.*,
|
||||||
|
COALESCE(om.enabled, false) as enabled,
|
||||||
|
COALESCE(om.config, m.default_config) as config
|
||||||
|
FROM modules m
|
||||||
|
LEFT JOIN organization_modules om ON m.id = om.module_id AND om.org_id = $1
|
||||||
|
ORDER BY m.is_core DESC, m.name`,
|
||||||
|
[orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { modules };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable/disable module for organization (Chef only)
|
||||||
|
modulesRouter.post("/:moduleId/toggle", requireChef, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const moduleId = ctx.params.moduleId;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { enabled } = body;
|
||||||
|
|
||||||
|
if (enabled === undefined) {
|
||||||
|
throw new AppError("enabled field required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get module
|
||||||
|
const module = await queryOne<Module>(
|
||||||
|
`SELECT * FROM modules WHERE id = $1`,
|
||||||
|
[moduleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
throw new AppError("Module not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cannot disable core modules
|
||||||
|
if (module.is_core && !enabled) {
|
||||||
|
throw new AppError("Cannot disable core module", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert organization_modules
|
||||||
|
await execute(
|
||||||
|
`INSERT INTO organization_modules (org_id, module_id, enabled, config)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (org_id, module_id)
|
||||||
|
DO UPDATE SET enabled = $3, updated_at = NOW()`,
|
||||||
|
[orgId, moduleId, enabled, module.default_config]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
message: `Module ${enabled ? "enabled" : "disabled"}`,
|
||||||
|
module: module.name,
|
||||||
|
enabled
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update module configuration (Chef only)
|
||||||
|
modulesRouter.put("/:moduleId/config", requireChef, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const moduleId = ctx.params.moduleId;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { config } = body;
|
||||||
|
|
||||||
|
if (!config || typeof config !== "object") {
|
||||||
|
throw new AppError("config object required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify module exists
|
||||||
|
const module = await queryOne<Module>(
|
||||||
|
`SELECT * FROM modules WHERE id = $1`,
|
||||||
|
[moduleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!module) {
|
||||||
|
throw new AppError("Module not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert with new config
|
||||||
|
await execute(
|
||||||
|
`INSERT INTO organization_modules (org_id, module_id, enabled, config)
|
||||||
|
VALUES ($1, $2, true, $3)
|
||||||
|
ON CONFLICT (org_id, module_id)
|
||||||
|
DO UPDATE SET config = $3, updated_at = NOW()`,
|
||||||
|
[orgId, moduleId, JSON.stringify(config)]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Module configuration updated" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if specific module is enabled for current org
|
||||||
|
modulesRouter.get("/check/:moduleName", authMiddleware, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const moduleName = ctx.params.moduleName;
|
||||||
|
|
||||||
|
const result = await queryOne<{ enabled: boolean; config: Record<string, unknown> }>(
|
||||||
|
`SELECT om.enabled, COALESCE(om.config, m.default_config) as config
|
||||||
|
FROM modules m
|
||||||
|
LEFT JOIN organization_modules om ON m.id = om.module_id AND om.org_id = $2
|
||||||
|
WHERE m.name = $1`,
|
||||||
|
[moduleName, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
throw new AppError("Module not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
module: moduleName,
|
||||||
|
enabled: result.enabled ?? false,
|
||||||
|
config: result.config
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ DEVELOPER PANEL ENDPOINTS ============
|
||||||
|
// These require the 'developer' module to be enabled and special permissions
|
||||||
|
|
||||||
|
// Get system status
|
||||||
|
modulesRouter.get("/developer/status", requireChef, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
|
||||||
|
// Check if developer module is enabled
|
||||||
|
const devModule = await queryOne<{ enabled: boolean }>(
|
||||||
|
`SELECT om.enabled FROM organization_modules om
|
||||||
|
JOIN modules m ON om.module_id = m.id
|
||||||
|
WHERE m.name = 'developer' AND om.org_id = $1`,
|
||||||
|
[orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!devModule?.enabled) {
|
||||||
|
throw new AppError("Developer module not enabled", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
const stats = await queryOne<{
|
||||||
|
user_count: number;
|
||||||
|
order_count: number;
|
||||||
|
timesheet_count: number;
|
||||||
|
enabled_modules: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*) FROM users WHERE org_id = $1) as user_count,
|
||||||
|
(SELECT COUNT(*) FROM orders WHERE org_id = $1) as order_count,
|
||||||
|
(SELECT COUNT(*) FROM timesheets t JOIN users u ON t.user_id = u.id WHERE u.org_id = $1) as timesheet_count,
|
||||||
|
(SELECT COUNT(*) FROM organization_modules WHERE org_id = $1 AND enabled = true) as enabled_modules`,
|
||||||
|
[orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
status: "ok",
|
||||||
|
organization: orgId,
|
||||||
|
stats,
|
||||||
|
serverTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get audit logs
|
||||||
|
modulesRouter.get("/developer/logs", requireChef, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const limit = parseInt(ctx.request.url.searchParams.get("limit") || "50");
|
||||||
|
const offset = parseInt(ctx.request.url.searchParams.get("offset") || "0");
|
||||||
|
|
||||||
|
// Check developer module
|
||||||
|
const devModule = await queryOne<{ enabled: boolean }>(
|
||||||
|
`SELECT om.enabled FROM organization_modules om
|
||||||
|
JOIN modules m ON om.module_id = m.id
|
||||||
|
WHERE m.name = 'developer' AND om.org_id = $1`,
|
||||||
|
[orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!devModule?.enabled) {
|
||||||
|
throw new AppError("Developer module not enabled", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const logs = await query<{
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
entity_type: string;
|
||||||
|
user_email: string;
|
||||||
|
created_at: Date;
|
||||||
|
}>(
|
||||||
|
`SELECT al.id, al.action, al.entity_type, al.created_at, u.email as user_email
|
||||||
|
FROM audit_logs al
|
||||||
|
LEFT JOIN users u ON al.user_id = u.id
|
||||||
|
WHERE al.org_id = $1
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3`,
|
||||||
|
[orgId, limit, offset]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { logs };
|
||||||
|
});
|
||||||
303
src/routes/orders.ts
Normal file
303
src/routes/orders.ts
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { query, queryOne, execute } from "../db/postgres.ts";
|
||||||
|
import { AppError } from "../middleware/error.ts";
|
||||||
|
import { authMiddleware, requireDisponentOrHigher } from "../middleware/auth.ts";
|
||||||
|
import type { Order, OrderAssignment } from "../types/index.ts";
|
||||||
|
|
||||||
|
export const ordersRouter = new Router({ prefix: "/api/orders" });
|
||||||
|
|
||||||
|
// Get all orders (filtered by role)
|
||||||
|
ordersRouter.get("/", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const status = ctx.request.url.searchParams.get("status");
|
||||||
|
|
||||||
|
let orders: Order[];
|
||||||
|
const params: unknown[] = [orgId];
|
||||||
|
let whereClause = "WHERE o.org_id = $1";
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += " AND o.status = $2";
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "mitarbeiter") {
|
||||||
|
// Mitarbeiter only sees assigned orders
|
||||||
|
const paramIndex = params.length + 1;
|
||||||
|
whereClause += ` AND EXISTS (
|
||||||
|
SELECT 1 FROM order_assignments oa
|
||||||
|
WHERE oa.order_id = o.id AND oa.user_id = $${paramIndex}
|
||||||
|
)`;
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
orders = await query<Order>(
|
||||||
|
`SELECT o.*,
|
||||||
|
u.first_name || ' ' || u.last_name as creator_name,
|
||||||
|
(SELECT COUNT(*) FROM order_assignments WHERE order_id = o.id) as assigned_count
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN users u ON o.created_by = u.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY o.start_time DESC NULLS LAST, o.created_at DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { orders };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single order with assignments
|
||||||
|
ordersRouter.get("/:id", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const orderId = ctx.params.id;
|
||||||
|
|
||||||
|
const order = await queryOne<Order>(
|
||||||
|
`SELECT o.*, u.first_name || ' ' || u.last_name as creator_name
|
||||||
|
FROM orders o
|
||||||
|
LEFT JOIN users u ON o.created_by = u.id
|
||||||
|
WHERE o.id = $1 AND o.org_id = $2`,
|
||||||
|
[orderId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new AppError("Order not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Mitarbeiter is assigned to this order
|
||||||
|
if (role === "mitarbeiter") {
|
||||||
|
const assignment = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM order_assignments WHERE order_id = $1 AND user_id = $2`,
|
||||||
|
[orderId, userId]
|
||||||
|
);
|
||||||
|
if (!assignment) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get assignments
|
||||||
|
const assignments = await query<OrderAssignment & { user_name: string }>(
|
||||||
|
`SELECT oa.*, u.first_name || ' ' || u.last_name as user_name, u.phone as user_phone
|
||||||
|
FROM order_assignments oa
|
||||||
|
JOIN users u ON oa.user_id = u.id
|
||||||
|
WHERE oa.order_id = $1
|
||||||
|
ORDER BY oa.created_at`,
|
||||||
|
[orderId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { order, assignments };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
ordersRouter.post("/", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
const {
|
||||||
|
title, description, location, address, client_name, client_contact,
|
||||||
|
status, start_time, end_time, required_staff, special_instructions
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
throw new AppError("Title is required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne<{ id: string; number: number }>(
|
||||||
|
`INSERT INTO orders (org_id, title, description, location, address, client_name,
|
||||||
|
client_contact, status, start_time, end_time, required_staff,
|
||||||
|
special_instructions, created_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
|
RETURNING id, number`,
|
||||||
|
[orgId, title, description || null, location || null, address || null,
|
||||||
|
client_name || null, client_contact || null, status || "draft",
|
||||||
|
start_time || null, end_time || null, required_staff || 1,
|
||||||
|
special_instructions || null, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { message: "Order created", orderId: result?.id, number: result?.number };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update order
|
||||||
|
ordersRouter.put("/:id", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const orderId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
// Get order
|
||||||
|
const order = await queryOne<Order>(
|
||||||
|
`SELECT * FROM orders WHERE id = $1 AND org_id = $2`,
|
||||||
|
[orderId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new AppError("Order not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disponent can only edit their own orders
|
||||||
|
if (role === "disponent" && order.created_by !== userId) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update
|
||||||
|
const allowedFields = [
|
||||||
|
"title", "description", "location", "address", "client_name",
|
||||||
|
"client_contact", "status", "start_time", "end_time", "required_staff",
|
||||||
|
"special_instructions"
|
||||||
|
];
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
for (const field of allowedFields) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
updates.push(`${field} = $${paramIndex}`);
|
||||||
|
values.push(body[field] === "" ? null : body[field]);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
throw new AppError("No valid fields to update", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(orderId);
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`UPDATE orders SET ${updates.join(", ")} WHERE id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Order updated" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete order
|
||||||
|
ordersRouter.delete("/:id", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const orderId = ctx.params.id;
|
||||||
|
|
||||||
|
const order = await queryOne<Order>(
|
||||||
|
`SELECT * FROM orders WHERE id = $1 AND org_id = $2`,
|
||||||
|
[orderId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new AppError("Order not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disponent can only delete their own orders
|
||||||
|
if (role === "disponent" && order.created_by !== userId) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute(`DELETE FROM orders WHERE id = $1`, [orderId]);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Order deleted" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ ASSIGNMENTS ============
|
||||||
|
|
||||||
|
// Assign user to order
|
||||||
|
ordersRouter.post("/:id/assign", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const orderId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { user_id, note } = body;
|
||||||
|
|
||||||
|
if (!user_id) {
|
||||||
|
throw new AppError("User ID required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify order exists
|
||||||
|
const order = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM orders WHERE id = $1 AND org_id = $2`,
|
||||||
|
[orderId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new AppError("Order not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user exists and is in same org
|
||||||
|
const user = await queryOne<{ id: string; role: string }>(
|
||||||
|
`SELECT id, role FROM users WHERE id = $1 AND org_id = $2 AND active = true`,
|
||||||
|
[user_id, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already assigned
|
||||||
|
const existing = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM order_assignments WHERE order_id = $1 AND user_id = $2`,
|
||||||
|
[orderId, user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new AppError("User already assigned", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`INSERT INTO order_assignments (order_id, user_id, status, note)
|
||||||
|
VALUES ($1, $2, 'pending', $3)`,
|
||||||
|
[orderId, user_id, note || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { message: "User assigned" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove assignment
|
||||||
|
ordersRouter.delete("/:id/assign/:userId", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const orderId = ctx.params.id;
|
||||||
|
const assignedUserId = ctx.params.userId;
|
||||||
|
|
||||||
|
// Verify order exists
|
||||||
|
const order = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM orders WHERE id = $1 AND org_id = $2`,
|
||||||
|
[orderId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
throw new AppError("Order not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`DELETE FROM order_assignments WHERE order_id = $1 AND user_id = $2`,
|
||||||
|
[orderId, assignedUserId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Assignment removed" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update assignment status (confirm/decline) - for assigned user
|
||||||
|
ordersRouter.put("/:id/assignment", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId } = ctx.state.auth.user;
|
||||||
|
const orderId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { status, note } = body;
|
||||||
|
|
||||||
|
if (!status || !["confirmed", "declined"].includes(status)) {
|
||||||
|
throw new AppError("Valid status required (confirmed/declined)", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignment = await queryOne<{ id: string }>(
|
||||||
|
`SELECT id FROM order_assignments WHERE order_id = $1 AND user_id = $2`,
|
||||||
|
[orderId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
throw new AppError("Assignment not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmedAt = status === "confirmed" ? "NOW()" : "NULL";
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`UPDATE order_assignments
|
||||||
|
SET status = $1, note = $2, confirmed_at = ${confirmedAt}
|
||||||
|
WHERE order_id = $3 AND user_id = $4`,
|
||||||
|
[status, note || null, orderId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Assignment updated" };
|
||||||
|
});
|
||||||
293
src/routes/timesheets.ts
Normal file
293
src/routes/timesheets.ts
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { query, queryOne, execute } from "../db/postgres.ts";
|
||||||
|
import { AppError } from "../middleware/error.ts";
|
||||||
|
import { authMiddleware, requireDisponentOrHigher } from "../middleware/auth.ts";
|
||||||
|
import type { Timesheet } from "../types/index.ts";
|
||||||
|
|
||||||
|
export const timesheetsRouter = new Router({ prefix: "/api/timesheets" });
|
||||||
|
|
||||||
|
// Get timesheets (filtered by role)
|
||||||
|
timesheetsRouter.get("/", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const status = ctx.request.url.searchParams.get("status");
|
||||||
|
const targetUserId = ctx.request.url.searchParams.get("user_id");
|
||||||
|
const fromDate = ctx.request.url.searchParams.get("from");
|
||||||
|
const toDate = ctx.request.url.searchParams.get("to");
|
||||||
|
|
||||||
|
const params: unknown[] = [orgId];
|
||||||
|
let whereClause = "WHERE u.org_id = $1";
|
||||||
|
|
||||||
|
if (role === "mitarbeiter") {
|
||||||
|
// Mitarbeiter only sees own timesheets
|
||||||
|
whereClause += ` AND t.user_id = $${params.length + 1}`;
|
||||||
|
params.push(userId);
|
||||||
|
} else if (targetUserId) {
|
||||||
|
whereClause += ` AND t.user_id = $${params.length + 1}`;
|
||||||
|
params.push(targetUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
whereClause += ` AND t.status = $${params.length + 1}`;
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromDate) {
|
||||||
|
whereClause += ` AND t.work_date >= $${params.length + 1}`;
|
||||||
|
params.push(fromDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDate) {
|
||||||
|
whereClause += ` AND t.work_date <= $${params.length + 1}`;
|
||||||
|
params.push(toDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timesheets = await query<Timesheet & { user_name: string; order_title: string }>(
|
||||||
|
`SELECT t.*,
|
||||||
|
u.first_name || ' ' || u.last_name as user_name,
|
||||||
|
o.title as order_title
|
||||||
|
FROM timesheets t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
LEFT JOIN orders o ON t.order_id = o.id
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY t.work_date DESC, t.created_at DESC`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { timesheets };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single timesheet
|
||||||
|
timesheetsRouter.get("/:id", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const timesheetId = ctx.params.id;
|
||||||
|
|
||||||
|
const timesheet = await queryOne<Timesheet & { user_name: string; order_title: string }>(
|
||||||
|
`SELECT t.*,
|
||||||
|
u.first_name || ' ' || u.last_name as user_name,
|
||||||
|
o.title as order_title
|
||||||
|
FROM timesheets t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
LEFT JOIN orders o ON t.order_id = o.id
|
||||||
|
WHERE t.id = $1 AND u.org_id = $2`,
|
||||||
|
[timesheetId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!timesheet) {
|
||||||
|
throw new AppError("Timesheet not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check access
|
||||||
|
if (role === "mitarbeiter" && timesheet.user_id !== userId) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.body = { timesheet };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create timesheet (submit)
|
||||||
|
timesheetsRouter.post("/", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId } = ctx.state.auth.user;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
const { order_id, work_date, start_time, end_time, hours_worked, photo_url } = body;
|
||||||
|
|
||||||
|
if (!work_date) {
|
||||||
|
throw new AppError("Work date required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate hours if not provided
|
||||||
|
let calculatedHours = hours_worked;
|
||||||
|
if (!calculatedHours && start_time && end_time) {
|
||||||
|
const [startH, startM] = start_time.split(":").map(Number);
|
||||||
|
const [endH, endM] = end_time.split(":").map(Number);
|
||||||
|
calculatedHours = ((endH * 60 + endM) - (startH * 60 + startM)) / 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await queryOne<{ id: string }>(
|
||||||
|
`INSERT INTO timesheets (user_id, order_id, work_date, start_time, end_time,
|
||||||
|
hours_worked, photo_url, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
|
||||||
|
RETURNING id`,
|
||||||
|
[userId, order_id || null, work_date, start_time || null, end_time || null,
|
||||||
|
calculatedHours || null, photo_url || null]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { message: "Timesheet submitted", timesheetId: result?.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update timesheet (only if pending and own)
|
||||||
|
timesheetsRouter.put("/:id", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, role } = ctx.state.auth.user;
|
||||||
|
const timesheetId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
const timesheet = await queryOne<Timesheet>(
|
||||||
|
`SELECT * FROM timesheets WHERE id = $1`,
|
||||||
|
[timesheetId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!timesheet) {
|
||||||
|
throw new AppError("Timesheet not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only owner can edit, and only if pending
|
||||||
|
if (timesheet.user_id !== userId) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timesheet.status !== "pending") {
|
||||||
|
throw new AppError("Cannot edit approved/rejected timesheet", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedFields = ["order_id", "work_date", "start_time", "end_time", "hours_worked", "photo_url"];
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
for (const field of allowedFields) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
updates.push(`${field} = $${paramIndex}`);
|
||||||
|
values.push(body[field] === "" ? null : body[field]);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
throw new AppError("No valid fields to update", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(timesheetId);
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`UPDATE timesheets SET ${updates.join(", ")} WHERE id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Timesheet updated" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Approve/Reject timesheet
|
||||||
|
timesheetsRouter.post("/:id/review", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const timesheetId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
const { status, rejection_reason } = body;
|
||||||
|
|
||||||
|
if (!status || !["approved", "rejected"].includes(status)) {
|
||||||
|
throw new AppError("Valid status required (approved/rejected)", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "rejected" && !rejection_reason) {
|
||||||
|
throw new AppError("Rejection reason required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify timesheet exists and belongs to org
|
||||||
|
const timesheet = await queryOne<Timesheet>(
|
||||||
|
`SELECT t.* FROM timesheets t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.id = $1 AND u.org_id = $2`,
|
||||||
|
[timesheetId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!timesheet) {
|
||||||
|
throw new AppError("Timesheet not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timesheet.status !== "pending") {
|
||||||
|
throw new AppError("Timesheet already reviewed", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`UPDATE timesheets
|
||||||
|
SET status = $1, approved_by = $2, rejection_reason = $3, approved_at = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[status, userId, status === "rejected" ? rejection_reason : null, timesheetId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: `Timesheet ${status}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete timesheet (only if pending and own)
|
||||||
|
timesheetsRouter.delete("/:id", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, role } = ctx.state.auth.user;
|
||||||
|
const timesheetId = ctx.params.id;
|
||||||
|
|
||||||
|
const timesheet = await queryOne<Timesheet>(
|
||||||
|
`SELECT * FROM timesheets WHERE id = $1`,
|
||||||
|
[timesheetId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!timesheet) {
|
||||||
|
throw new AppError("Timesheet not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner can delete pending, Disponent+ can delete any
|
||||||
|
if (role === "mitarbeiter") {
|
||||||
|
if (timesheet.user_id !== userId) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
if (timesheet.status !== "pending") {
|
||||||
|
throw new AppError("Cannot delete reviewed timesheet", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await execute(`DELETE FROM timesheets WHERE id = $1`, [timesheetId]);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "Timesheet deleted" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get timesheet summary for billing
|
||||||
|
timesheetsRouter.get("/summary/:userId", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { org_id: orgId } = ctx.state.auth.user;
|
||||||
|
const targetUserId = ctx.params.userId;
|
||||||
|
const fromDate = ctx.request.url.searchParams.get("from");
|
||||||
|
const toDate = ctx.request.url.searchParams.get("to");
|
||||||
|
|
||||||
|
if (!fromDate || !toDate) {
|
||||||
|
throw new AppError("from and to dates required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = await queryOne<{
|
||||||
|
total_hours: number;
|
||||||
|
approved_hours: number;
|
||||||
|
pending_count: number;
|
||||||
|
approved_count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
COALESCE(SUM(hours_worked), 0) as total_hours,
|
||||||
|
COALESCE(SUM(hours_worked) FILTER (WHERE status = 'approved'), 0) as approved_hours,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'approved') as approved_count
|
||||||
|
FROM timesheets t
|
||||||
|
JOIN users u ON t.user_id = u.id
|
||||||
|
WHERE t.user_id = $1 AND u.org_id = $2
|
||||||
|
AND t.work_date BETWEEN $3 AND $4`,
|
||||||
|
[targetUserId, orgId, fromDate, toDate]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { summary };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload photo endpoint placeholder
|
||||||
|
timesheetsRouter.post("/upload", authMiddleware, async (ctx) => {
|
||||||
|
// In production: Handle multipart form data, upload to S3, return URL
|
||||||
|
// For now, just accept a base64 encoded image or external URL
|
||||||
|
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { base64, filename } = body;
|
||||||
|
|
||||||
|
if (!base64) {
|
||||||
|
throw new AppError("Image data required", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Upload to S3 and return URL
|
||||||
|
// For now, return a placeholder
|
||||||
|
const photoUrl = `https://storage.secu.app/timesheets/${Date.now()}_${filename || "photo.jpg"}`;
|
||||||
|
|
||||||
|
ctx.response.body = {
|
||||||
|
message: "Upload placeholder - implement S3 storage",
|
||||||
|
photo_url: photoUrl
|
||||||
|
};
|
||||||
|
});
|
||||||
260
src/routes/users.ts
Normal file
260
src/routes/users.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { Router } from "@oak/oak";
|
||||||
|
import { query, queryOne, execute } from "../db/postgres.ts";
|
||||||
|
import { hashPassword, canManageRole, canManageUser } from "../utils/auth.ts";
|
||||||
|
import { AppError } from "../middleware/error.ts";
|
||||||
|
import { authMiddleware, requireDisponentOrHigher, requireChef } from "../middleware/auth.ts";
|
||||||
|
import type { User, UserRole } from "../types/index.ts";
|
||||||
|
|
||||||
|
export const usersRouter = new Router({ prefix: "/api/users" });
|
||||||
|
|
||||||
|
// Get all users (filtered by role permissions)
|
||||||
|
usersRouter.get("/", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
|
||||||
|
let users: User[];
|
||||||
|
|
||||||
|
if (role === "chef") {
|
||||||
|
// Chef sees all users
|
||||||
|
users = await query<User>(
|
||||||
|
`SELECT id, org_id, email, role, first_name, last_name, phone, avatar_url,
|
||||||
|
managed_by, active, created_at
|
||||||
|
FROM users WHERE org_id = $1
|
||||||
|
ORDER BY role, last_name`,
|
||||||
|
[orgId]
|
||||||
|
);
|
||||||
|
} else if (role === "disponent") {
|
||||||
|
// Disponent sees only their managed employees + themselves
|
||||||
|
users = await query<User>(
|
||||||
|
`SELECT id, org_id, email, role, first_name, last_name, phone, avatar_url,
|
||||||
|
managed_by, active, created_at
|
||||||
|
FROM users WHERE org_id = $1 AND (managed_by = $2 OR id = $2)
|
||||||
|
ORDER BY role, last_name`,
|
||||||
|
[orgId, userId]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Mitarbeiter only sees themselves
|
||||||
|
users = await query<User>(
|
||||||
|
`SELECT id, org_id, email, role, first_name, last_name, phone, avatar_url,
|
||||||
|
managed_by, active, created_at
|
||||||
|
FROM users WHERE id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.body = { users };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single user
|
||||||
|
usersRouter.get("/:id", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const targetId = ctx.params.id;
|
||||||
|
|
||||||
|
const user = await queryOne<User>(
|
||||||
|
`SELECT id, org_id, email, role, first_name, last_name, phone, avatar_url,
|
||||||
|
managed_by, active, created_at
|
||||||
|
FROM users WHERE id = $1 AND org_id = $2`,
|
||||||
|
[targetId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
if (role === "mitarbeiter" && user.id !== userId) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === "disponent" && user.managed_by !== userId && user.id !== userId) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.body = { user };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create user (Chef can create any, Disponent can create Mitarbeiter)
|
||||||
|
usersRouter.post("/", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { id: creatorId, org_id: orgId, role: creatorRole } = ctx.state.auth.user;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
const { email, password, role, first_name, last_name, phone } = body;
|
||||||
|
|
||||||
|
if (!email || !password || !role || !first_name || !last_name) {
|
||||||
|
throw new AppError("Missing required fields", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate role assignment permissions
|
||||||
|
const targetRole = role as UserRole;
|
||||||
|
|
||||||
|
if (creatorRole === "disponent") {
|
||||||
|
// Disponent can only create Mitarbeiter
|
||||||
|
if (targetRole !== "mitarbeiter") {
|
||||||
|
throw new AppError("Disponenten können nur Mitarbeiter anlegen", 403);
|
||||||
|
}
|
||||||
|
} else if (creatorRole === "chef") {
|
||||||
|
// Chef can create any role except chef (only one chef via registration)
|
||||||
|
if (!["disponent", "mitarbeiter"].includes(targetRole)) {
|
||||||
|
throw new AppError("Invalid role", 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email uniqueness
|
||||||
|
const existing = await queryOne<{ id: string }>(
|
||||||
|
"SELECT id FROM users WHERE org_id = $1 AND email = $2",
|
||||||
|
[orgId, email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new AppError("Email already exists", 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
// Set managed_by for Mitarbeiter created by Disponent
|
||||||
|
const managedBy = (creatorRole === "disponent" && targetRole === "mitarbeiter")
|
||||||
|
? creatorId
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const result = await queryOne<{ id: string }>(
|
||||||
|
`INSERT INTO users (org_id, email, password_hash, role, first_name, last_name, phone,
|
||||||
|
created_by, managed_by, active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true)
|
||||||
|
RETURNING id`,
|
||||||
|
[orgId, email, passwordHash, targetRole, first_name, last_name, phone || null,
|
||||||
|
creatorId, managedBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.status = 201;
|
||||||
|
ctx.response.body = { message: "User created", userId: result?.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
usersRouter.put("/:id", authMiddleware, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const targetId = ctx.params.id;
|
||||||
|
const body = await ctx.request.body.json();
|
||||||
|
|
||||||
|
// Get target user
|
||||||
|
const targetUser = await queryOne<User>(
|
||||||
|
`SELECT * FROM users WHERE id = $1 AND org_id = $2`,
|
||||||
|
[targetId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new AppError("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const canEdit =
|
||||||
|
role === "chef" ||
|
||||||
|
(role === "disponent" && targetUser.managed_by === userId) ||
|
||||||
|
targetId === userId; // Can edit own profile
|
||||||
|
|
||||||
|
if (!canEdit) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update fields
|
||||||
|
const allowedFields = ["first_name", "last_name", "phone", "avatar_url"];
|
||||||
|
|
||||||
|
// Only chef can change role and active status
|
||||||
|
if (role === "chef") {
|
||||||
|
allowedFields.push("active");
|
||||||
|
if (body.role && body.role !== "chef") {
|
||||||
|
allowedFields.push("role");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
for (const field of allowedFields) {
|
||||||
|
if (body[field] !== undefined) {
|
||||||
|
updates.push(`${field} = $${paramIndex}`);
|
||||||
|
values.push(body[field]);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
throw new AppError("No valid fields to update", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(targetId);
|
||||||
|
|
||||||
|
await execute(
|
||||||
|
`UPDATE users SET ${updates.join(", ")} WHERE id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "User updated" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete user (deactivate)
|
||||||
|
usersRouter.delete("/:id", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role } = ctx.state.auth.user;
|
||||||
|
const targetId = ctx.params.id;
|
||||||
|
|
||||||
|
if (targetId === userId) {
|
||||||
|
throw new AppError("Cannot delete yourself", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get target user
|
||||||
|
const targetUser = await queryOne<User>(
|
||||||
|
`SELECT * FROM users WHERE id = $1 AND org_id = $2`,
|
||||||
|
[targetId, orgId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
throw new AppError("User not found", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
if (role === "disponent" && targetUser.managed_by !== userId) {
|
||||||
|
throw new AppError("Access denied", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetUser.role === "chef") {
|
||||||
|
throw new AppError("Cannot delete chef", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete (deactivate)
|
||||||
|
await execute(
|
||||||
|
`UPDATE users SET active = false WHERE id = $1`,
|
||||||
|
[targetId]
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.response.body = { message: "User deactivated" };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get users by role (for assignment dropdowns)
|
||||||
|
usersRouter.get("/by-role/:role", requireDisponentOrHigher, async (ctx) => {
|
||||||
|
const { id: userId, org_id: orgId, role: userRole } = ctx.state.auth.user;
|
||||||
|
const targetRole = ctx.params.role;
|
||||||
|
|
||||||
|
if (!["chef", "disponent", "mitarbeiter"].includes(targetRole)) {
|
||||||
|
throw new AppError("Invalid role", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let users: User[];
|
||||||
|
|
||||||
|
if (userRole === "chef") {
|
||||||
|
users = await query<User>(
|
||||||
|
`SELECT id, first_name, last_name, email, phone
|
||||||
|
FROM users WHERE org_id = $1 AND role = $2 AND active = true
|
||||||
|
ORDER BY last_name`,
|
||||||
|
[orgId, targetRole]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Disponent can only see their Mitarbeiter
|
||||||
|
users = await query<User>(
|
||||||
|
`SELECT id, first_name, last_name, email, phone
|
||||||
|
FROM users WHERE org_id = $1 AND role = $2 AND managed_by = $3 AND active = true
|
||||||
|
ORDER BY last_name`,
|
||||||
|
[orgId, targetRole, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.response.body = { users };
|
||||||
|
});
|
||||||
145
src/types/index.ts
Normal file
145
src/types/index.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
// User roles
|
||||||
|
export type UserRole = "chef" | "disponent" | "mitarbeiter";
|
||||||
|
|
||||||
|
// Order status
|
||||||
|
export type OrderStatus = "draft" | "published" | "in_progress" | "completed" | "cancelled";
|
||||||
|
|
||||||
|
// Assignment status
|
||||||
|
export type AssignmentStatus = "pending" | "confirmed" | "declined" | "completed";
|
||||||
|
|
||||||
|
// Timesheet status
|
||||||
|
export type TimesheetStatus = "pending" | "approved" | "rejected";
|
||||||
|
|
||||||
|
// User
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
org_id: string;
|
||||||
|
email: string;
|
||||||
|
password_hash?: string;
|
||||||
|
role: UserRole;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
created_by?: string;
|
||||||
|
managed_by?: string;
|
||||||
|
active: boolean;
|
||||||
|
last_login?: Date;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organization
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order
|
||||||
|
export interface Order {
|
||||||
|
id: string;
|
||||||
|
org_id: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
location?: string;
|
||||||
|
address?: string;
|
||||||
|
client_name?: string;
|
||||||
|
client_contact?: string;
|
||||||
|
status: OrderStatus;
|
||||||
|
start_time?: Date;
|
||||||
|
end_time?: Date;
|
||||||
|
required_staff: number;
|
||||||
|
special_instructions?: string;
|
||||||
|
created_by: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Order Assignment
|
||||||
|
export interface OrderAssignment {
|
||||||
|
id: string;
|
||||||
|
order_id: string;
|
||||||
|
user_id: string;
|
||||||
|
status: AssignmentStatus;
|
||||||
|
note?: string;
|
||||||
|
confirmed_at?: Date;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Availability
|
||||||
|
export interface Availability {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
date: Date;
|
||||||
|
available: boolean;
|
||||||
|
time_from?: string;
|
||||||
|
time_to?: string;
|
||||||
|
note?: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timesheet
|
||||||
|
export interface Timesheet {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
order_id?: string;
|
||||||
|
work_date: Date;
|
||||||
|
start_time?: string;
|
||||||
|
end_time?: string;
|
||||||
|
hours_worked?: number;
|
||||||
|
photo_url?: string;
|
||||||
|
status: TimesheetStatus;
|
||||||
|
approved_by?: string;
|
||||||
|
rejection_reason?: string;
|
||||||
|
approved_at?: Date;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module
|
||||||
|
export interface Module {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
description?: string;
|
||||||
|
is_core: boolean;
|
||||||
|
default_config: Record<string, unknown>;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organization Module
|
||||||
|
export interface OrganizationModule {
|
||||||
|
id: string;
|
||||||
|
org_id: string;
|
||||||
|
module_id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
enabled_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT Payload
|
||||||
|
export interface JWTPayload {
|
||||||
|
sub: string; // user id
|
||||||
|
org: string; // org id
|
||||||
|
role: UserRole;
|
||||||
|
email: string;
|
||||||
|
iat: number;
|
||||||
|
exp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth Context
|
||||||
|
export interface AuthContext {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
org_id: string;
|
||||||
|
role: UserRole;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
109
src/utils/auth.ts
Normal file
109
src/utils/auth.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { create, verify, getNumericDate } from "https://deno.land/x/djwt@v3.0.1/mod.ts";
|
||||||
|
import { hash, verify as verifyHash } from "https://deno.land/x/argon2@v0.9.2/mod.ts";
|
||||||
|
import type { JWTPayload, UserRole } from "../types/index.ts";
|
||||||
|
|
||||||
|
const JWT_SECRET = Deno.env.get("JWT_SECRET") || "secu-super-secret-key-change-in-production";
|
||||||
|
const ACCESS_TOKEN_EXPIRY = 15 * 60; // 15 minutes
|
||||||
|
const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days
|
||||||
|
|
||||||
|
// Create crypto key from secret
|
||||||
|
async function getKey(): Promise<CryptoKey> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
return await crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
encoder.encode(JWT_SECRET),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign", "verify"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate access token
|
||||||
|
export async function generateAccessToken(
|
||||||
|
userId: string,
|
||||||
|
orgId: string,
|
||||||
|
role: UserRole,
|
||||||
|
email: string
|
||||||
|
): Promise<string> {
|
||||||
|
const key = await getKey();
|
||||||
|
const now = getNumericDate(0);
|
||||||
|
|
||||||
|
const payload: JWTPayload = {
|
||||||
|
sub: userId,
|
||||||
|
org: orgId,
|
||||||
|
role,
|
||||||
|
email,
|
||||||
|
iat: now,
|
||||||
|
exp: getNumericDate(ACCESS_TOKEN_EXPIRY),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await create({ alg: "HS256", typ: "JWT" }, payload, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate refresh token
|
||||||
|
export async function generateRefreshToken(userId: string): Promise<string> {
|
||||||
|
const key = await getKey();
|
||||||
|
const now = getNumericDate(0);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
sub: userId,
|
||||||
|
type: "refresh",
|
||||||
|
iat: now,
|
||||||
|
exp: getNumericDate(REFRESH_TOKEN_EXPIRY),
|
||||||
|
};
|
||||||
|
|
||||||
|
return await create({ alg: "HS256", typ: "JWT" }, payload, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify token
|
||||||
|
export async function verifyToken(token: string): Promise<JWTPayload | null> {
|
||||||
|
try {
|
||||||
|
const key = await getKey();
|
||||||
|
const payload = await verify(token, key);
|
||||||
|
return payload as JWTPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password with Argon2
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return await hash(password);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
export async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
return await verifyHash(password, hashedPassword);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random token for refresh token storage
|
||||||
|
export function generateRandomToken(): string {
|
||||||
|
const array = new Uint8Array(32);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
return Array.from(array, b => b.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role hierarchy check
|
||||||
|
export function canManageRole(managerRole: UserRole, targetRole: UserRole): boolean {
|
||||||
|
const hierarchy: Record<UserRole, number> = {
|
||||||
|
chef: 3,
|
||||||
|
disponent: 2,
|
||||||
|
mitarbeiter: 1,
|
||||||
|
};
|
||||||
|
return hierarchy[managerRole] > hierarchy[targetRole];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can perform action on target user
|
||||||
|
export function canManageUser(
|
||||||
|
managerRole: UserRole,
|
||||||
|
managerId: string,
|
||||||
|
targetManagedBy: string | null
|
||||||
|
): boolean {
|
||||||
|
if (managerRole === "chef") return true;
|
||||||
|
if (managerRole === "disponent" && targetManagedBy === managerId) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user