fix: persist auth cookie across refreshes and add customer detail tracking
Some checks failed
Build & Release / build (push) Has been cancelled
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user