179 lines
4.9 KiB
TypeScript
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];
|
|
}
|
|
});
|