+
+
- Customers
+
+
+
+
+
+
+
+ | Slug | +Modules | +Start | +Expires | +Created | +Updated | ++ |
|---|---|---|---|---|---|---|
| Loading… | ||||||
+
@@ -203,13 +412,15 @@
+
+
+
diff --git a/src/db/manager.ts b/src/db/manager.ts
index 1055ca5..1d1c1ff 100644
--- a/src/db/manager.ts
+++ b/src/db/manager.ts
@@ -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()
+ `;
}
diff --git a/src/routes/customers.ts b/src/routes/customers.ts
index ca7e4fc..e7fda04 100644
--- a/src/routes/customers.ts
+++ b/src/routes/customers.ts
@@ -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();
});
diff --git a/src/services/git.ts b/src/services/git.ts
index 4319820..101b18c 100644
--- a/src/services/git.ts
+++ b/src/services/git.ts
@@ -4,11 +4,12 @@ import { tmpdir } from "os";
import { join } from "path";
import { config } from "../lib/config";
-function withRepoCreate Environment
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Account
-
-
-
-
-
@@ -218,22 +429,45 @@
-
+
+
+
+
+
+
+
+