Session 6.3
This commit is contained in:
175
src/lib/db.ts
Normal file
175
src/lib/db.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user