🚀 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:
2026-02-20 15:12:06 +00:00
parent a07c2ad858
commit ee19e45171
16 changed files with 2079 additions and 2 deletions

54
src/db/postgres.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}