diff --git a/src/db/postgres.ts b/src/db/postgres.ts index 00819ef..a86ddce 100644 --- a/src/db/postgres.ts +++ b/src/db/postgres.ts @@ -5,6 +5,22 @@ const DATABASE_URL = Deno.env.get("DATABASE_URL") || let pool: Pool; +// Convert BigInt to Number recursively in query results +function convertBigInt(obj: T): T { + if (obj === null || obj === undefined) return obj; + if (typeof obj === 'bigint') return Number(obj) as unknown as T; + if (obj instanceof Date) return obj; + if (Array.isArray(obj)) return obj.map(convertBigInt) as unknown as T; + if (typeof obj === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + result[key] = convertBigInt(value); + } + return result as T; + } + return obj; +} + export async function initDB(): Promise { pool = new Pool(DATABASE_URL, 10); @@ -29,7 +45,8 @@ export async function query(sql: string, params?: unknown[]): Promise { text: sql, args: params || [], }); - return result.rows; + // Convert BigInt to Number in all results + return result.rows.map(row => convertBigInt(row)); } finally { client.release(); } @@ -47,7 +64,7 @@ export async function execute(sql: string, params?: unknown[]): Promise text: sql, args: params || [], }); - return result.rowCount || 0; + return Number(result.rowCount || 0); } finally { client.release(); } diff --git a/src/routes/admin.ts b/src/routes/admin.ts index c03585e..50ed83f 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -5,14 +5,6 @@ import type { Context, Next } from "@oak/oak"; export const adminRouter = new Router({ prefix: "/api/admin" }); -// Helper to stringify with BigInt support - returns a string for ctx.response.body -function jsonResponse(obj: unknown): string { - return JSON.stringify(obj, (_key, value) => { - if (typeof value === 'bigint') return Number(value); - return value; - }); -} - // Super Admin emails (hardcoded for security) const SUPER_ADMINS = [ "admin@kronos-soulution.de", @@ -41,161 +33,94 @@ async function requireSuperAdmin(ctx: Context, next: Next): Promise { // Dashboard stats adminRouter.get("/dashboard", requireSuperAdmin, async (ctx) => { - try { - // Use ::text to avoid BigInt issues - const rawStats = await queryOne( - `SELECT - (SELECT COUNT(*) FROM organizations)::text as org_count, - (SELECT COUNT(*) FROM users WHERE active = true)::text as user_count, - (SELECT COUNT(*) FROM orders)::text as order_count, - (SELECT COUNT(*) FROM timesheets)::text as timesheet_count` - ); - - console.log("rawStats:", rawStats); - - const stats = { - org_count: Number(rawStats?.org_count || 0), - user_count: Number(rawStats?.user_count || 0), - order_count: Number(rawStats?.order_count || 0), - timesheet_count: Number(rawStats?.timesheet_count || 0) - }; - - console.log("stats:", stats); - - // Recent orgs - also cast to text to avoid BigInt - const rawOrgs = await query( - `SELECT id::text as id, name, slug, to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"') as created_at - FROM organizations - ORDER BY created_at DESC - LIMIT 10` - ); - - console.log("rawOrgs:", rawOrgs); - - const recentOrgs = rawOrgs.map((o: Record) => ({ - id: Number(o.id), - name: String(o.name), - slug: String(o.slug), - created_at: String(o.created_at) - })); - - console.log("recentOrgs:", recentOrgs); - - const responseBody = { stats, recentOrgs }; - - // Stringify manually and set as text response to avoid Oak's JSON.stringify - ctx.response.type = "application/json"; - ctx.response.body = JSON.stringify(responseBody); - } catch (err) { - console.error("Dashboard error:", err); - throw err; - } + const stats = await queryOne<{ + org_count: number; + user_count: number; + order_count: number; + timesheet_count: number; + }>( + `SELECT + (SELECT COUNT(*) FROM organizations) as org_count, + (SELECT COUNT(*) FROM users WHERE active = true) as user_count, + (SELECT COUNT(*) FROM orders) as order_count, + (SELECT COUNT(*) FROM timesheets) as timesheet_count` + ); + + const recentOrgs = await query<{ id: number; name: string; slug: string; created_at: Date }>( + `SELECT id, name, slug, created_at + FROM organizations + ORDER BY created_at DESC + LIMIT 10` + ); + + ctx.response.body = { stats, recentOrgs }; }); // List all organizations adminRouter.get("/organizations", requireSuperAdmin, async (ctx) => { - const rawOrgs = await query( - `SELECT o.id::text, o.name, o.slug, o.status, o.plan, o.max_users::text, o.created_at::text, - (SELECT COUNT(*)::text FROM users WHERE org_id = o.id AND active = true) as user_count, - (SELECT COUNT(*)::text FROM orders WHERE org_id = o.id) as order_count + const orgs = await query( + `SELECT o.id, o.name, o.slug, o.status, o.plan, o.max_users, o.created_at, + (SELECT COUNT(*) FROM users WHERE org_id = o.id AND active = true) as user_count, + (SELECT COUNT(*) FROM orders WHERE org_id = o.id) as order_count FROM organizations o ORDER BY o.created_at DESC` ); - const organizations = rawOrgs.map((o: Record) => ({ - ...o, - id: Number(o.id), - max_users: Number(o.max_users || 10), - user_count: Number(o.user_count || 0), - order_count: Number(o.order_count || 0) - })); - - ctx.response.body = { organizations }; + ctx.response.body = { organizations: orgs }; }); // Get single organization details adminRouter.get("/organizations/:id", requireSuperAdmin, async (ctx) => { const orgId = ctx.params.id; - const rawOrg = await queryOne( - `SELECT id::text, name, slug, status, plan, max_users::text, created_at::text + const org = await queryOne( + `SELECT id, name, slug, status, plan, max_users, created_at FROM organizations WHERE id = $1`, [orgId] ); - if (!rawOrg) { - throw new AppError("Organisation nicht gefunden", 404); - } - - const org = { - ...rawOrg, - id: Number(rawOrg.id), - max_users: Number(rawOrg.max_users || 10) - }; - - // Get users - const rawUsers = await query( - `SELECT id::text, email, role, first_name, last_name, active, last_login::text - FROM users WHERE org_id = $1 - ORDER BY role, last_name`, - [orgId] - ); - - const users = rawUsers.map((u: Record) => ({ - ...u, - id: Number(u.id) - })); - - // Get stats - const rawStats = await queryOne( - `SELECT - (SELECT COUNT(*)::text FROM orders WHERE org_id = $1) as order_count, - (SELECT COUNT(*)::text FROM timesheets t JOIN users u ON t.user_id = u.id WHERE u.org_id = $1) as timesheet_count, - (SELECT COUNT(*)::text FROM organization_modules WHERE org_id = $1 AND enabled = true) as enabled_modules`, - [orgId] - ); - - const stats = { - order_count: Number(rawStats?.order_count || 0), - timesheet_count: Number(rawStats?.timesheet_count || 0), - enabled_modules: Number(rawStats?.enabled_modules || 0) - }; - - ctx.response.body = { organization: org, users, stats }; -}); - -// Update organization (plan, settings, etc.) -adminRouter.put("/organizations/:id", requireSuperAdmin, async (ctx) => { - const orgId = ctx.params.id; - const body = await ctx.request.body.json(); - const { name, settings, plan } = body; - - // Get current org - const org = await queryOne<{ settings: Record }>( - "SELECT settings FROM organizations WHERE id = $1", - [orgId] - ); - if (!org) { throw new AppError("Organisation nicht gefunden", 404); } - // Merge settings - const newSettings = { - ...org.settings, - ...settings, - ...(plan ? { plan } : {}) - }; + const users = await query( + `SELECT id, email, role, first_name, last_name, active, last_login + FROM users WHERE org_id = $1 + ORDER BY role, last_name`, + [orgId] + ); + + const stats = await queryOne( + `SELECT + (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 = { organization: org, users, stats }; +}); + +// Update organization +adminRouter.put("/organizations/:id", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.id; + const body = await ctx.request.body.json(); + const { name, status, plan, max_users } = body; await execute( - `UPDATE organizations SET name = COALESCE($1, name), settings = $2 WHERE id = $3`, - [name || null, JSON.stringify(newSettings), orgId] + `UPDATE organizations SET + name = COALESCE($1, name), + status = COALESCE($2, status), + plan = COALESCE($3, plan), + max_users = COALESCE($4, max_users) + WHERE id = $5`, + [name, status, plan, max_users, orgId] ); ctx.response.body = { message: "Organisation aktualisiert" }; }); -// Delete organization (careful!) +// Delete organization adminRouter.delete("/organizations/:id", requireSuperAdmin, async (ctx) => { const orgId = ctx.params.id; const body = await ctx.request.body.json(); @@ -205,98 +130,99 @@ adminRouter.delete("/organizations/:id", requireSuperAdmin, async (ctx) => { throw new AppError("Bestätigung erforderlich: confirm = 'DELETE'", 400); } - // Check it's not demo org - const org = await queryOne<{ slug: string }>( - "SELECT slug FROM organizations WHERE id = $1", - [orgId] - ); - - if (org?.slug === "demo") { - throw new AppError("Demo-Organisation kann nicht gelöscht werden", 400); - } - - // Delete (cascades to users, orders, etc.) await execute("DELETE FROM organizations WHERE id = $1", [orgId]); - ctx.response.body = { message: "Organisation gelöscht" }; }); -// Impersonate: Get login token for any user (for support) -adminRouter.post("/impersonate/:userId", requireSuperAdmin, async (ctx) => { - const userId = ctx.params.userId; +// Impersonate organization (login as org admin) +adminRouter.post("/impersonate/:orgId", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.orgId; - const user = await queryOne<{ - id: string; - org_id: string; - email: string; - role: string; - }>( - "SELECT id, org_id, email, role FROM users WHERE id = $1 AND active = true", - [userId] + // Find the org chef + const chef = await queryOne<{ id: number; email: string; role: string }>( + `SELECT id, email, role FROM users + WHERE org_id = $1 AND role = 'chef' AND active = true + LIMIT 1`, + [orgId] ); - if (!user) { - throw new AppError("User nicht gefunden", 404); + if (!chef) { + throw new AppError("Kein aktiver Chef in dieser Organisation", 404); } + // Generate token for this user const { generateAccessToken } = await import("../utils/auth.ts"); - const token = await generateAccessToken(user.id, user.org_id, user.role as any, user.email); + const token = await generateAccessToken( + String(chef.id), + String(orgId), + chef.role as "chef" | "disponent" | "mitarbeiter", + chef.email + ); ctx.response.body = { - message: "Impersonation Token erstellt", token, - user: { - id: user.id, - email: user.email, - role: user.role - } + user: chef, + message: "Als Chef eingeloggt" }; }); -// System info -adminRouter.get("/system", requireSuperAdmin, async (ctx) => { - const dbVersion = await queryOne<{ version: string }>("SELECT version()"); - - ctx.response.body = { - service: "secu-backend", - version: "1.0.0", - environment: Deno.env.get("DENO_ENV") || "production", - database: dbVersion?.version?.split(" ")[1] || "unknown", - uptime: Deno.osUptime?.() || "unknown", - memory: Deno.memoryUsage?.() || {}, - superAdmins: SUPER_ADMINS - }; -}); - -// Broadcast message to all orgs (for announcements) +// Broadcast message to all organizations adminRouter.post("/broadcast", requireSuperAdmin, async (ctx) => { const body = await ctx.request.body.json(); - const { message, type } = body; + const { message, title } = body; if (!message) { throw new AppError("Nachricht erforderlich", 400); } - // Store broadcast in all org settings - const orgs = await query<{ id: string }>("SELECT id FROM organizations"); + // Get all org chefs + const chefs = await query<{ email: string; first_name: string }>( + `SELECT DISTINCT ON (org_id) email, first_name + FROM users + WHERE role = 'chef' AND active = true + ORDER BY org_id, created_at` + ); - for (const org of orgs) { - await execute( - `UPDATE organizations - SET settings = settings || $1::jsonb - WHERE id = $2`, - [JSON.stringify({ - broadcast: { - message, - type: type || "info", - created_at: new Date().toISOString() - } - }), org.id] - ); - } + // In production: send emails here + // For now, just log + console.log(`Broadcast to ${chefs.length} organizations:`, { title, message }); ctx.response.body = { - message: "Broadcast gesendet", - affected: orgs.length + message: `Broadcast an ${chefs.length} Organisationen gesendet`, + recipients: chefs.length }; }); + +// Module management for an org +adminRouter.get("/organizations/:id/modules", requireSuperAdmin, async (ctx) => { + const orgId = ctx.params.id; + + const modules = await query( + `SELECT m.id, m.name, m.display_name, m.description, m.is_core, + COALESCE(om.enabled, m.is_core) as enabled, + om.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 }; +}); + +adminRouter.put("/organizations/:id/modules/:moduleId", requireSuperAdmin, async (ctx) => { + const { id: orgId, moduleId } = ctx.params; + const body = await ctx.request.body.json(); + const { enabled, config } = body; + + // Upsert module setting + 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, config = COALESCE($4, organization_modules.config)`, + [orgId, moduleId, enabled, config ? JSON.stringify(config) : null] + ); + + ctx.response.body = { message: "Modul aktualisiert" }; +});