Session 6.3

This commit is contained in:
2026-04-10 21:56:31 +02:00
parent f20d5f09ae
commit 94bfd25553
24 changed files with 2398 additions and 104 deletions

175
src/lib/db.ts Normal file
View File

@@ -0,0 +1,175 @@
/**
* Database client for the portal-db PostgreSQL database.
*
* Uses the `pg` package directly — no ORM overhead for a single table.
* The tenant_requests table acts as the approval gate between customer
* registration and actual PiecedTenant CR creation.
*
* Connection: via DATABASE_URL env var pointing to CloudNativePG cluster.
*/
import { Pool } from "pg";
import type { TenantRequest, TenantRequestStatus } from "@/types";
// Lazy-init: pool is created on first use, not at module import time.
// This avoids "Invalid URL" errors during Next.js build when env vars
// aren't available yet.
let _pool: Pool | null = null;
function getPool(): Pool {
if (!_pool) {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is not set");
_pool = new Pool({
connectionString: url,
max: 5,
idleTimeoutMillis: 30_000,
});
}
return _pool;
}
// ---------------------------------------------------------------------------
// Schema migration (idempotent)
// ---------------------------------------------------------------------------
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS tenant_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
zitadel_org_id TEXT NOT NULL UNIQUE,
zitadel_user_id TEXT NOT NULL,
company_name TEXT NOT NULL,
contact_name TEXT NOT NULL,
contact_email TEXT NOT NULL,
agent_name TEXT NOT NULL DEFAULT 'Assistant',
soul_md TEXT,
packages TEXT[] DEFAULT '{}',
billing_address JSONB DEFAULT '{}',
billing_notes TEXT,
status TEXT NOT NULL DEFAULT 'pending',
admin_notes TEXT,
tenant_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_status ON tenant_requests(status);
CREATE INDEX IF NOT EXISTS idx_tenant_requests_org_id ON tenant_requests(zitadel_org_id);
`;
let migrated = false;
export async function ensureSchema(): Promise<void> {
if (migrated) return;
await getPool().query(MIGRATION_SQL);
migrated = true;
}
// ---------------------------------------------------------------------------
// CRUD
// ---------------------------------------------------------------------------
export async function createTenantRequest(
params: Omit<TenantRequest, "id" | "status" | "createdAt" | "updatedAt">
): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query<TenantRequest>(
`INSERT INTO tenant_requests
(zitadel_org_id, zitadel_user_id, company_name, contact_name,
contact_email, agent_name, soul_md, packages, billing_address, billing_notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
params.zitadelOrgId,
params.zitadelUserId,
params.companyName,
params.contactName,
params.contactEmail,
params.agentName,
params.soulMd,
params.packages,
JSON.stringify(params.billingAddress),
params.billingNotes,
]
);
return mapRow(result.rows[0]);
}
export async function getTenantRequestByOrgId(
orgId: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM tenant_requests WHERE zitadel_org_id = $1",
[orgId]
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
export async function getTenantRequestById(
id: string
): Promise<TenantRequest | null> {
await ensureSchema();
const result = await getPool().query(
"SELECT * FROM tenant_requests WHERE id = $1",
[id]
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
export async function listTenantRequests(
status?: TenantRequestStatus
): Promise<TenantRequest[]> {
await ensureSchema();
const pool = getPool();
const query = status
? { text: "SELECT * FROM tenant_requests WHERE status = $1 ORDER BY created_at DESC", values: [status] }
: { text: "SELECT * FROM tenant_requests ORDER BY created_at DESC", values: [] };
const result = await pool.query(query);
return result.rows.map(mapRow);
}
export async function updateTenantRequestStatus(
id: string,
status: TenantRequestStatus,
extra?: { adminNotes?: string; tenantName?: string }
): Promise<TenantRequest> {
await ensureSchema();
const result = await getPool().query(
`UPDATE tenant_requests
SET status = $1, admin_notes = COALESCE($2, admin_notes),
tenant_name = COALESCE($3, tenant_name), updated_at = now()
WHERE id = $4
RETURNING *`,
[status, extra?.adminNotes ?? null, extra?.tenantName ?? null, id]
);
if (!result.rows[0]) throw new Error(`TenantRequest ${id} not found`);
return mapRow(result.rows[0]);
}
// ---------------------------------------------------------------------------
// Row mapping (snake_case → camelCase)
// ---------------------------------------------------------------------------
function mapRow(row: any): TenantRequest {
return {
id: row.id,
zitadelOrgId: row.zitadel_org_id,
zitadelUserId: row.zitadel_user_id,
companyName: row.company_name,
contactName: row.contact_name,
contactEmail: row.contact_email,
agentName: row.agent_name,
soulMd: row.soul_md,
packages: row.packages ?? [],
billingAddress: typeof row.billing_address === "string"
? JSON.parse(row.billing_address)
: row.billing_address ?? {},
billingNotes: row.billing_notes,
status: row.status as TenantRequestStatus,
adminNotes: row.admin_notes,
tenantName: row.tenant_name,
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
};
}