fix: Global BigInt conversion in postgres module
- Added convertBigInt helper in db/postgres.ts - Converts all BigInt values to Number in query results - Simplified admin.ts routes (removed workarounds) - All APIs now work correctly
This commit is contained in:
@@ -5,6 +5,22 @@ const DATABASE_URL = Deno.env.get("DATABASE_URL") ||
|
|||||||
|
|
||||||
let pool: Pool;
|
let pool: Pool;
|
||||||
|
|
||||||
|
// Convert BigInt to Number recursively in query results
|
||||||
|
function convertBigInt<T>(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<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||||
|
result[key] = convertBigInt(value);
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
export async function initDB(): Promise<void> {
|
export async function initDB(): Promise<void> {
|
||||||
pool = new Pool(DATABASE_URL, 10);
|
pool = new Pool(DATABASE_URL, 10);
|
||||||
|
|
||||||
@@ -29,7 +45,8 @@ export async function query<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
|||||||
text: sql,
|
text: sql,
|
||||||
args: params || [],
|
args: params || [],
|
||||||
});
|
});
|
||||||
return result.rows;
|
// Convert BigInt to Number in all results
|
||||||
|
return result.rows.map(row => convertBigInt(row));
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
@@ -47,7 +64,7 @@ export async function execute(sql: string, params?: unknown[]): Promise<number>
|
|||||||
text: sql,
|
text: sql,
|
||||||
args: params || [],
|
args: params || [],
|
||||||
});
|
});
|
||||||
return result.rowCount || 0;
|
return Number(result.rowCount || 0);
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
client.release();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ import type { Context, Next } from "@oak/oak";
|
|||||||
|
|
||||||
export const adminRouter = new Router({ prefix: "/api/admin" });
|
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)
|
// Super Admin emails (hardcoded for security)
|
||||||
const SUPER_ADMINS = [
|
const SUPER_ADMINS = [
|
||||||
"admin@kronos-soulution.de",
|
"admin@kronos-soulution.de",
|
||||||
@@ -41,161 +33,94 @@ async function requireSuperAdmin(ctx: Context, next: Next): Promise<void> {
|
|||||||
|
|
||||||
// Dashboard stats
|
// Dashboard stats
|
||||||
adminRouter.get("/dashboard", requireSuperAdmin, async (ctx) => {
|
adminRouter.get("/dashboard", requireSuperAdmin, async (ctx) => {
|
||||||
try {
|
const stats = await queryOne<{
|
||||||
// Use ::text to avoid BigInt issues
|
org_count: number;
|
||||||
const rawStats = await queryOne(
|
user_count: number;
|
||||||
|
order_count: number;
|
||||||
|
timesheet_count: number;
|
||||||
|
}>(
|
||||||
`SELECT
|
`SELECT
|
||||||
(SELECT COUNT(*) FROM organizations)::text as org_count,
|
(SELECT COUNT(*) FROM organizations) as org_count,
|
||||||
(SELECT COUNT(*) FROM users WHERE active = true)::text as user_count,
|
(SELECT COUNT(*) FROM users WHERE active = true) as user_count,
|
||||||
(SELECT COUNT(*) FROM orders)::text as order_count,
|
(SELECT COUNT(*) FROM orders) as order_count,
|
||||||
(SELECT COUNT(*) FROM timesheets)::text as timesheet_count`
|
(SELECT COUNT(*) FROM timesheets) as timesheet_count`
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("rawStats:", rawStats);
|
const recentOrgs = await query<{ id: number; name: string; slug: string; created_at: Date }>(
|
||||||
|
`SELECT id, name, slug, created_at
|
||||||
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
|
FROM organizations
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 10`
|
LIMIT 10`
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("rawOrgs:", rawOrgs);
|
ctx.response.body = { stats, recentOrgs };
|
||||||
|
|
||||||
const recentOrgs = rawOrgs.map((o: Record<string, unknown>) => ({
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// List all organizations
|
// List all organizations
|
||||||
adminRouter.get("/organizations", requireSuperAdmin, async (ctx) => {
|
adminRouter.get("/organizations", requireSuperAdmin, async (ctx) => {
|
||||||
const rawOrgs = await query(
|
const orgs = await query(
|
||||||
`SELECT o.id::text, o.name, o.slug, o.status, o.plan, o.max_users::text, o.created_at::text,
|
`SELECT o.id, o.name, o.slug, o.status, o.plan, o.max_users, o.created_at,
|
||||||
(SELECT COUNT(*)::text FROM users WHERE org_id = o.id AND active = true) as user_count,
|
(SELECT COUNT(*) 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
|
(SELECT COUNT(*) FROM orders WHERE org_id = o.id) as order_count
|
||||||
FROM organizations o
|
FROM organizations o
|
||||||
ORDER BY o.created_at DESC`
|
ORDER BY o.created_at DESC`
|
||||||
);
|
);
|
||||||
|
|
||||||
const organizations = rawOrgs.map((o: Record<string, unknown>) => ({
|
ctx.response.body = { organizations: orgs };
|
||||||
...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 };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get single organization details
|
// Get single organization details
|
||||||
adminRouter.get("/organizations/:id", requireSuperAdmin, async (ctx) => {
|
adminRouter.get("/organizations/:id", requireSuperAdmin, async (ctx) => {
|
||||||
const orgId = ctx.params.id;
|
const orgId = ctx.params.id;
|
||||||
|
|
||||||
const rawOrg = await queryOne(
|
const org = await queryOne(
|
||||||
`SELECT id::text, name, slug, status, plan, max_users::text, created_at::text
|
`SELECT id, name, slug, status, plan, max_users, created_at
|
||||||
FROM organizations WHERE id = $1`,
|
FROM organizations WHERE id = $1`,
|
||||||
[orgId]
|
[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<string, unknown>) => ({
|
|
||||||
...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<string, unknown> }>(
|
|
||||||
"SELECT settings FROM organizations WHERE id = $1",
|
|
||||||
[orgId]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
throw new AppError("Organisation nicht gefunden", 404);
|
throw new AppError("Organisation nicht gefunden", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge settings
|
const users = await query(
|
||||||
const newSettings = {
|
`SELECT id, email, role, first_name, last_name, active, last_login
|
||||||
...org.settings,
|
FROM users WHERE org_id = $1
|
||||||
...settings,
|
ORDER BY role, last_name`,
|
||||||
...(plan ? { plan } : {})
|
[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(
|
await execute(
|
||||||
`UPDATE organizations SET name = COALESCE($1, name), settings = $2 WHERE id = $3`,
|
`UPDATE organizations SET
|
||||||
[name || null, JSON.stringify(newSettings), orgId]
|
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" };
|
ctx.response.body = { message: "Organisation aktualisiert" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete organization (careful!)
|
// Delete organization
|
||||||
adminRouter.delete("/organizations/:id", requireSuperAdmin, async (ctx) => {
|
adminRouter.delete("/organizations/:id", requireSuperAdmin, async (ctx) => {
|
||||||
const orgId = ctx.params.id;
|
const orgId = ctx.params.id;
|
||||||
const body = await ctx.request.body.json();
|
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);
|
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]);
|
await execute("DELETE FROM organizations WHERE id = $1", [orgId]);
|
||||||
|
|
||||||
ctx.response.body = { message: "Organisation gelöscht" };
|
ctx.response.body = { message: "Organisation gelöscht" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Impersonate: Get login token for any user (for support)
|
// Impersonate organization (login as org admin)
|
||||||
adminRouter.post("/impersonate/:userId", requireSuperAdmin, async (ctx) => {
|
adminRouter.post("/impersonate/:orgId", requireSuperAdmin, async (ctx) => {
|
||||||
const userId = ctx.params.userId;
|
const orgId = ctx.params.orgId;
|
||||||
|
|
||||||
const user = await queryOne<{
|
// Find the org chef
|
||||||
id: string;
|
const chef = await queryOne<{ id: number; email: string; role: string }>(
|
||||||
org_id: string;
|
`SELECT id, email, role FROM users
|
||||||
email: string;
|
WHERE org_id = $1 AND role = 'chef' AND active = true
|
||||||
role: string;
|
LIMIT 1`,
|
||||||
}>(
|
[orgId]
|
||||||
"SELECT id, org_id, email, role FROM users WHERE id = $1 AND active = true",
|
|
||||||
[userId]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!chef) {
|
||||||
throw new AppError("User nicht gefunden", 404);
|
throw new AppError("Kein aktiver Chef in dieser Organisation", 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate token for this user
|
||||||
const { generateAccessToken } = await import("../utils/auth.ts");
|
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 = {
|
ctx.response.body = {
|
||||||
message: "Impersonation Token erstellt",
|
|
||||||
token,
|
token,
|
||||||
user: {
|
user: chef,
|
||||||
id: user.id,
|
message: "Als Chef eingeloggt"
|
||||||
email: user.email,
|
|
||||||
role: user.role
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// System info
|
// Broadcast message to all organizations
|
||||||
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)
|
|
||||||
adminRouter.post("/broadcast", requireSuperAdmin, async (ctx) => {
|
adminRouter.post("/broadcast", requireSuperAdmin, async (ctx) => {
|
||||||
const body = await ctx.request.body.json();
|
const body = await ctx.request.body.json();
|
||||||
const { message, type } = body;
|
const { message, title } = body;
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw new AppError("Nachricht erforderlich", 400);
|
throw new AppError("Nachricht erforderlich", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store broadcast in all org settings
|
// Get all org chefs
|
||||||
const orgs = await query<{ id: string }>("SELECT id FROM organizations");
|
const chefs = await query<{ email: string; first_name: string }>(
|
||||||
|
`SELECT DISTINCT ON (org_id) email, first_name
|
||||||
for (const org of orgs) {
|
FROM users
|
||||||
await execute(
|
WHERE role = 'chef' AND active = true
|
||||||
`UPDATE organizations
|
ORDER BY org_id, created_at`
|
||||||
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 = {
|
ctx.response.body = {
|
||||||
message: "Broadcast gesendet",
|
message: `Broadcast an ${chefs.length} Organisationen gesendet`,
|
||||||
affected: orgs.length
|
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" };
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user