feat: customers management UI with paginated table and full delete
All checks were successful
Build & Release / build (push) Successful in 12s

- Fix SSH key missing trailing newline (error in libcrypto)
- Pass env with SSH command through all git operations
- Add customers table (modules, start/expiration dates, created/updated timestamps)
- Idempotent ALTER TABLE for existing deployments
- GET /customers with pagination, search, and sort
- POST /customers persists slug with modules and dates to DB
- DELETE /customers/:slug removes ArgoCD chart, DO DB, pgbouncer pool, and manager record
- Redesigned frontend: dark slate theme, customers table page with search/sort/pagination, delete confirm dialog, module checkboxes, slate buttons
This commit is contained in:
Ryan Moon
2026-04-03 17:46:16 -05:00
parent cb3e027ed2
commit d1c4aa7d6f
4 changed files with 567 additions and 106 deletions

View File

@@ -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()
`;
}

View File

@@ -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();
});

View File

@@ -4,11 +4,12 @@ import { tmpdir } from "os";
import { join } from "path";
import { config } from "../lib/config";
function withRepo<T>(fn: (dir: string) => T): T {
function withRepo<T>(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<T>(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<T>(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 });
});
}