fix: persist auth cookie across refreshes and add customer detail tracking
Some checks failed
Build & Release / build (push) Has been cancelled

- Fix cookie sameSite strict → lax so browser sends it on page refresh
- Add customer name field (separate from slug)
- Add steps JSONB column tracking per-step provisioning state (DB, User, Schema, Pool, Chart)
- Insert customer record before provisioning starts so partial failures are visible
- Show status + step checklist in customers table
- Add DELETE /customers/:slug/record endpoint to clear failed records without touching infra
- Add "Record Only" button in UI for manual cleanup of partial deployments
This commit is contained in:
Ryan Moon
2026-04-03 18:00:46 -05:00
parent 7e63dbac9c
commit accc963883
4 changed files with 100 additions and 17 deletions

View File

@@ -16,7 +16,9 @@ export async function migrate() {
await db`
CREATE TABLE IF NOT EXISTS customers (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'provisioned',
steps JSONB NOT NULL DEFAULT '{}',
modules TEXT[] NOT NULL DEFAULT '{}',
start_date DATE NOT NULL DEFAULT CURRENT_DATE,
expiration_date DATE,
@@ -28,6 +30,8 @@ export async function migrate() {
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 name TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS steps JSONB NOT NULL DEFAULT '{}',
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,

View File

@@ -51,7 +51,7 @@ export async function authRoutes(app: FastifyInstance) {
reply.setCookie("token", token, {
httpOnly: true,
secure: true,
sameSite: "strict",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});

View File

@@ -9,7 +9,8 @@ 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"),
slug: z.string().min(2).max(32).regex(/^[a-z0-9-]+$/, "lowercase letters, numbers, and hyphens only"),
name: z.string().min(1).max(128),
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)),
@@ -52,29 +53,62 @@ export async function customerRoutes(app: FastifyInstance) {
app.post("/customers", async (req, reply) => {
const body = ProvisionSchema.parse(req.body);
const slug = body.name;
const slug = body.slug;
app.log.info({ slug }, "provisioning customer");
const [, user] = await Promise.all([
createDatabase(slug),
createDatabaseUser(slug),
]);
type StepStatus = "pending" | "done" | "failed";
const steps: Record<string, StepStatus> = {
database: "pending",
database_user: "pending",
database_setup: "pending",
pool: "pending",
chart: "pending",
};
await setupCustomerDatabase(slug, user.name);
await addCustomerToPool(slug, user.password);
addCustomerChart(slug, body.appVersion);
const setStep = async (step: string, status: StepStatus) => {
steps[step] = status;
await db`UPDATE customers SET steps = ${db.json(steps)}, updated_at = NOW() WHERE slug = ${slug}`;
};
// Insert record immediately so partial failures are visible in the UI
await db`
INSERT INTO customers (slug, modules, start_date, expiration_date)
VALUES (${slug}, ${body.modules}, ${body.startDate}, ${body.expirationDate})
INSERT INTO customers (slug, name, modules, start_date, expiration_date, status, steps)
VALUES (${slug}, ${body.name}, ${body.modules}, ${body.startDate}, ${body.expirationDate}, 'provisioning', ${db.json(steps)})
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
modules = EXCLUDED.modules,
start_date = EXCLUDED.start_date,
expiration_date = EXCLUDED.expiration_date,
status = 'provisioning',
steps = ${db.json(steps)},
updated_at = NOW()
`;
try {
const [, user] = await Promise.all([
createDatabase(slug).then(r => { setStep("database", "done"); return r; }),
createDatabaseUser(slug).then(r => { setStep("database_user", "done"); return r; }),
]);
await setupCustomerDatabase(slug, user.name);
await setStep("database_setup", "done");
await addCustomerToPool(slug, user.password);
await setStep("pool", "done");
addCustomerChart(slug, body.appVersion);
await setStep("chart", "done");
await db`UPDATE customers SET status = 'provisioned', updated_at = NOW() WHERE slug = ${slug}`;
} catch (err) {
const failedStep = Object.entries(steps).find(([, v]) => v === "pending")?.[0];
if (failedStep) await setStep(failedStep, "failed");
await db`UPDATE customers SET status = 'failed', updated_at = NOW() WHERE slug = ${slug}`;
app.log.error({ slug, err }, "provisioning failed");
return reply.code(500).send({ message: (err as Error).message ?? "Provisioning failed" });
}
app.log.info({ slug }, "customer provisioned");
return reply.code(201).send({ slug, status: "provisioned" });
});
@@ -97,4 +131,13 @@ export async function customerRoutes(app: FastifyInstance) {
app.log.info({ slug }, "customer deprovisioned");
return reply.code(204).send();
});
// Remove only the manager DB record without touching infrastructure —
// useful for cleaning up failed partial deployments
app.delete("/customers/:slug/record", async (req, reply) => {
const { slug } = req.params as { slug: string };
await db`DELETE FROM customers WHERE slug = ${slug}`;
app.log.info({ slug }, "customer record removed");
return reply.code(204).send();
});
}