🚀 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:
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