feat: customers management UI with paginated table and full delete
All checks were successful
Build & Release / build (push) Successful in 12s
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:
@@ -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()
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user