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

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