Files
portal/src/lib/db.ts
T

179 lines
4.9 KiB
TypeScript

import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '$lib/generated/prisma/client';
import { env } from '$env/dynamic/private';
import { getAuditContext } from '$lib/server/audit-context';
import { describeAction, entityIdFor, sanitizeAuditValue, shouldAudit } from '$lib/server/audit';
let _prisma: PrismaClient | undefined;
let _base: PrismaClient | undefined;
let _pool: pg.Pool | undefined;
function getBase(): { base: PrismaClient; pool: pg.Pool } {
if (!_base || !_pool) {
_pool = new pg.Pool({ connectionString: env.DATABASE_URL });
const adapter = new PrismaPg(_pool);
_base = new PrismaClient({ adapter });
}
return { base: _base, pool: _pool };
}
const roleNameCache = new Map<number, string>();
async function resolveRoleName(base: PrismaClient, roleId: number | null): Promise<string | null> {
if (roleId === null) return null;
const cached = roleNameCache.get(roleId);
if (cached) return cached;
try {
const role = await base.userRole.findUnique({
where: { id: roleId },
select: { name: true }
});
if (role?.name) {
roleNameCache.set(roleId, role.name);
return role.name;
}
} catch (e) {
console.error('Failed to resolve role name for audit log:', e);
}
return null;
}
type Delegate = {
findFirst?: (args: unknown) => Promise<unknown>;
findMany?: (args: unknown) => Promise<unknown>;
};
function delegateFor(base: PrismaClient, model: string): Delegate | undefined {
const key = model.charAt(0).toLowerCase() + model.slice(1);
return (base as unknown as Record<string, unknown>)[key] as Delegate | undefined;
}
async function readBefore(
base: PrismaClient,
model: string,
operation: string,
args: unknown
): Promise<unknown> {
if (operation.startsWith('create')) return null;
const delegate = delegateFor(base, model);
if (!delegate) return null;
const where = (args as { where?: unknown } | null)?.where;
try {
if (operation === 'updateMany' || operation === 'deleteMany') {
return delegate.findMany ? await delegate.findMany({ where, take: 50 }) : null;
}
return delegate.findFirst ? await delegate.findFirst({ where }) : null;
} catch {
return null;
}
}
async function writeAuditRow(
pool: pg.Pool,
base: PrismaClient,
entry: {
userId: string | null;
email: string | null;
roleId: number | null;
page: string;
action: string;
entity: string;
entityId: string | null;
before: unknown;
after: unknown;
ip: string | null;
}
): Promise<void> {
const roleName = await resolveRoleName(base, entry.roleId);
await pool.query(
`INSERT INTO "AuditLog"
("id", "createdAt", "userId", "email", "roleId", "roleName", "page", "action", "entity", "entityId", "before", "after", "ip")
VALUES ($1, now(), $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12)`,
[
crypto.randomUUID(),
entry.userId,
entry.email,
entry.roleId,
roleName,
entry.page,
entry.action,
entry.entity,
entry.entityId,
entry.before === null ? null : JSON.stringify(entry.before),
entry.after === null ? null : JSON.stringify(entry.after),
entry.ip
]
);
}
function getPrisma(): PrismaClient {
if (_prisma) return _prisma;
const { base, pool } = getBase();
const extended = base.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
if (!shouldAudit(model, operation)) {
return query(args);
}
const before = await readBefore(base, model, operation, args);
const result = await query(args);
try {
const ctx = getAuditContext();
const res = result as { id?: unknown; email?: string; roleId?: number | null } | null;
let userId = ctx?.userId ?? null;
let email = ctx?.email ?? null;
let roleId = ctx?.roleId ?? null;
// Self-signup: attribute the new user to itself since no session exists yet.
if (!userId && model === 'User' && operation === 'create' && res) {
userId = (res.id as string | undefined) ?? null;
email = res.email ?? null;
roleId = res.roleId ?? null;
}
const after =
operation === 'updateMany' || operation === 'deleteMany'
? { count: (result as { count?: number } | null)?.count ?? null }
: operation.startsWith('delete')
? null
: result;
await writeAuditRow(pool, base, {
userId,
email,
roleId,
page: ctx?.page ?? '/system',
action: describeAction(operation),
entity: model,
entityId: entityIdFor(args, result, before),
before: sanitizeAuditValue(before),
after: sanitizeAuditValue(after),
ip: ctx?.ip ?? null
});
} catch (e) {
console.error('Failed to write audit log:', e);
}
return result;
}
}
}
});
_prisma = extended as unknown as PrismaClient;
return _prisma;
}
export const prisma = new Proxy({} as PrismaClient, {
get(_, prop) {
return (getPrisma() as unknown as Record<string | symbol, unknown>)[prop];
}
});