diff --git a/frontend/index.html b/frontend/index.html index a5d57dd..975c04d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,7 +6,7 @@ LunarFront Manager @@ -153,11 +284,15 @@

LunarFront Manager

Sign in to continue

- - - - - +
+ + +
+
+ + +
+
@@ -169,7 +304,11 @@
+ +
+
Customers
+
+ + +
+
+ + + + + + + + + + + + + + + +
Slug ModulesStart Expires Created Updated
Loading…
+ +
+
+ -
+
Create Environment
- - - +
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ + + + + + +
+
+
@@ -203,13 +412,15 @@
Account
- - - - -
- +
+ +
+
+ + +
+
@@ -218,22 +429,45 @@
+ +
+
+

Delete Customer

+

This will remove the ArgoCD app, database, and all associated resources for . This cannot be undone.

+
+ + +
+
+
+ diff --git a/src/db/manager.ts b/src/db/manager.ts index 1055ca5..1d1c1ff 100644 --- a/src/db/manager.ts +++ b/src/db/manager.ts @@ -9,7 +9,28 @@ export async function migrate() { id SERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `; + await db` + CREATE TABLE IF NOT EXISTS customers ( + slug TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'provisioned', + modules TEXT[] NOT NULL DEFAULT '{}', + start_date DATE NOT NULL DEFAULT CURRENT_DATE, + expiration_date DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; + // idempotent column additions for existing deployments + await db`ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`; + await db` + ALTER TABLE customers + ADD COLUMN IF NOT EXISTS modules TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS start_date DATE NOT NULL DEFAULT CURRENT_DATE, + ADD COLUMN IF NOT EXISTS expiration_date DATE, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + `; } diff --git a/src/routes/customers.ts b/src/routes/customers.ts index ca7e4fc..e7fda04 100644 --- a/src/routes/customers.ts +++ b/src/routes/customers.ts @@ -4,13 +4,52 @@ import { createDatabase, createDatabaseUser, deleteDatabase, deleteDatabaseUser import { addCustomerToPool, removeCustomerFromPool } from "../services/pgbouncer"; import { addCustomerChart, removeCustomerChart } from "../services/git"; import { setupCustomerDatabase, teardownCustomerDatabase } from "../services/db"; +import { db } from "../db/manager"; + +const MODULES = ["pos", "inventory", "rentals", "scheduling", "repairs", "accounting"] as const; const ProvisionSchema = z.object({ name: z.string().min(2).max(32).regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only"), appVersion: z.string().default("latest"), + modules: z.array(z.enum(MODULES)).default([]), + startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).default(() => new Date().toISOString().slice(0, 10)), + expirationDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().default(null), +}); + +const ListQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(25), + q: z.string().optional(), + sort: z.enum(["slug", "start_date", "expiration_date", "created_at", "updated_at"]).default("created_at"), + order: z.enum(["asc", "desc"]).default("desc"), }); export async function customerRoutes(app: FastifyInstance) { + app.get("/customers", async (req, reply) => { + const query = ListQuerySchema.parse(req.query); + const offset = (query.page - 1) * query.limit; + const search = query.q ? `%${query.q}%` : null; + + const [rows, [{ count }]] = await Promise.all([ + search + ? db`SELECT * FROM customers WHERE slug ILIKE ${search} ORDER BY ${db(query.sort)} ${db.unsafe(query.order)} LIMIT ${query.limit} OFFSET ${offset}` + : db`SELECT * FROM customers ORDER BY ${db(query.sort)} ${db.unsafe(query.order)} LIMIT ${query.limit} OFFSET ${offset}`, + search + ? db`SELECT COUNT(*)::int FROM customers WHERE slug ILIKE ${search}` + : db`SELECT COUNT(*)::int FROM customers`, + ]); + + return reply.send({ + data: rows, + pagination: { + page: query.page, + limit: query.limit, + total: count, + totalPages: Math.ceil(count / query.limit), + }, + }); + }); + app.post("/customers", async (req, reply) => { const body = ProvisionSchema.parse(req.body); const slug = body.name; @@ -26,6 +65,16 @@ export async function customerRoutes(app: FastifyInstance) { await addCustomerToPool(slug, user.password); addCustomerChart(slug, body.appVersion); + await db` + INSERT INTO customers (slug, modules, start_date, expiration_date) + VALUES (${slug}, ${body.modules}, ${body.startDate}, ${body.expirationDate}) + ON CONFLICT (slug) DO UPDATE SET + modules = EXCLUDED.modules, + start_date = EXCLUDED.start_date, + expiration_date = EXCLUDED.expiration_date, + updated_at = NOW() + `; + app.log.info({ slug }, "customer provisioned"); return reply.code(201).send({ slug, status: "provisioned" }); }); @@ -43,6 +92,8 @@ export async function customerRoutes(app: FastifyInstance) { deleteDatabaseUser(slug), ]); + await db`DELETE FROM customers WHERE slug = ${slug}`; + app.log.info({ slug }, "customer deprovisioned"); return reply.code(204).send(); }); diff --git a/src/services/git.ts b/src/services/git.ts index 4319820..101b18c 100644 --- a/src/services/git.ts +++ b/src/services/git.ts @@ -4,11 +4,12 @@ import { tmpdir } from "os"; import { join } from "path"; import { config } from "../lib/config"; -function withRepo(fn: (dir: string) => T): T { +function withRepo(fn: (dir: string, env: NodeJS.ProcessEnv) => T): T { const keyPath = join(tmpdir(), `manager-ssh-key-${Date.now()}`); const dir = join(tmpdir(), `lunarfront-charts-${Date.now()}`); - writeFileSync(keyPath, config.gitSshKey, { mode: 0o600 }); + const keyContent = config.gitSshKey.endsWith("\n") ? config.gitSshKey : config.gitSshKey + "\n"; + writeFileSync(keyPath, keyContent, { mode: 0o600 }); const env = { ...process.env, @@ -19,7 +20,7 @@ function withRepo(fn: (dir: string) => T): T { execSync(`git clone ${config.gitRepoUrl} ${dir}`, { env, stdio: "pipe" }); execSync(`git -C ${dir} config user.email "manager@lunarfront.tech"`, { env }); execSync(`git -C ${dir} config user.name "lunarfront-manager"`, { env }); - const result = fn(dir); + const result = fn(dir, env); execSync(`git -C ${dir} push origin main`, { env, stdio: "pipe" }); return result; } finally { @@ -29,19 +30,19 @@ function withRepo(fn: (dir: string) => T): T { } export function addCustomerChart(slug: string, appVersion: string) { - withRepo((dir) => { + withRepo((dir, env) => { const manifest = buildArgoCDApp(slug, appVersion); writeFileSync(join(dir, "customers", `${slug}.yaml`), manifest); - execSync(`git -C ${dir} add customers/${slug}.yaml`); - execSync(`git -C ${dir} commit -m "feat: provision customer ${slug}"`, { env: process.env }); + execSync(`git -C ${dir} add customers/${slug}.yaml`, { env }); + execSync(`git -C ${dir} commit -m "feat: provision customer ${slug}"`, { env }); }); } export function removeCustomerChart(slug: string) { - withRepo((dir) => { + withRepo((dir, env) => { rmSync(join(dir, "customers", `${slug}.yaml`), { force: true }); - execSync(`git -C ${dir} add customers/${slug}.yaml`); - execSync(`git -C ${dir} commit -m "chore: deprovision customer ${slug}"`, { env: process.env }); + execSync(`git -C ${dir} add customers/${slug}.yaml`, { env }); + execSync(`git -C ${dir} commit -m "chore: deprovision customer ${slug}"`, { env }); }); }