Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58232381c7 |
@@ -23,7 +23,6 @@
|
|||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"stripe": "^22.0.0",
|
"stripe": "^22.0.0",
|
||||||
"telnyx": "^1.23.0",
|
|
||||||
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,14 +27,12 @@ const DISABLED_CLIENT = {
|
|||||||
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
// ─── Queue-based mock DB ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
let selectRows: Record<string, unknown>[] = [];
|
let selectRows: Record<string, unknown>[] = [];
|
||||||
let appointmentRows: Record<string, unknown>[] = [];
|
|
||||||
let insertedValues: Record<string, unknown>[] = [];
|
let insertedValues: Record<string, unknown>[] = [];
|
||||||
let updatedValues: Record<string, unknown>[] = [];
|
let updatedValues: Record<string, unknown>[] = [];
|
||||||
let deletedId: string | null = null;
|
let deletedId: string | null = null;
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
selectRows = [];
|
selectRows = [];
|
||||||
appointmentRows = [];
|
|
||||||
insertedValues = [];
|
insertedValues = [];
|
||||||
updatedValues = [];
|
updatedValues = [];
|
||||||
deletedId = null;
|
deletedId = null;
|
||||||
@@ -60,19 +58,10 @@ vi.mock("@groombook/db", () => {
|
|||||||
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
{ get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) }
|
||||||
);
|
);
|
||||||
|
|
||||||
const appointments = new Proxy(
|
|
||||||
{ _name: "appointments" },
|
|
||||||
{ get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) }
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getDb: () => ({
|
getDb: () => ({
|
||||||
select: () => ({
|
select: () => ({
|
||||||
from: (table: unknown) => {
|
from: () => makeChainable(selectRows),
|
||||||
const tableName = (table as { _name?: string })._name;
|
|
||||||
const rows = tableName === "appointments" ? appointmentRows : selectRows;
|
|
||||||
return makeChainable(rows);
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
insert: () => ({
|
insert: () => ({
|
||||||
values: (vals: Record<string, unknown>) => {
|
values: (vals: Record<string, unknown>) => {
|
||||||
@@ -106,10 +95,8 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
clients,
|
clients,
|
||||||
appointments,
|
|
||||||
eq: vi.fn(),
|
eq: vi.fn(),
|
||||||
and: vi.fn(),
|
and: vi.fn(),
|
||||||
or: vi.fn(),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,11 +182,10 @@ describe("POST /clients", () => {
|
|||||||
expect(insertedValues[0]!.name).toBe("Charlie");
|
expect(insertedValues[0]!.name).toBe("Charlie");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a client with name and email", async () => {
|
it("creates a client with only required name field", async () => {
|
||||||
const res = await jsonRequest("POST", "/clients", { name: "Dana", email: "dana@example.com" });
|
const res = await jsonRequest("POST", "/clients", { name: "Dana" });
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
expect(insertedValues[0]!.name).toBe("Dana");
|
expect(insertedValues[0]!.name).toBe("Dana");
|
||||||
expect(insertedValues[0]!.email).toBe("dana@example.com");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects empty name", async () => {
|
it("rejects empty name", async () => {
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
appointments,
|
appointments,
|
||||||
eq: () => ({}),
|
eq: () => ({}),
|
||||||
and: (..._clauses: unknown[]) => ({}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
staff,
|
staff,
|
||||||
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
eq: vi.fn((_col: unknown, _val: unknown) => ({ col: _col, val: _val })),
|
||||||
and: vi.fn((..._clauses: unknown[]) => ({})),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -363,7 +362,7 @@ describe("requireRoleOrSuperUser", () => {
|
|||||||
const res = await app.request("/test");
|
const res = await app.request("/test");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/role.*not permitted/i);
|
expect(body.error).toMatch(/super user privileges required/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||||
@@ -371,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
|
|||||||
const res = await app.request("/test");
|
const res = await app.request("/test");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/role.*not permitted/i);
|
expect(body.error).toMatch(/super user privileges required/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a manager with multiple allowed roles", async () => {
|
it("allows a manager with multiple allowed roles", async () => {
|
||||||
|
|||||||
+2
-32
@@ -33,26 +33,11 @@ import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
|||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
// Global middleware
|
// Global middleware
|
||||||
const TRUSTED_ORIGINS = (process.env.CORS_ORIGIN ?? "http://localhost:5173")
|
|
||||||
.split(",")
|
|
||||||
.map((o) => o.trim());
|
|
||||||
|
|
||||||
const ALLOWED_ORIGIN = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
|
||||||
|
|
||||||
app.use("*", logger());
|
app.use("*", logger());
|
||||||
app.use(
|
app.use(
|
||||||
"/api/*",
|
"/api/*",
|
||||||
cors({
|
cors({
|
||||||
origin: (origin, ctx) => {
|
origin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
|
||||||
if (!origin) {
|
|
||||||
return ALLOWED_ORIGIN;
|
|
||||||
}
|
|
||||||
if (TRUSTED_ORIGINS.includes(origin)) {
|
|
||||||
return origin;
|
|
||||||
}
|
|
||||||
ctx.status(403);
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -202,24 +187,9 @@ api.route("/search", searchRouter);
|
|||||||
const port = Number(process.env.PORT ?? 3000);
|
const port = Number(process.env.PORT ?? 3000);
|
||||||
await initAuth();
|
await initAuth();
|
||||||
console.log(`API server listening on port ${port}`);
|
console.log(`API server listening on port ${port}`);
|
||||||
const server = serve({ fetch: app.fetch, port });
|
serve({ fetch: app.fetch, port });
|
||||||
|
|
||||||
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
// Start background reminder scheduler (runs every minute to check for upcoming appointments)
|
||||||
startReminderScheduler();
|
startReminderScheduler();
|
||||||
|
|
||||||
function shutdown() {
|
|
||||||
console.log("Shutting down gracefully...");
|
|
||||||
server.close(() => {
|
|
||||||
console.log("HTTP server closed");
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
console.error("Forced shutdown after timeout");
|
|
||||||
process.exit(1);
|
|
||||||
}, 10_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on("SIGTERM", shutdown);
|
|
||||||
process.on("SIGINT", shutdown);
|
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function initAuth(): Promise<void> {
|
|||||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||||
authInstance = betterAuth({
|
authInstance = betterAuth({
|
||||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||||
secret: BETTER_AUTH_SECRET!,
|
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -177,9 +177,9 @@ export async function initAuth(): Promise<void> {
|
|||||||
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
|
||||||
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
|
||||||
|
|
||||||
const issuerUrlObj = new URL(providerConfig.issuerUrl);
|
// Fetch OIDC discovery document to derive canonical provider URLs.
|
||||||
const issuerHostname = issuerUrlObj.hostname;
|
// Replace the host of token/userinfo endpoints with internalBaseUrl when set,
|
||||||
|
// while keeping authorizationUrl public for browser redirects.
|
||||||
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
|
||||||
let oidcConfig: Record<string, string> = {};
|
let oidcConfig: Record<string, string> = {};
|
||||||
try {
|
try {
|
||||||
@@ -203,14 +203,6 @@ export async function initAuth(): Promise<void> {
|
|||||||
const tokenUrl = discovery.token_endpoint;
|
const tokenUrl = discovery.token_endpoint;
|
||||||
const userInfoUrl = discovery.userinfo_endpoint;
|
const userInfoUrl = discovery.userinfo_endpoint;
|
||||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||||
const authzUrlObj = new URL(authzUrl);
|
|
||||||
// Only validate authorizationUrl hostname against issuer — token/userinfo
|
|
||||||
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
|
|
||||||
if (authzUrlObj.hostname !== issuerHostname) {
|
|
||||||
throw new Error(
|
|
||||||
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
oidcConfig = {
|
oidcConfig = {
|
||||||
authorizationUrl: authzUrl,
|
authorizationUrl: authzUrl,
|
||||||
tokenUrl: providerConfig.internalBaseUrl
|
tokenUrl: providerConfig.internalBaseUrl
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
|
||||||
import { getDb, impersonationAuditLogs } from "@groombook/db";
|
|
||||||
import type { PortalEnv } from "./portalSession.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-side audit logging middleware for portal routes.
|
|
||||||
* Applied after validatePortalSession in the middleware chain.
|
|
||||||
*
|
|
||||||
* After the route handler completes (await next()), inserts an audit log entry
|
|
||||||
* into impersonationAuditLogs:
|
|
||||||
* - sessionId: from c.get("portalSessionId")
|
|
||||||
* - action: "{METHOD} {routePath}" (e.g., "GET /portal/appointments")
|
|
||||||
* - pageVisited: c.req.path
|
|
||||||
* - metadata: { method, statusCode: c.res.status }
|
|
||||||
*
|
|
||||||
* Log entries are written for both success and error responses.
|
|
||||||
* Does NOT throw if audit logging fails — errors are logged but the user's
|
|
||||||
* request is not affected.
|
|
||||||
*/
|
|
||||||
export const portalAudit: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
|
||||||
await next();
|
|
||||||
|
|
||||||
const sessionId = c.get("portalSessionId");
|
|
||||||
if (!sessionId) return;
|
|
||||||
|
|
||||||
const method = c.req.method;
|
|
||||||
const routePath = c.req.path;
|
|
||||||
const pageVisited = c.req.path;
|
|
||||||
const statusCode = c.res.status;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
await db
|
|
||||||
.insert(impersonationAuditLogs)
|
|
||||||
.values({
|
|
||||||
sessionId,
|
|
||||||
action: `${method} ${routePath}`,
|
|
||||||
pageVisited,
|
|
||||||
metadata: { method, statusCode },
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[portalAudit] Failed to write audit log:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
|
||||||
import { and, eq, getDb, impersonationSessions } from "@groombook/db";
|
|
||||||
|
|
||||||
export interface PortalEnv {
|
|
||||||
Variables: {
|
|
||||||
portalClientId: string;
|
|
||||||
portalSessionId: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates the X-Impersonation-Session-Id header against the impersonationSessions table.
|
|
||||||
* Must be applied to all portal routes.
|
|
||||||
*
|
|
||||||
* Reads x-session-id from request headers, queries impersonationSessions for a row where
|
|
||||||
* id = sessionId AND status = 'active', and checks session.expiresAt > new Date().
|
|
||||||
* Returns 401 if session is invalid/missing/expired.
|
|
||||||
* On success, sets c.set("portalClientId", session.clientId) and c.set("portalSessionId", session.id).
|
|
||||||
*/
|
|
||||||
export const validatePortalSession: MiddlewareHandler<PortalEnv> = async (c, next) => {
|
|
||||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
|
||||||
if (!sessionId) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const [session] = await db
|
|
||||||
.select()
|
|
||||||
.from(impersonationSessions)
|
|
||||||
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!session || session.expiresAt <= new Date()) {
|
|
||||||
return c.json({ error: "Unauthorized" }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
c.set("portalClientId", session.clientId);
|
|
||||||
c.set("portalSessionId", session.id);
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { MiddlewareHandler } from "hono";
|
import type { MiddlewareHandler } from "hono";
|
||||||
import { and, eq, getDb, sql, staff } from "@groombook/db";
|
import { eq, getDb, staff } from "@groombook/db";
|
||||||
|
|
||||||
export type StaffRole = "groomer" | "receptionist" | "manager";
|
export type StaffRole = "groomer" | "receptionist" | "manager";
|
||||||
export type StaffRow = typeof staff.$inferSelect;
|
export type StaffRow = typeof staff.$inferSelect;
|
||||||
@@ -89,31 +89,14 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
|
|||||||
.select()
|
.select()
|
||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.oidcSub, jwt.sub));
|
.where(eq(staff.oidcSub, jwt.sub));
|
||||||
if (fallbackRow) {
|
if (!fallbackRow) {
|
||||||
c.set("staff", fallbackRow);
|
|
||||||
await next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Auto-link by email: staff record exists with matching email but no userId
|
|
||||||
if (jwt.email) {
|
|
||||||
const [byEmail] = await db
|
|
||||||
.select()
|
|
||||||
.from(staff)
|
|
||||||
.where(and(eq(staff.email, jwt.email), sql`${staff.userId} IS NULL`));
|
|
||||||
if (byEmail) {
|
|
||||||
await db
|
|
||||||
.update(staff)
|
|
||||||
.set({ userId: jwt.sub, updatedAt: new Date() })
|
|
||||||
.where(eq(staff.id, byEmail.id));
|
|
||||||
c.set("staff", { ...byEmail, userId: jwt.sub });
|
|
||||||
await next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: "Forbidden: no staff record found for authenticated user" },
|
{ error: "Forbidden: no staff record found for authenticated user" },
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
c.set("staff", fallbackRow);
|
||||||
|
await next();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,9 +149,9 @@ export function requireRoleOrSuperUser(
|
|||||||
}
|
}
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: hasAllowedRole
|
error: staffRow.isSuperUser
|
||||||
? "Forbidden: super user privileges required"
|
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
: "Forbidden: super user privileges required",
|
||||||
},
|
},
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,27 +23,6 @@ import { buildConfirmationEmail, sendEmail } from "../services/email.js";
|
|||||||
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
import { notifyWaitlistForAppointment } from "../services/waitlistNotify.js";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
async function withRetry<T>(
|
|
||||||
fn: () => Promise<T>,
|
|
||||||
maxRetries: number,
|
|
||||||
delayMs: number,
|
|
||||||
context: string
|
|
||||||
): Promise<void> {
|
|
||||||
let lastError: unknown;
|
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
return;
|
|
||||||
} catch (err) {
|
|
||||||
lastError = err;
|
|
||||||
if (attempt < maxRetries) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.error(`[appointments] ${context}: ${lastError}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const appointmentsRouter = new Hono<AppEnv>();
|
export const appointmentsRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
const createAppointmentSchema = z.object({
|
const createAppointmentSchema = z.object({
|
||||||
@@ -62,10 +41,6 @@ const createAppointmentSchema = z.object({
|
|||||||
frequencyWeeks: z.number().int().min(1).max(52),
|
frequencyWeeks: z.number().int().min(1).max(52),
|
||||||
count: z.number().int().min(2).max(52),
|
count: z.number().int().min(2).max(52),
|
||||||
})
|
})
|
||||||
.refine(
|
|
||||||
(r) => r.frequencyWeeks * r.count <= 52,
|
|
||||||
{ message: "Recurrence series must not exceed 1 year" }
|
|
||||||
)
|
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,54 +208,11 @@ appointmentsRouter.post(
|
|||||||
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
recurrence.frequencyWeeks * 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
let first: typeof appointments.$inferSelect | undefined;
|
let first: typeof appointments.$inferSelect | undefined;
|
||||||
const conflictingInstances: number[] = [];
|
|
||||||
for (let i = 0; i < recurrence.count; i++) {
|
for (let i = 0; i < recurrence.count; i++) {
|
||||||
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
const instanceStart = new Date(start.getTime() + i * intervalMs);
|
||||||
const instanceEnd = new Date(
|
const instanceEnd = new Date(
|
||||||
instanceStart.getTime() + durationMs
|
instanceStart.getTime() + durationMs
|
||||||
);
|
);
|
||||||
|
|
||||||
if (apptFields.staffId) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.staffId, apptFields.staffId),
|
|
||||||
lt(appointments.startTime, instanceEnd),
|
|
||||||
gte(appointments.endTime, instanceStart),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
conflictingInstances.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apptFields.batherStaffId) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, apptFields.batherStaffId),
|
|
||||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
|
||||||
),
|
|
||||||
lt(appointments.startTime, instanceEnd),
|
|
||||||
gte(appointments.endTime, instanceStart),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
conflictingInstances.push(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [inserted] = await tx
|
const [inserted] = await tx
|
||||||
.insert(appointments)
|
.insert(appointments)
|
||||||
.values({
|
.values({
|
||||||
@@ -291,19 +223,9 @@ appointmentsRouter.post(
|
|||||||
seriesIndex: i,
|
seriesIndex: i,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
if (!inserted) throw new Error(`Insert failed for occurrence ${i}`);
|
|
||||||
if (i === 0) first = inserted;
|
if (i === 0) first = inserted;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conflictingInstances.length > 0) {
|
|
||||||
throw Object.assign(
|
|
||||||
new Error(
|
|
||||||
`Conflicts detected at occurrence(s): ${conflictingInstances.join(", ")}`
|
|
||||||
),
|
|
||||||
{ statusCode: 409 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!first) throw new Error("No appointments created");
|
if (!first) throw new Error("No appointments created");
|
||||||
return first;
|
return first;
|
||||||
});
|
});
|
||||||
@@ -321,12 +243,9 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send confirmation email (fire-and-forget — never fails the request)
|
// Send confirmation email (fire-and-forget — never fails the request)
|
||||||
withRetry(
|
sendConfirmationEmail(db, firstRow).catch((err) => {
|
||||||
() => sendConfirmationEmail(db, firstRow),
|
console.error("[appointments] Failed to send confirmation email:", err);
|
||||||
2,
|
});
|
||||||
1000,
|
|
||||||
`Failed to send confirmation email for appointment ${firstRow.id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json(firstRow, 201);
|
return c.json(firstRow, 201);
|
||||||
}
|
}
|
||||||
@@ -338,35 +257,44 @@ async function sendConfirmationEmail(
|
|||||||
db: ReturnType<typeof getDb>,
|
db: ReturnType<typeof getDb>,
|
||||||
appt: typeof appointments.$inferSelect
|
appt: typeof appointments.$inferSelect
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const [row] = await db
|
const [client] = await db
|
||||||
.select({
|
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||||
clientName: clients.name,
|
.from(clients)
|
||||||
clientEmail: clients.email,
|
.where(eq(clients.id, appt.clientId))
|
||||||
clientEmailOptOut: clients.emailOptOut,
|
|
||||||
petName: pets.name,
|
|
||||||
serviceName: services.name,
|
|
||||||
groomerName: staff.name,
|
|
||||||
})
|
|
||||||
.from(appointments)
|
|
||||||
.innerJoin(clients, eq(clients.id, appointments.clientId))
|
|
||||||
.innerJoin(pets, eq(pets.id, appointments.petId))
|
|
||||||
.innerJoin(services, eq(services.id, appointments.serviceId))
|
|
||||||
.leftJoin(staff, eq(staff.id, appointments.staffId))
|
|
||||||
.where(eq(appointments.id, appt.id))
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!row) return;
|
if (!client || !client.email || client.emailOptOut) return;
|
||||||
const { clientName, clientEmail, clientEmailOptOut, petName, serviceName, groomerName } = row;
|
|
||||||
|
|
||||||
if (!clientEmail || clientEmailOptOut) return;
|
const [pet] = await db
|
||||||
if (!petName || !serviceName) return;
|
.select({ name: pets.name })
|
||||||
|
.from(pets)
|
||||||
|
.where(eq(pets.id, appt.petId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const [service] = await db
|
||||||
|
.select({ name: services.name })
|
||||||
|
.from(services)
|
||||||
|
.where(eq(services.id, appt.serviceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let groomerName: string | null = null;
|
||||||
|
if (appt.staffId) {
|
||||||
|
const [groomer] = await db
|
||||||
|
.select({ name: staff.name })
|
||||||
|
.from(staff)
|
||||||
|
.where(eq(staff.id, appt.staffId))
|
||||||
|
.limit(1);
|
||||||
|
groomerName = groomer?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pet || !service) return;
|
||||||
|
|
||||||
const sent = await sendEmail(
|
const sent = await sendEmail(
|
||||||
buildConfirmationEmail(clientEmail, {
|
buildConfirmationEmail(client.email, {
|
||||||
clientName,
|
clientName: client.name,
|
||||||
petName,
|
petName: pet.name,
|
||||||
serviceName,
|
serviceName: service.name,
|
||||||
groomerName: groomerName ?? null,
|
groomerName,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -446,76 +374,6 @@ appointmentsRouter.patch(
|
|||||||
|
|
||||||
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
let firstUpdated: typeof appointments.$inferSelect | undefined;
|
||||||
for (const appt of affected) {
|
for (const appt of affected) {
|
||||||
const newStart =
|
|
||||||
startDeltaMs !== 0
|
|
||||||
? new Date(appt.startTime.getTime() + startDeltaMs)
|
|
||||||
: appt.startTime;
|
|
||||||
const newEnd =
|
|
||||||
endDeltaMs !== 0
|
|
||||||
? new Date(appt.endTime.getTime() + endDeltaMs)
|
|
||||||
: appt.endTime;
|
|
||||||
const newStaffId =
|
|
||||||
updateFields.staffId !== undefined
|
|
||||||
? updateFields.staffId
|
|
||||||
: appt.staffId;
|
|
||||||
const newBatherStaffId =
|
|
||||||
updateFields.batherStaffId !== undefined
|
|
||||||
? updateFields.batherStaffId
|
|
||||||
: appt.batherStaffId;
|
|
||||||
|
|
||||||
if (
|
|
||||||
newStaffId &&
|
|
||||||
(startDeltaMs !== 0 ||
|
|
||||||
endDeltaMs !== 0 ||
|
|
||||||
updateFields.staffId !== undefined)
|
|
||||||
) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.staffId, newStaffId),
|
|
||||||
lt(appointments.startTime, newEnd),
|
|
||||||
gte(appointments.endTime, newStart),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
ne(appointments.id, appt.id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
newBatherStaffId &&
|
|
||||||
(startDeltaMs !== 0 ||
|
|
||||||
endDeltaMs !== 0 ||
|
|
||||||
updateFields.batherStaffId !== undefined)
|
|
||||||
) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, newBatherStaffId),
|
|
||||||
eq(appointments.batherStaffId, newBatherStaffId)
|
|
||||||
),
|
|
||||||
lt(appointments.startTime, newEnd),
|
|
||||||
gte(appointments.endTime, newStart),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
ne(appointments.id, appt.id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const apptUpdate: Record<string, unknown> = {
|
const apptUpdate: Record<string, unknown> = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -551,13 +409,6 @@ appointmentsRouter.patch(
|
|||||||
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
if (statusCode === 404) return c.json({ error: "Not found" }, 404);
|
||||||
if (statusCode === 422)
|
if (statusCode === 422)
|
||||||
return c.json({ error: "endTime must be after startTime" }, 422);
|
return c.json({ error: "endTime must be after startTime" }, 422);
|
||||||
if (statusCode === 409)
|
|
||||||
return c.json(
|
|
||||||
{
|
|
||||||
error: "Staff member has a conflicting appointment at this time",
|
|
||||||
},
|
|
||||||
409
|
|
||||||
);
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,12 +586,9 @@ appointmentsRouter.delete("/:id", async (c) => {
|
|||||||
|
|
||||||
const apptDate = current.startTime.toISOString().slice(0, 10);
|
const apptDate = current.startTime.toISOString().slice(0, 10);
|
||||||
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
const apptTime = current.startTime.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: true });
|
||||||
withRetry(
|
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
||||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
console.error("[appointments] Failed to notify waitlist:", err);
|
||||||
2,
|
});
|
||||||
1000,
|
|
||||||
`Failed to notify waitlist for appointment ${id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -763,12 +611,9 @@ appointmentsRouter.delete("/:id", async (c) => {
|
|||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
withRetry(
|
notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId).catch((err) => {
|
||||||
() => notifyWaitlistForAppointment(id, apptDate, apptTime, current.serviceId),
|
console.error("[appointments] Failed to notify waitlist:", err);
|
||||||
2,
|
});
|
||||||
1000,
|
|
||||||
`Failed to notify waitlist for appointment ${id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
+12
-28
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
|
|
||||||
const bookingSchema = z.object({
|
const bookingSchema = z.object({
|
||||||
serviceId: z.string().uuid(),
|
serviceId: z.string().uuid(),
|
||||||
startTime: z.string().datetime().refine(
|
startTime: z.string().datetime(),
|
||||||
(dt) => new Date(dt) > new Date(),
|
|
||||||
{ message: "Appointment must be in the future" }
|
|
||||||
),
|
|
||||||
clientName: z.string().min(1).max(200),
|
clientName: z.string().min(1).max(200),
|
||||||
clientEmail: z.string().email(),
|
clientEmail: z.string().email(),
|
||||||
clientPhone: z.string().max(50).optional(),
|
clientPhone: z.string().max(50).optional(),
|
||||||
@@ -268,36 +265,29 @@ bookRouter.get("/confirm/:token", async (c) => {
|
|||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject if appointment is in the past
|
||||||
if (appt.startTime < new Date()) {
|
if (appt.startTime < new Date()) {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idempotent confirm: if already confirmed, redirect to success
|
||||||
if (appt.confirmationStatus === "confirmed") {
|
if (appt.confirmationStatus === "confirmed") {
|
||||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject if already cancelled
|
||||||
if (appt.confirmationStatus === "cancelled") {
|
if (appt.confirmationStatus === "cancelled") {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await db
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({
|
.set({
|
||||||
confirmationStatus: "confirmed",
|
confirmationStatus: "confirmed",
|
||||||
confirmedAt: new Date(),
|
confirmedAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(appointments.id, appt.id));
|
||||||
and(
|
|
||||||
eq(appointments.confirmationToken, token),
|
|
||||||
eq(appointments.confirmationStatus, "pending")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (updated.length === 0) {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||||
});
|
});
|
||||||
@@ -319,15 +309,19 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject if appointment is in the past
|
||||||
if (appt.startTime < new Date()) {
|
if (appt.startTime < new Date()) {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject if already cancelled (token was nullified — this path won't normally hit,
|
||||||
|
// but guard against edge cases where token lookup still works)
|
||||||
if (appt.confirmationStatus === "cancelled") {
|
if (appt.confirmationStatus === "cancelled") {
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await db
|
// Single-use cancellation: nullify token after use
|
||||||
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({
|
.set({
|
||||||
confirmationStatus: "cancelled",
|
confirmationStatus: "cancelled",
|
||||||
@@ -335,17 +329,7 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
confirmationToken: null,
|
confirmationToken: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(appointments.id, appt.id));
|
||||||
and(
|
|
||||||
eq(appointments.confirmationToken, token),
|
|
||||||
eq(appointments.confirmationStatus, "pending")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (updated.length === 0) {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
@@ -84,18 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
|
|||||||
.where(eq(staff.id, staffId))
|
.where(eq(staff.id, staffId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!staffMember || !staffMember.icalToken) {
|
if (!staffMember || staffMember.icalToken !== token) {
|
||||||
return c.text("Unauthorized", 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedToken = staffMember.icalToken;
|
|
||||||
const incomingToken = token;
|
|
||||||
const storedBuf = Buffer.from(storedToken, "utf8");
|
|
||||||
const incomingBuf = Buffer.from(incomingToken, "utf8");
|
|
||||||
if (
|
|
||||||
storedBuf.length !== incomingBuf.length ||
|
|
||||||
!timingSafeEqual(storedBuf, incomingBuf)
|
|
||||||
) {
|
|
||||||
return c.text("Unauthorized", 401);
|
return c.text("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ export const clientsRouter = new Hono<AppEnv>();
|
|||||||
|
|
||||||
const createClientSchema = z.object({
|
const createClientSchema = z.object({
|
||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
email: z.string().email(),
|
email: z.string().email().optional(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
address: z.string().max(500).optional(),
|
address: z.string().max(500).optional(),
|
||||||
notes: z.string().max(2000).optional(),
|
notes: z.string().max(2000).optional(),
|
||||||
smsOptIn: z.boolean().optional(),
|
|
||||||
smsConsentText: z.string().max(1000).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +95,6 @@ clientsRouter.post("/", zValidator("json", createClientSchema), async (c) => {
|
|||||||
// Update a client (including status changes)
|
// Update a client (including status changes)
|
||||||
const patchClientSchema = createClientSchema.partial().extend({
|
const patchClientSchema = createClientSchema.partial().extend({
|
||||||
status: z.enum(["active", "disabled"]).optional(),
|
status: z.enum(["active", "disabled"]).optional(),
|
||||||
smsOptOut: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
clientsRouter.patch(
|
clientsRouter.patch(
|
||||||
@@ -110,19 +107,13 @@ clientsRouter.patch(
|
|||||||
|
|
||||||
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
const setValues: Record<string, unknown> = { ...body, updatedAt: now };
|
||||||
|
|
||||||
|
// When disabling, set disabledAt; when re-enabling, clear it
|
||||||
if (body.status === "disabled") {
|
if (body.status === "disabled") {
|
||||||
setValues.disabledAt = now;
|
setValues.disabledAt = now;
|
||||||
} else if (body.status === "active") {
|
} else if (body.status === "active") {
|
||||||
setValues.disabledAt = null;
|
setValues.disabledAt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.smsOptOut === true) {
|
|
||||||
setValues.smsOptIn = false;
|
|
||||||
setValues.smsOptOutDate = now;
|
|
||||||
delete setValues.smsOptOut;
|
|
||||||
}
|
|
||||||
delete setValues.smsOptOut;
|
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set(setValues)
|
.set(setValues)
|
||||||
@@ -144,24 +135,9 @@ clientsRouter.delete("/:id", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.req.param("id");
|
|
||||||
|
|
||||||
const [existingAppt] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.clientId, clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingAppt) {
|
|
||||||
return c.json(
|
|
||||||
{ error: "Cannot delete client with existing appointments. Cancel or reassign appointments first." },
|
|
||||||
409
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.delete(clients)
|
.delete(clients)
|
||||||
.where(eq(clients.id, clientId))
|
.where(eq(clients.id, c.req.param("id")))
|
||||||
.returning();
|
.returning();
|
||||||
if (!row) return c.json({ error: "Not found" }, 404);
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
|
|||||||
+120
-59
@@ -4,11 +4,11 @@ import { z } from "zod/v3";
|
|||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
|
gte,
|
||||||
getDb,
|
getDb,
|
||||||
invoices,
|
invoices,
|
||||||
invoiceLineItems,
|
invoiceLineItems,
|
||||||
invoiceTipSplits,
|
invoiceTipSplits,
|
||||||
refunds,
|
|
||||||
appointments,
|
appointments,
|
||||||
services,
|
services,
|
||||||
clients,
|
clients,
|
||||||
@@ -45,20 +45,13 @@ const updateInvoiceSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// List invoices
|
// List invoices
|
||||||
const listInvoicesQuerySchema = z.object({
|
invoicesRouter.get("/", async (c) => {
|
||||||
clientId: z.string().uuid().optional(),
|
|
||||||
appointmentId: z.string().uuid().optional(),
|
|
||||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
||||||
offset: z.coerce.number().int().min(0).default(0),
|
|
||||||
});
|
|
||||||
|
|
||||||
invoicesRouter.get(
|
|
||||||
"/",
|
|
||||||
zValidator("query", listInvoicesQuerySchema),
|
|
||||||
async (c) => {
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
|
const clientId = c.req.query("clientId");
|
||||||
|
const appointmentId = c.req.query("appointmentId");
|
||||||
|
const status = c.req.query("status");
|
||||||
|
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
|
||||||
|
const offset = parseInt(c.req.query("offset") || "0", 10);
|
||||||
|
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||||
@@ -97,8 +90,7 @@ invoicesRouter.get(
|
|||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
|
||||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
// Get single invoice with line items and tip splits
|
// Get single invoice with line items and tip splits
|
||||||
invoicesRouter.get("/:id", async (c) => {
|
invoicesRouter.get("/:id", async (c) => {
|
||||||
@@ -126,8 +118,8 @@ const tipSplitSchema = z.object({
|
|||||||
})
|
})
|
||||||
).min(1).refine(
|
).min(1).refine(
|
||||||
(splits) => {
|
(splits) => {
|
||||||
const totalBps = splits.reduce((sum, s) => sum + Math.round(s.sharePct * 100), 0);
|
const total = splits.reduce((sum, s) => sum + s.sharePct, 0);
|
||||||
return totalBps === 10000;
|
return Math.abs(total - 100) < 0.01;
|
||||||
},
|
},
|
||||||
{ message: "Split percentages must sum to 100" }
|
{ message: "Split percentages must sum to 100" }
|
||||||
),
|
),
|
||||||
@@ -171,13 +163,12 @@ invoicesRouter.post(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [updatedInvoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
const splits = await db
|
||||||
const [lineItems, tipSplits] = await Promise.all([
|
.select()
|
||||||
db.select().from(invoiceLineItems).where(eq(invoiceLineItems.invoiceId, id)),
|
.from(invoiceTipSplits)
|
||||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
.where(eq(invoiceTipSplits.invoiceId, id));
|
||||||
]);
|
|
||||||
|
|
||||||
return c.json({ ...updatedInvoice, lineItems, tipSplits }, 201);
|
return c.json(splits, 201);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -302,13 +293,6 @@ invoicesRouter.post("/from-appointment/:appointmentId", async (c) => {
|
|||||||
return c.json({ ...invoice, lineItems: [lineItem] }, 201);
|
return c.json({ ...invoice, lineItems: [lineItem] }, 201);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
|
|
||||||
draft: ["pending", "void"],
|
|
||||||
pending: ["draft", "paid", "void"],
|
|
||||||
paid: ["void"],
|
|
||||||
void: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update invoice
|
// Update invoice
|
||||||
invoicesRouter.patch(
|
invoicesRouter.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
@@ -324,14 +308,8 @@ invoicesRouter.patch(
|
|||||||
.where(eq(invoices.id, id));
|
.where(eq(invoices.id, id));
|
||||||
if (!current) return c.json({ error: "Not found" }, 404);
|
if (!current) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
if (body.status !== undefined) {
|
if (current.status === "void") {
|
||||||
const allowed = ALLOWED_TRANSITIONS[current.status] ?? [];
|
return c.json({ error: "Cannot modify a voided invoice" }, 422);
|
||||||
if (!allowed.includes(body.status)) {
|
|
||||||
return c.json(
|
|
||||||
{ error: `Invalid status transition from ${current.status} to ${body.status}` },
|
|
||||||
422
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
const update: Record<string, unknown> = { ...body, updatedAt: new Date() };
|
||||||
@@ -369,7 +347,6 @@ import { processRefund } from "../services/payment.js";
|
|||||||
|
|
||||||
const refundSchema = z.object({
|
const refundSchema = z.object({
|
||||||
amountCents: z.number().int().nonnegative().optional(),
|
amountCents: z.number().int().nonnegative().optional(),
|
||||||
idempotencyKey: z.string().max(255).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
invoicesRouter.post(
|
invoicesRouter.post(
|
||||||
@@ -395,28 +372,112 @@ invoicesRouter.post(
|
|||||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await db.transaction(async (tx) => {
|
|
||||||
if (body.idempotencyKey) {
|
|
||||||
const [existing] = await tx
|
|
||||||
.select()
|
|
||||||
.from(refunds)
|
|
||||||
.where(eq(refunds.idempotencyKey, body.idempotencyKey));
|
|
||||||
if (existing) {
|
|
||||||
return c.json({ refundId: existing.stripeRefundId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await processRefund(id, body.amountCents);
|
const result = await processRefund(id, body.amountCents);
|
||||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||||
|
|
||||||
await tx.insert(refunds).values({
|
|
||||||
invoiceId: id,
|
|
||||||
stripeRefundId: result.refundId,
|
|
||||||
idempotencyKey: body.idempotencyKey ?? null,
|
|
||||||
amountCents: body.amountCents ?? null,
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({ refundId: result.refundId });
|
return c.json({ refundId: result.refundId });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ─── Stripe Payment Info ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import { getStripeClient } from "../services/payment.js";
|
||||||
|
|
||||||
|
invoicesRouter.get("/:id/stripe-payment", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const id = c.req.param("id");
|
||||||
|
|
||||||
|
const [invoice] = await db.select().from(invoices).where(eq(invoices.id, id));
|
||||||
|
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
|
if (!invoice.stripePaymentIntentId) {
|
||||||
|
return c.json({ error: "No Stripe payment found for this invoice" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripeClient();
|
||||||
|
if (!stripe) return c.json({ error: "Stripe not configured" }, 503);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const paymentIntent = await stripe.paymentIntents.retrieve(invoice.stripePaymentIntentId);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const cardDetails = (paymentIntent as any).payment_details?.card;
|
||||||
|
const refundStatus = invoice.stripeRefundId
|
||||||
|
? await stripe.refunds.retrieve(invoice.stripeRefundId).then((r) => r.status).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
paymentIntentId: invoice.stripePaymentIntentId,
|
||||||
|
amountPaidCents: paymentIntent.amount_received,
|
||||||
|
status: paymentIntent.status,
|
||||||
|
cardLast4: cardDetails?.last4 ?? null,
|
||||||
|
cardBrand: cardDetails?.brand ?? null,
|
||||||
|
refundId: invoice.stripeRefundId,
|
||||||
|
refundStatus,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Failed to retrieve Stripe payment info" }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Payment Stats ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
invoicesRouter.get("/stats", async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
|
const thisMonthInvoices = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(invoices.createdAt, startOfMonth),
|
||||||
|
eq(invoices.status, "paid")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const revenueCents = thisMonthInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||||
|
|
||||||
|
const pendingInvoices = await db
|
||||||
|
.select({ totalCents: invoices.totalCents })
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.status, "pending"));
|
||||||
|
|
||||||
|
const outstandingCents = pendingInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||||
|
|
||||||
|
const refundedInvoices = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(invoices.createdAt, startOfMonth),
|
||||||
|
sql`${invoices.stripeRefundId} IS NOT NULL`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const refundsCents = refundedInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||||
|
|
||||||
|
const paymentMethodBreakdown = await db
|
||||||
|
.select({
|
||||||
|
paymentMethod: invoices.paymentMethod,
|
||||||
|
count: sql<number>`count(*)`,
|
||||||
|
totalCents: sql<number>`sum(${invoices.totalCents})`,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(invoices.createdAt, startOfMonth),
|
||||||
|
sql`${invoices.paymentMethod} IS NOT NULL`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(invoices.paymentMethod);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
revenueCents,
|
||||||
|
outstandingCents,
|
||||||
|
refundsCents,
|
||||||
|
revenueCount: thisMonthInvoices.length,
|
||||||
|
refundCount: refundedInvoices.length,
|
||||||
|
paymentMethodBreakdown,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+122
-23
@@ -1,22 +1,33 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { eq, inArray } from "@groombook/db";
|
import { and, eq, inArray } from "@groombook/db";
|
||||||
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
import { getDb, appointments, impersonationSessions, waitlistEntries, clients, pets, services, staff, invoices, invoiceLineItems } from "@groombook/db";
|
||||||
import { validatePortalSession } from "../middleware/portalSession.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
import { portalAudit } from "../middleware/portalAudit.js";
|
|
||||||
import type { PortalEnv } from "../middleware/portalSession.js";
|
|
||||||
|
|
||||||
export const portalRouter = new Hono<PortalEnv>();
|
export const portalRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// Apply middleware to all portal routes
|
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||||
portalRouter.use("/*", validatePortalSession, portalAudit);
|
|
||||||
|
async function getClientIdFromSession(sessionId: string | null | undefined): Promise<string | null> {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const db = getDb();
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(and(eq(impersonationSessions.id, sessionId), eq(impersonationSessions.status, "active")))
|
||||||
|
.limit(1);
|
||||||
|
if (!session || session.expiresAt <= new Date()) return null;
|
||||||
|
return session.clientId;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET routes ──────────────────────────────────────────────────────────────
|
// ─── GET routes ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
portalRouter.get("/me", async (c) => {
|
portalRouter.get("/me", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
const [client] = await db.select().from(clients).where(eq(clients.id, clientId)).limit(1);
|
||||||
if (!client) return c.json({ error: "Not found" }, 404);
|
if (!client) return c.json({ error: "Not found" }, 404);
|
||||||
@@ -38,7 +49,9 @@ portalRouter.get("/services", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/appointments", async (c) => {
|
portalRouter.get("/appointments", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const allAppts = await db
|
const allAppts = await db
|
||||||
@@ -88,7 +101,9 @@ portalRouter.get("/appointments", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/pets", async (c) => {
|
portalRouter.get("/pets", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId));
|
||||||
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weightKg: p.weightKg, dateOfBirth: p.dateOfBirth, photoKey: p.photoKey, groomingNotes: p.groomingNotes })));
|
||||||
@@ -96,7 +111,9 @@ portalRouter.get("/pets", async (c) => {
|
|||||||
|
|
||||||
portalRouter.get("/invoices", async (c) => {
|
portalRouter.get("/invoices", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
const clientInvoices = await db.select().from(invoices).where(eq(invoices.clientId, clientId));
|
||||||
const invoiceIds = clientInvoices.map(i => i.id);
|
const invoiceIds = clientInvoices.map(i => i.id);
|
||||||
@@ -131,7 +148,12 @@ portalRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -174,7 +196,12 @@ portalRouter.patch(
|
|||||||
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
portalRouter.post("/appointments/:id/confirm", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -223,7 +250,12 @@ portalRouter.post("/appointments/:id/confirm", async (c) => {
|
|||||||
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
portalRouter.post("/appointments/:id/cancel", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -287,7 +319,28 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
let clientId: string | null = null;
|
||||||
|
if (sessionId) {
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
if (session && session.expiresAt > new Date()) {
|
||||||
|
clientId = session.clientId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [entry] = await db
|
const [entry] = await db
|
||||||
.insert(waitlistEntries)
|
.insert(waitlistEntries)
|
||||||
@@ -311,7 +364,26 @@ portalRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -320,7 +392,7 @@ portalRouter.patch(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existing) return c.json({ error: "Not found" }, 404);
|
if (!existing) return c.json({ error: "Not found" }, 404);
|
||||||
if (existing.clientId !== clientId) {
|
if (existing.clientId !== session.clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,7 +414,26 @@ portalRouter.patch(
|
|||||||
portalRouter.delete("/waitlist/:id", async (c) => {
|
portalRouter.delete("/waitlist/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [session] = await db
|
||||||
|
.select()
|
||||||
|
.from(impersonationSessions)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(impersonationSessions.id, sessionId),
|
||||||
|
eq(impersonationSessions.status, "active")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!session || session.expiresAt <= new Date()) {
|
||||||
|
return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const [entry] = await db
|
const [entry] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -351,7 +442,7 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!entry) return c.json({ error: "Not found" }, 404);
|
if (!entry) return c.json({ error: "Not found" }, 404);
|
||||||
if (entry.clientId !== clientId) {
|
if (entry.clientId !== session.clientId) {
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
return c.json({ error: "Forbidden" }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +475,9 @@ portalRouter.post(
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const invoiceRows = await db
|
const invoiceRows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -421,7 +514,9 @@ portalRouter.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
portalRouter.get("/payment-methods", async (c) => {
|
portalRouter.get("/payment-methods", async (c) => {
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const methods = await listPaymentMethods(clientId);
|
const methods = await listPaymentMethods(clientId);
|
||||||
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
if (methods === null) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
@@ -429,7 +524,9 @@ portalRouter.get("/payment-methods", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.post("/payment-methods", async (c) => {
|
portalRouter.post("/payment-methods", async (c) => {
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
const customerId = await getOrCreateStripeCustomer(clientId);
|
const customerId = await getOrCreateStripeCustomer(clientId);
|
||||||
@@ -442,7 +539,9 @@ portalRouter.post("/payment-methods", async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.delete("/payment-methods/:id", async (c) => {
|
portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||||
const clientId = c.get("portalClientId");
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const paymentMethodId = c.req.param("id");
|
const paymentMethodId = c.req.param("id");
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
|
|||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
basePriceCents: z.number().int().positive(),
|
basePriceCents: z.number().int().positive(),
|
||||||
durationMinutes: z.number().int().positive().max(480),
|
durationMinutes: z.number().int().positive(),
|
||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,6 @@ import { z } from "zod/v3";
|
|||||||
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
import { and, eq, getDb, sql, staff, businessSettings, authProviderConfig, encryptSecret } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
import type { AppEnv } from "../middleware/rbac.js";
|
||||||
|
|
||||||
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
||||||
const RATE_LIMIT_MAX = 10;
|
|
||||||
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
|
||||||
|
|
||||||
function rateLimitByIp(ip: string): { allowed: boolean; remaining: number } {
|
|
||||||
const now = Date.now();
|
|
||||||
const entry = rateLimitMap.get(ip);
|
|
||||||
if (!entry || now > entry.resetAt) {
|
|
||||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
|
||||||
return { allowed: true, remaining: RATE_LIMIT_MAX - 1 };
|
|
||||||
}
|
|
||||||
if (entry.count >= RATE_LIMIT_MAX) {
|
|
||||||
return { allowed: false, remaining: 0 };
|
|
||||||
}
|
|
||||||
entry.count++;
|
|
||||||
return { allowed: true, remaining: RATE_LIMIT_MAX - entry.count };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const setupRouter = new Hono<AppEnv>();
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||||
@@ -203,43 +185,37 @@ const authProviderTestSchema = z.object({
|
|||||||
* After setup completes, this endpoint permanently returns 403.
|
* After setup completes, this endpoint permanently returns 403.
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider", async (c) => {
|
setupRouter.post("/auth-provider", async (c) => {
|
||||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const { allowed, remaining } = rateLimitByIp(ip);
|
|
||||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
|
||||||
if (!allowed) {
|
|
||||||
return c.json({ error: "Too many requests. Please try again later." }, 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
let row: typeof authProviderConfig.$inferSelect;
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
try {
|
const [superUser] = await db
|
||||||
row = await db.transaction(async (tx) => {
|
|
||||||
const [superUser] = await tx
|
|
||||||
.select({ id: staff.id })
|
.select({ id: staff.id })
|
||||||
.from(staff)
|
.from(staff)
|
||||||
.where(eq(staff.isSuperUser, true))
|
.where(eq(staff.isSuperUser, true))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (superUser) {
|
if (superUser) {
|
||||||
throw Object.assign(new Error("setup-complete"), { code: 403 });
|
// Setup already completed — lock this endpoint permanently
|
||||||
|
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [existingConfig] = await tx
|
// Guard: ensure no DB config already exists (should be redundant with status check but defensive)
|
||||||
|
const [existingConfig] = await db
|
||||||
.select({ id: authProviderConfig.id })
|
.select({ id: authProviderConfig.id })
|
||||||
.from(authProviderConfig)
|
.from(authProviderConfig)
|
||||||
.where(eq(authProviderConfig.enabled, true))
|
.where(eq(authProviderConfig.enabled, true))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (existingConfig) {
|
if (existingConfig) {
|
||||||
throw Object.assign(new Error("config-exists"), { code: 409 });
|
return c.json({ error: "Auth provider is already configured." }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
||||||
|
|
||||||
|
// Encrypt clientSecret before storing
|
||||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||||
|
|
||||||
const [configRow] = await tx
|
const [row] = await db
|
||||||
.insert(authProviderConfig)
|
.insert(authProviderConfig)
|
||||||
.values({
|
.values({
|
||||||
providerId: body.providerId,
|
providerId: body.providerId,
|
||||||
@@ -253,24 +229,8 @@ setupRouter.post("/auth-provider", async (c) => {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
if (!configRow) {
|
if (!row) {
|
||||||
throw Object.assign(new Error("insert-failed"), { code: 500 });
|
return c.json({ error: "Failed to save auth provider configuration." }, 500);
|
||||||
}
|
|
||||||
|
|
||||||
return configRow;
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const e = err as Error & { code?: number };
|
|
||||||
if (e.message === "setup-complete") {
|
|
||||||
return c.json({ error: "Setup has already been completed. This endpoint is no longer available." }, e.code as 403);
|
|
||||||
}
|
|
||||||
if (e.message === "config-exists") {
|
|
||||||
return c.json({ error: "Auth provider is already configured." }, e.code as 409);
|
|
||||||
}
|
|
||||||
if (e.message === "insert-failed") {
|
|
||||||
return c.json({ error: "Failed to save auth provider configuration." }, e.code as 500);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -294,13 +254,6 @@ setupRouter.post("/auth-provider", async (c) => {
|
|||||||
* Only available when needsSetup is true (no super user = fresh install).
|
* Only available when needsSetup is true (no super user = fresh install).
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider/test", async (c) => {
|
setupRouter.post("/auth-provider/test", async (c) => {
|
||||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
||||||
const { allowed, remaining } = rateLimitByIp(ip);
|
|
||||||
c.res.headers.set("x-rate-limit-remaining", String(remaining));
|
|
||||||
if (!allowed) {
|
|
||||||
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Guard: only allow during fresh install (no super user yet)
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { z } from "zod/v3";
|
|
||||||
import { eq, getDb, invoices } from "@groombook/db";
|
import { eq, getDb, invoices } from "@groombook/db";
|
||||||
import { getStripeClient } from "../services/payment.js";
|
import { getStripeClient } from "../services/payment.js";
|
||||||
|
|
||||||
@@ -45,13 +44,10 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
|
||||||
if (!parsed.success) continue;
|
|
||||||
const invoiceIdTrimmed = invoiceId.trim();
|
|
||||||
const [inv] = await db
|
const [inv] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.id, invoiceIdTrimmed))
|
.where(eq(invoices.id, invoiceId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!inv) continue;
|
if (!inv) continue;
|
||||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||||
@@ -64,7 +60,7 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
stripePaymentIntentId: pi.id,
|
stripePaymentIntentId: pi.id,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
.where(eq(invoices.id, invoiceId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "payment_intent.payment_failed") {
|
} else if (event.type === "payment_intent.payment_failed") {
|
||||||
@@ -73,16 +69,13 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
|
||||||
if (!parsed.success) continue;
|
|
||||||
const invoiceIdTrimmed = invoiceId.trim();
|
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
.where(eq(invoices.id, invoiceId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "charge.refunded") {
|
} else if (event.type === "charge.refunded") {
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ import {
|
|||||||
buildReminderEmail,
|
buildReminderEmail,
|
||||||
sendEmail,
|
sendEmail,
|
||||||
} from "./email.js";
|
} from "./email.js";
|
||||||
import { smsSend } from "./sms.js";
|
|
||||||
|
|
||||||
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
|
|
||||||
|
|
||||||
|
// How many hours before the appointment to send each reminder.
|
||||||
|
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
|
||||||
function getReminderWindows(): { label: string; hours: number }[] {
|
function getReminderWindows(): { label: string; hours: number }[] {
|
||||||
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
||||||
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
||||||
@@ -31,14 +30,20 @@ function getReminderWindows(): { label: string; hours: number }[] {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checks for upcoming appointments that need reminders and sends them.
|
||||||
|
// Runs every minute — idempotent via reminder_logs unique constraint.
|
||||||
export async function runReminderCheck(): Promise<void> {
|
export async function runReminderCheck(): Promise<void> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
for (const window of getReminderWindows()) {
|
for (const window of getReminderWindows()) {
|
||||||
|
// Target window: appointments starting between (hours - 1) and hours from now.
|
||||||
|
// Running every minute means we check a 1-minute slice; the 1-hour window
|
||||||
|
// ensures we catch appointments that started between heartbeats.
|
||||||
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
const windowStart = new Date(now.getTime() + (window.hours - 1) * 3600_000);
|
||||||
const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
|
const windowEnd = new Date(now.getTime() + window.hours * 3600_000);
|
||||||
|
|
||||||
|
// Find upcoming appointments in this time window that haven't been cancelled/completed
|
||||||
const upcoming = await db
|
const upcoming = await db
|
||||||
.select({
|
.select({
|
||||||
id: appointments.id,
|
id: appointments.id,
|
||||||
@@ -60,38 +65,23 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const appt of upcoming) {
|
for (const appt of upcoming) {
|
||||||
const [emailLog] = await db
|
// Check if reminder already sent (unique constraint prevents double-send)
|
||||||
|
const existing = await db
|
||||||
.select({ id: reminderLogs.id })
|
.select({ id: reminderLogs.id })
|
||||||
.from(reminderLogs)
|
.from(reminderLogs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
eq(reminderLogs.appointmentId, appt.id),
|
||||||
eq(reminderLogs.reminderType, window.label),
|
eq(reminderLogs.reminderType, window.label)
|
||||||
eq(reminderLogs.channel, "email")
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const [smsLog] = await db
|
if (existing.length > 0) continue; // already sent
|
||||||
.select({ id: reminderLogs.id })
|
|
||||||
.from(reminderLogs)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
|
||||||
eq(reminderLogs.reminderType, window.label),
|
|
||||||
eq(reminderLogs.channel, "sms")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
|
// Fetch related records for the email
|
||||||
const [client] = await db
|
const [client] = await db
|
||||||
.select({
|
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||||
name: clients.name,
|
|
||||||
email: clients.email,
|
|
||||||
emailOptOut: clients.emailOptOut,
|
|
||||||
smsOptIn: clients.smsOptIn,
|
|
||||||
phone: clients.phone,
|
|
||||||
})
|
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.id, appt.clientId))
|
.where(eq(clients.id, appt.clientId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -122,6 +112,8 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
|
|
||||||
if (!pet || !service) continue;
|
if (!pet || !service) continue;
|
||||||
|
|
||||||
|
// Ensure the appointment has a confirmation token before sending the reminder.
|
||||||
|
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
|
||||||
let confirmationToken = appt.confirmationToken;
|
let confirmationToken = appt.confirmationToken;
|
||||||
if (!confirmationToken) {
|
if (!confirmationToken) {
|
||||||
confirmationToken = randomBytes(32).toString("hex");
|
confirmationToken = randomBytes(32).toString("hex");
|
||||||
@@ -131,7 +123,6 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
.where(eq(appointments.id, appt.id));
|
.where(eq(appointments.id, appt.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!emailLog) {
|
|
||||||
const sent = await sendEmail(
|
const sent = await sendEmail(
|
||||||
buildReminderEmail(
|
buildReminderEmail(
|
||||||
client.email,
|
client.email,
|
||||||
@@ -148,42 +139,19 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
|
// Record send — ignore conflicts (race condition between instances)
|
||||||
await db
|
await db
|
||||||
.insert(reminderLogs)
|
.insert(reminderLogs)
|
||||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
|
.values({ appointmentId: appt.id, reminderType: window.label })
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!smsLog && client.smsOptIn && client.phone) {
|
|
||||||
const apiUrl = process.env.API_URL ?? "http://localhost:3000";
|
|
||||||
const confirmUrl = `${apiUrl}/api/book/confirm/${confirmationToken}`;
|
|
||||||
const cancelUrl = `${apiUrl}/api/book/cancel/${confirmationToken}`;
|
|
||||||
const when = window.hours >= 24 ? "tomorrow" : `in ${window.hours} hours`;
|
|
||||||
const smsBody = [
|
|
||||||
`Hi ${client.name}, just a reminder: ${pet.name}'s grooming appointment is ${when}.`,
|
|
||||||
`Service: ${service.name}${groomerName ? ` with ${groomerName}` : ""}`,
|
|
||||||
`Confirm: ${confirmUrl}`,
|
|
||||||
`Cancel: ${cancelUrl}`,
|
|
||||||
TCPA_OPT_OUT,
|
|
||||||
].join(". ");
|
|
||||||
try {
|
|
||||||
const smsOk = await smsSend(client.phone, smsBody);
|
|
||||||
if (smsOk) {
|
|
||||||
await db
|
|
||||||
.insert(reminderLogs)
|
|
||||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "sms" })
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[reminders] SMS send failed:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Starts the cron scheduler. Call once at server startup.
|
||||||
export function startReminderScheduler(): void {
|
export function startReminderScheduler(): void {
|
||||||
|
// Run every minute
|
||||||
cron.schedule("* * * * *", () => {
|
cron.schedule("* * * * *", () => {
|
||||||
runReminderCheck().catch((err) => {
|
runReminderCheck().catch((err) => {
|
||||||
console.error("[reminders] Error during reminder check:", err);
|
console.error("[reminders] Error during reminder check:", err);
|
||||||
@@ -195,6 +163,8 @@ export function startReminderScheduler(): void {
|
|||||||
console.log("[reminders] Reminder scheduler started");
|
console.log("[reminders] Reminder scheduler started");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletes expired sessions from the database.
|
||||||
|
// Runs every minute alongside reminder checks.
|
||||||
export async function runSessionCleanup(): Promise<void> {
|
export async function runSessionCleanup(): Promise<void> {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import { Telnyx } from "telnyx";
|
|
||||||
import { createHmac } from "crypto";
|
|
||||||
|
|
||||||
export interface SmsProvider {
|
|
||||||
sendSms(to: string, body: string, mediaUrls?: string[]): Promise<{ messageId: string; status: string }>;
|
|
||||||
validateWebhookSignature(req: Request): boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TelnyxSmsResult {
|
|
||||||
message_id: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTelnyxClient(): Telnyx | null {
|
|
||||||
const apiKey = process.env.TELNYX_API_KEY;
|
|
||||||
if (!apiKey) return null;
|
|
||||||
return new Telnyx(apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _client: Telnyx | null | undefined;
|
|
||||||
|
|
||||||
function getClient(): Telnyx | null {
|
|
||||||
if (_client === undefined) _client = createTelnyxClient();
|
|
||||||
return _client;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFromNumber(): string | null {
|
|
||||||
return process.env.TELNYX_FROM_NUMBER ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isE164(phone: string): boolean {
|
|
||||||
return /^\+[1-9]\d{7,14}$/.test(phone);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendSms(
|
|
||||||
to: string,
|
|
||||||
body: string,
|
|
||||||
mediaUrls?: string[]
|
|
||||||
): Promise<{ messageId: string; status: string }> {
|
|
||||||
const client = getClient();
|
|
||||||
if (!client) throw new Error("Telnyx client not initialized. Set TELNYX_API_KEY.");
|
|
||||||
|
|
||||||
const from = getFromNumber();
|
|
||||||
if (!from) throw new Error("TELNYX_FROM_NUMBER is not set");
|
|
||||||
|
|
||||||
if (!isE164(to)) throw new Error(`Invalid recipient phone format: ${to}. Expected E.164.`);
|
|
||||||
if (!isE164(from)) throw new Error(`Invalid sender phone format: ${from}. Expected E.164.`);
|
|
||||||
|
|
||||||
const payload: Record<string, unknown> = {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
body,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (mediaUrls && mediaUrls.length > 0) {
|
|
||||||
payload.media_urls = mediaUrls;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await client.messages.create(payload as Record<string, string | string[]>);
|
|
||||||
const smsResult = result.data as unknown as TelnyxSmsResult;
|
|
||||||
return {
|
|
||||||
messageId: smsResult.message_id,
|
|
||||||
status: smsResult.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TelnyxProvider implements SmsProvider {
|
|
||||||
async sendSms(
|
|
||||||
to: string,
|
|
||||||
body: string,
|
|
||||||
mediaUrls?: string[]
|
|
||||||
): Promise<{ messageId: string; status: string }> {
|
|
||||||
return sendSms(to, body, mediaUrls);
|
|
||||||
}
|
|
||||||
|
|
||||||
validateWebhookSignature(req: Request): boolean {
|
|
||||||
const secret = process.env.TELNYX_WEBHOOK_SECRET;
|
|
||||||
if (!secret) return false;
|
|
||||||
|
|
||||||
const signature = req.headers.get("telnyx-signature");
|
|
||||||
if (!signature) return false;
|
|
||||||
|
|
||||||
const payload = JSON.stringify(req.body);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hmac = createHmac("sha256", secret);
|
|
||||||
const expected = `sha256=${hmac.update(payload).digest("hex")}`;
|
|
||||||
|
|
||||||
const sigBuf = Buffer.from(signature);
|
|
||||||
const expBuf = Buffer.from(expected);
|
|
||||||
|
|
||||||
if (sigBuf.length !== expBuf.length) return false;
|
|
||||||
|
|
||||||
let diff = 0;
|
|
||||||
for (let i = 0; i < sigBuf.length; i++) {
|
|
||||||
const sigByte = sigBuf[i] ?? 0;
|
|
||||||
const expByte = expBuf[i] ?? 0;
|
|
||||||
diff |= sigByte ^ expByte;
|
|
||||||
}
|
|
||||||
return diff === 0;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _provider: SmsProvider | null | undefined;
|
|
||||||
|
|
||||||
export function createSmsProvider(): SmsProvider | null {
|
|
||||||
if (_provider === undefined) {
|
|
||||||
if (process.env.SMS_ENABLED !== "true") {
|
|
||||||
_provider = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
switch (process.env.SMS_PROVIDER) {
|
|
||||||
case "telnyx": {
|
|
||||||
const client = getClient();
|
|
||||||
if (!client) {
|
|
||||||
_provider = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
_provider = new TelnyxProvider();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
_provider = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function smsSend(
|
|
||||||
to: string,
|
|
||||||
body: string,
|
|
||||||
mediaUrls?: string[]
|
|
||||||
): Promise<boolean> {
|
|
||||||
const provider = createSmsProvider();
|
|
||||||
if (!provider) return false;
|
|
||||||
|
|
||||||
await provider.sendSms(to, body, mediaUrls);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
Vendored
-19
@@ -1,19 +0,0 @@
|
|||||||
declare module "telnyx" {
|
|
||||||
export interface MessageResult {
|
|
||||||
data: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessagesCreateParams {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
body: string;
|
|
||||||
media_urls?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Telnyx {
|
|
||||||
constructor(apiKey: string);
|
|
||||||
messages: {
|
|
||||||
create(params: Record<string, string | string[]>): Promise<MessageResult>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit } from "@groombook/types";
|
import type { Invoice, Client, Appointment, Service, Staff, InvoiceTipSplit, StripePaymentInfo, PaymentStats } from "@groombook/types";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -173,6 +173,23 @@ function InvoiceDetailModal({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
const [tipStr, setTipStr] = useState((invoice.tipCents / 100).toFixed(2));
|
||||||
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
const [paymentMethod, setPaymentMethod] = useState<string>(invoice.paymentMethod ?? "cash");
|
||||||
|
const [stripeInfo, setStripeInfo] = useState<StripePaymentInfo | null>(null);
|
||||||
|
const [stripeLoading, setStripeLoading] = useState(false);
|
||||||
|
const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||||
|
const [refundType, setRefundType] = useState<"full" | "partial">("full");
|
||||||
|
const [refundAmountStr, setRefundAmountStr] = useState("");
|
||||||
|
const [refunding, setRefunding] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (invoice.status === "paid" && invoice.stripePaymentIntentId) {
|
||||||
|
setStripeLoading(true);
|
||||||
|
fetch(`/api/invoices/${invoice.id}/stripe-payment`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: StripePaymentInfo) => setStripeInfo(data))
|
||||||
|
.catch(() => { /* non-blocking */ })
|
||||||
|
.finally(() => setStripeLoading(false));
|
||||||
|
}
|
||||||
|
}, [invoice.id, invoice.status, invoice.stripePaymentIntentId]);
|
||||||
|
|
||||||
// Tip split state: array of {staffId, staffName, pct}
|
// Tip split state: array of {staffId, staffName, pct}
|
||||||
const linkedAppt = invoice.appointmentId
|
const linkedAppt = invoice.appointmentId
|
||||||
@@ -271,6 +288,31 @@ function InvoiceDetailModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitRefund() {
|
||||||
|
setRefunding(true);
|
||||||
|
setError(null);
|
||||||
|
const amountCents = refundType === "partial"
|
||||||
|
? Math.round(parseFloat(refundAmountStr) * 100)
|
||||||
|
: undefined;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ amountCents }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (await res.json()) as { error?: string };
|
||||||
|
throw new Error(err.error ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
setShowRefundDialog(false);
|
||||||
|
onUpdated();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setError(e instanceof Error ? e.message : "Refund failed");
|
||||||
|
} finally {
|
||||||
|
setRefunding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading…</p></Modal>;
|
if (loading) return <Modal onClose={onClose}><p style={{ padding: "1rem" }}>Loading…</p></Modal>;
|
||||||
|
|
||||||
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
const tipCentsCalc = Math.round(parseFloat(tipStr) * 100) || 0;
|
||||||
@@ -330,6 +372,18 @@ function InvoiceDetailModal({
|
|||||||
/>
|
/>
|
||||||
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
{invoice.paidAt && <SummaryRow label="Paid on" value={fmtDate(invoice.paidAt)} />}
|
||||||
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
{invoice.paymentMethod && <SummaryRow label="Payment" value={invoice.paymentMethod} />}
|
||||||
|
{stripeLoading && <SummaryRow label="Stripe" value="Loading…" />}
|
||||||
|
{stripeInfo && (
|
||||||
|
<>
|
||||||
|
{stripeInfo.cardLast4 && (
|
||||||
|
<SummaryRow label="Card" value={`${stripeInfo.cardBrand ?? "Card"} •••• ${stripeInfo.cardLast4}`} />
|
||||||
|
)}
|
||||||
|
<SummaryRow label="Stripe status" value={stripeInfo.status} />
|
||||||
|
{invoice.stripeRefundId && stripeInfo.refundStatus && (
|
||||||
|
<SummaryRow label="Refund status" value={stripeInfo.refundStatus === "succeeded" ? "Refunded" : stripeInfo.refundStatus} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tip Distribution ── */}
|
{/* ── Tip Distribution ── */}
|
||||||
@@ -447,10 +501,101 @@ function InvoiceDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(invoice.status === "paid" || invoice.status === "void") && (
|
{(invoice.status === "paid" || invoice.status === "void") && (
|
||||||
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end" }}>
|
<div style={{ marginTop: "1rem", display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
|
||||||
|
{invoice.status === "paid" && invoice.stripePaymentIntentId && !invoice.stripeRefundId && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setRefundType("full");
|
||||||
|
setRefundAmountStr("");
|
||||||
|
setShowRefundDialog(true);
|
||||||
|
}}
|
||||||
|
style={{ ...btnStyle, color: "#dc2626", borderColor: "#dc2626" }}
|
||||||
|
>
|
||||||
|
Refund
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button onClick={onClose} style={btnStyle}>Close</button>
|
<button onClick={onClose} style={btnStyle}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showRefundDialog && (
|
||||||
|
<div style={{
|
||||||
|
position: "fixed", inset: 0, background: "rgba(0,0,0,0.45)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", zIndex: 110,
|
||||||
|
}}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) setShowRefundDialog(false); }}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: "#fff", borderRadius: 8, padding: "1.5rem",
|
||||||
|
maxWidth: 400, width: "calc(100% - 2rem)",
|
||||||
|
boxShadow: "0 20px 60px rgba(0,0,0,0.3)",
|
||||||
|
}}>
|
||||||
|
<h3 style={{ margin: "0 0 1rem" }}>Process Refund</h3>
|
||||||
|
<p style={{ fontSize: 14, color: "#6b7280", marginBottom: "1rem" }}>
|
||||||
|
Invoice total: {fmtMoney(invoice.totalCents)}
|
||||||
|
</p>
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13 }}>
|
||||||
|
Refund type
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setRefundType("full")}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
backgroundColor: refundType === "full" ? "var(--color-primary)" : "#fff",
|
||||||
|
color: refundType === "full" ? "#fff" : "#374151",
|
||||||
|
borderColor: refundType === "full" ? "var(--color-primary)" : "#d1d5db",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Full refund
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setRefundType("partial"); setRefundAmountStr((invoice.totalCents / 100).toFixed(2)); }}
|
||||||
|
style={{
|
||||||
|
...btnStyle,
|
||||||
|
backgroundColor: refundType === "partial" ? "var(--color-primary)" : "#fff",
|
||||||
|
color: refundType === "partial" ? "#fff" : "#374151",
|
||||||
|
borderColor: refundType === "partial" ? "var(--color-primary)" : "#d1d5db",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Partial refund
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{refundType === "partial" && (
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: "0.25rem", fontSize: 13 }}>
|
||||||
|
Refund amount
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
||||||
|
<span style={{ color: "#6b7280" }}>$</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
max={(invoice.totalCents / 100).toFixed(2)}
|
||||||
|
step="0.01"
|
||||||
|
value={refundAmountStr}
|
||||||
|
onChange={(e) => setRefundAmountStr(e.target.value)}
|
||||||
|
style={{ ...inputStyle, width: 100 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p style={{ color: "red", margin: "0.5rem 0" }}>{error}</p>}
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||||
|
<button onClick={() => setShowRefundDialog(false)} style={btnStyle}>Cancel</button>
|
||||||
|
<button
|
||||||
|
onClick={submitRefund}
|
||||||
|
disabled={refunding || (refundType === "partial" && (!refundAmountStr || parseFloat(refundAmountStr) <= 0))}
|
||||||
|
style={{ ...btnStyle, backgroundColor: "#dc2626", color: "#fff", borderColor: "#dc2626" }}
|
||||||
|
>
|
||||||
|
{refunding ? "Refunding…" : "Refund"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -492,6 +637,8 @@ export function InvoicesPage() {
|
|||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
const [detailData, setDetailData] = useState<{ staff: Staff[]; appointments: Appointment[] } | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [stats, setStats] = useState<PaymentStats | null>(null);
|
||||||
|
const [statsLoading, setStatsLoading] = useState(true);
|
||||||
|
|
||||||
const LIMIT = 50;
|
const LIMIT = 50;
|
||||||
|
|
||||||
@@ -513,6 +660,15 @@ export function InvoicesPage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [statusFilter]);
|
}, [statusFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStatsLoading(true);
|
||||||
|
fetch("/api/invoices/stats")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: PaymentStats) => setStats(data))
|
||||||
|
.catch(() => { /* non-blocking */ })
|
||||||
|
.finally(() => setStatsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
function loadCreateData() {
|
function loadCreateData() {
|
||||||
if (createData) return Promise.resolve();
|
if (createData) return Promise.resolve();
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
@@ -573,6 +729,36 @@ export function InvoicesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!statsLoading && stats && (
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: "0.75rem", marginBottom: "1rem" }}>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Revenue this month</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#065f46" }}>{fmtMoney(stats.revenueCents)}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>{stats.revenueCount} paid</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Outstanding</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#92400e" }}>{fmtMoney(stats.outstandingCents)}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>Refunds this month</div>
|
||||||
|
<div style={{ fontSize: 20, fontWeight: 700, color: "#991b1b" }}>{fmtMoney(stats.refundsCents)}</div>
|
||||||
|
<div style={{ fontSize: 12, color: "#9ca3af" }}>{stats.refundCount} refunds</div>
|
||||||
|
</div>
|
||||||
|
{stats.paymentMethodBreakdown.length > 0 && (
|
||||||
|
<div style={{ background: "#fff", borderRadius: 8, border: "1px solid #e5e7eb", padding: "0.875rem 1rem" }}>
|
||||||
|
<div style={{ fontSize: 12, color: "#6b7280", fontWeight: 500, marginBottom: "0.25rem" }}>By payment method</div>
|
||||||
|
{stats.paymentMethodBreakdown.map((b) => (
|
||||||
|
<div key={b.paymentMethod} style={{ fontSize: 13, display: "flex", justifyContent: "space-between", marginTop: "0.2rem" }}>
|
||||||
|
<span style={{ textTransform: "capitalize" }}>{b.paymentMethod}</span>
|
||||||
|
<span style={{ fontWeight: 600 }}>{fmtMoney(b.totalCents)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{invoiceList.length === 0 ? (
|
{invoiceList.length === 0 ? (
|
||||||
<p style={{ color: "#6b7280" }}>
|
<p style={{ color: "#6b7280" }}>
|
||||||
No invoices yet. Create one from a completed appointment.
|
No invoices yet. Create one from a completed appointment.
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
CREATE TABLE "refunds" (
|
|
||||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
"invoice_id" uuid NOT NULL REFERENCES "invoices"("id") ON DELETE RESTRICT,
|
|
||||||
"stripe_refund_id" text NOT NULL,
|
|
||||||
"idempotency_key" text UNIQUE,
|
|
||||||
"amount_cents" integer,
|
|
||||||
"created_at" timestamp NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX "idx_refunds_invoice_id" ON "refunds"("invoice_id");
|
|
||||||
CREATE INDEX "idx_refunds_idempotency_key" ON "refunds"("idempotency_key");
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
-- SMS opt-in fields for clients (idempotent)
|
|
||||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_in" boolean NOT NULL DEFAULT false;
|
|
||||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_date" timestamp;
|
|
||||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_opt_out_date" timestamp;
|
|
||||||
ALTER TABLE "clients" ADD COLUMN IF NOT EXISTS "sms_consent_text" text;
|
|
||||||
|
|
||||||
-- Add channel column to reminder_logs with default 'email' (idempotent)
|
|
||||||
ALTER TABLE "reminder_logs" ADD COLUMN IF NOT EXISTS "channel" text NOT NULL DEFAULT 'email';
|
|
||||||
|
|
||||||
-- Drop old unique constraints if they exist (idempotent)
|
|
||||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_key";
|
|
||||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT IF EXISTS "reminder_logs_appointment_id_reminder_type_unique";
|
|
||||||
|
|
||||||
-- Add new unique constraint with channel
|
|
||||||
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE ("appointment_id", "reminder_type", "channel");
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
-- Migration: 0029_db_indexes_constraints.sql
|
|
||||||
-- Add missing indexes on appointments, pets, clients tables and NOT NULL constraint on clients.email
|
|
||||||
|
|
||||||
-- Backfill NULL emails before setting NOT NULL
|
|
||||||
UPDATE clients SET email = concat('unknown-', id::text, '@placeholder.local') WHERE email IS NULL;
|
|
||||||
|
|
||||||
-- Add indexes on appointments table
|
|
||||||
CREATE INDEX idx_appointments_client_id ON appointments(client_id);
|
|
||||||
CREATE INDEX idx_appointments_staff_id ON appointments(staff_id);
|
|
||||||
CREATE INDEX idx_appointments_start_time ON appointments(start_time);
|
|
||||||
CREATE INDEX idx_appointments_status ON appointments(status);
|
|
||||||
|
|
||||||
-- Add index on pets table
|
|
||||||
CREATE INDEX idx_pets_client_id ON pets(client_id);
|
|
||||||
|
|
||||||
-- Add index on clients table
|
|
||||||
CREATE INDEX idx_clients_email ON clients(email);
|
|
||||||
|
|
||||||
-- Set NOT NULL on clients.email (after backfill)
|
|
||||||
ALTER TABLE clients ALTER COLUMN email SET NOT NULL;
|
|
||||||
@@ -190,20 +190,6 @@
|
|||||||
"when": 1775568867192,
|
"when": 1775568867192,
|
||||||
"tag": "0026_stripe_payment",
|
"tag": "0026_stripe_payment",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 27,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775655267192,
|
|
||||||
"tag": "0027_refunds",
|
|
||||||
"breakpoints": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 28,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1775741667192,
|
|
||||||
"tag": "0028_sms_reminders",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -71,10 +71,6 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
|||||||
address: "1 Main St, Springfield, CA 90000",
|
address: "1 Main St, Springfield, CA 90000",
|
||||||
notes: null,
|
notes: null,
|
||||||
emailOptOut: false,
|
emailOptOut: false,
|
||||||
smsOptIn: false,
|
|
||||||
smsConsentDate: null,
|
|
||||||
smsOptOutDate: null,
|
|
||||||
smsConsentText: null,
|
|
||||||
stripeCustomerId: null,
|
stripeCustomerId: null,
|
||||||
status: "active",
|
status: "active",
|
||||||
disabledAt: null,
|
disabledAt: null,
|
||||||
|
|||||||
@@ -102,32 +102,22 @@ export const verification = pgTable("verification", {
|
|||||||
|
|
||||||
// ─── Tables ───────────────────────────────────────────────────────────────────
|
// ─── Tables ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const clients = pgTable(
|
export const clients = pgTable("clients", {
|
||||||
"clients",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
email: text("email").notNull(),
|
email: text("email"),
|
||||||
phone: text("phone"),
|
phone: text("phone"),
|
||||||
address: text("address"),
|
address: text("address"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
|
||||||
smsConsentDate: timestamp("sms_consent_date"),
|
|
||||||
smsOptOutDate: timestamp("sms_opt_out_date"),
|
|
||||||
smsConsentText: text("sms_consent_text"),
|
|
||||||
stripeCustomerId: text("stripe_customer_id"),
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
status: clientStatusEnum("status").notNull().default("active"),
|
status: clientStatusEnum("status").notNull().default("active"),
|
||||||
disabledAt: timestamp("disabled_at"),
|
disabledAt: timestamp("disabled_at"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
},
|
});
|
||||||
(t) => [index("idx_clients_email").on(t.email)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const pets = pgTable(
|
export const pets = pgTable("pets", {
|
||||||
"pets",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
clientId: uuid("client_id")
|
clientId: uuid("client_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -148,9 +138,7 @@ export const pets = pgTable(
|
|||||||
image: text("image"),
|
image: text("image"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
},
|
});
|
||||||
(t) => [index("idx_pets_client_id").on(t.clientId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const services = pgTable("services", {
|
export const services = pgTable("services", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
@@ -312,28 +300,8 @@ export const invoiceTipSplits = pgTable(
|
|||||||
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
|
(t) => [index("idx_invoice_tip_splits_invoice_id").on(t.invoiceId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Refund records with idempotency key support
|
|
||||||
export const refunds = pgTable(
|
|
||||||
"refunds",
|
|
||||||
{
|
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
|
||||||
invoiceId: uuid("invoice_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => invoices.id, { onDelete: "restrict" }),
|
|
||||||
stripeRefundId: text("stripe_refund_id").notNull(),
|
|
||||||
idempotencyKey: text("idempotency_key").unique(),
|
|
||||||
amountCents: integer("amount_cents"),
|
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_refunds_invoice_id").on(t.invoiceId),
|
|
||||||
index("idx_refunds_idempotency_key").on(t.idempotencyKey),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
// Tracks which reminder emails have been sent per appointment (prevents duplicates).
|
||||||
// reminder_type values: "confirmation", "24h", "2h"
|
// reminder_type values: "confirmation", "24h", "2h"
|
||||||
// channel values: "email", "sms"
|
|
||||||
export const reminderLogs = pgTable(
|
export const reminderLogs = pgTable(
|
||||||
"reminder_logs",
|
"reminder_logs",
|
||||||
{
|
{
|
||||||
@@ -343,11 +311,9 @@ export const reminderLogs = pgTable(
|
|||||||
.references(() => appointments.id, { onDelete: "cascade" }),
|
.references(() => appointments.id, { onDelete: "cascade" }),
|
||||||
// "confirmation" | "24h" | "2h"
|
// "confirmation" | "24h" | "2h"
|
||||||
reminderType: text("reminder_type").notNull(),
|
reminderType: text("reminder_type").notNull(),
|
||||||
// "email" | "sms"
|
|
||||||
channel: text("channel").notNull().default("email"),
|
|
||||||
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
|
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -398,8 +398,6 @@ async function seedKnownUsers() {
|
|||||||
id: ADMIN_STAFF_ID,
|
id: ADMIN_STAFF_ID,
|
||||||
name: adminName,
|
name: adminName,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
oidcSub: adminEmail,
|
|
||||||
userId: adminEmail,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -426,7 +424,6 @@ async function seedKnownUsers() {
|
|||||||
name: "UAT Super User",
|
name: "UAT Super User",
|
||||||
email: "uat-super@groombook.dev",
|
email: "uat-super@groombook.dev",
|
||||||
oidcSub: uatSuperOidcSub,
|
oidcSub: uatSuperOidcSub,
|
||||||
userId: uatSuperOidcSub,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -453,7 +450,6 @@ async function seedKnownUsers() {
|
|||||||
name: "UAT Staff Groomer",
|
name: "UAT Staff Groomer",
|
||||||
email: "uat-groomer@groombook.dev",
|
email: "uat-groomer@groombook.dev",
|
||||||
oidcSub: uatStaffOidcSub,
|
oidcSub: uatStaffOidcSub,
|
||||||
userId: uatStaffOidcSub,
|
|
||||||
role: "groomer",
|
role: "groomer",
|
||||||
isSuperUser: false,
|
isSuperUser: false,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -462,37 +458,6 @@ async function seedKnownUsers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Staff: UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
|
||||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
|
||||||
for (let i = 0; i < groomerCount; i++) {
|
|
||||||
const email = groomerEmails[i]!;
|
|
||||||
const name = groomerNames[i]!;
|
|
||||||
// Use deterministic IDs in the 00000000-0000-0000-0000-000000000005+ range
|
|
||||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
|
||||||
const [existingGroomer] = await db
|
|
||||||
.select()
|
|
||||||
.from(schema.staff)
|
|
||||||
.where(eq(schema.staff.email, email))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existingGroomer) {
|
|
||||||
console.log(`✓ Staff groomer '${existingGroomer.name}' already exists — skipping`);
|
|
||||||
} else {
|
|
||||||
await db.insert(schema.staff).values({
|
|
||||||
id: staffId,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
oidcSub: email,
|
|
||||||
role: "groomer",
|
|
||||||
isSuperUser: false,
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
console.log(`✓ Created staff groomer '${name}' (${email})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
// ── Services: idempotent upsert using name as unique key ─────────────────────
|
||||||
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
// UNIQUE constraint on services.name (migration 0020) must exist first.
|
||||||
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
// Uses b0000001-... IDs to match main seed servicesDef for same-named services.
|
||||||
@@ -647,8 +612,6 @@ async function seed() {
|
|||||||
id: ADMIN_STAFF_ID,
|
id: ADMIN_STAFF_ID,
|
||||||
name: adminName,
|
name: adminName,
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
oidcSub: adminEmail,
|
|
||||||
userId: adminEmail,
|
|
||||||
role: "manager",
|
role: "manager",
|
||||||
isSuperUser: true,
|
isSuperUser: true,
|
||||||
active: true,
|
active: true,
|
||||||
@@ -660,31 +623,6 @@ async function seed() {
|
|||||||
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
console.log(`✓ Upserted admin staff '${adminName}' (${adminEmail})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── UAT Groomer Personas (SEED_UAT_GROOMER_EMAILS + SEED_UAT_GROOMER_NAMES) ──
|
|
||||||
const groomerEmails = process.env.SEED_UAT_GROOMER_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerNames = process.env.SEED_UAT_GROOMER_NAMES?.split(",").map((n) => n.trim()).filter(Boolean) ?? [];
|
|
||||||
const groomerCount = Math.min(groomerEmails.length, groomerNames.length);
|
|
||||||
for (let i = 0; i < groomerCount; i++) {
|
|
||||||
const email = groomerEmails[i]!;
|
|
||||||
const name = groomerNames[i]!;
|
|
||||||
const staffId = `00000000-0000-0000-0000-${String(5 + i).padStart(12, "0")}`;
|
|
||||||
await db.insert(schema.staff)
|
|
||||||
.values({
|
|
||||||
id: staffId,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
oidcSub: email,
|
|
||||||
role: "groomer",
|
|
||||||
isSuperUser: false,
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: schema.staff.email,
|
|
||||||
set: { id: staffId, name, role: "groomer", isSuperUser: false, active: true },
|
|
||||||
});
|
|
||||||
console.log(`✓ Upserted groomer '${name}' (${email})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Services ──
|
// ── Services ──
|
||||||
// Upsert services using name as unique key. With deterministic IDs in
|
// Upsert services using name as unique key. With deterministic IDs in
|
||||||
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
// servicesDef and TRUNCATE clearing downstream tables first, this is
|
||||||
|
|||||||
@@ -153,10 +153,38 @@ export interface Invoice {
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
stripePaymentIntentId?: string | null;
|
||||||
|
stripeRefundId?: string | null;
|
||||||
|
paymentFailureReason?: string | null;
|
||||||
lineItems?: InvoiceLineItem[];
|
lineItems?: InvoiceLineItem[];
|
||||||
tipSplits?: InvoiceTipSplit[];
|
tipSplits?: InvoiceTipSplit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StripePaymentInfo {
|
||||||
|
paymentIntentId: string;
|
||||||
|
amountPaidCents: number;
|
||||||
|
status: string;
|
||||||
|
cardLast4: string | null;
|
||||||
|
cardBrand: string | null;
|
||||||
|
refundId: string | null;
|
||||||
|
refundStatus: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentMethodBreakdown {
|
||||||
|
paymentMethod: PaymentMethod;
|
||||||
|
count: number;
|
||||||
|
totalCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentStats {
|
||||||
|
revenueCents: number;
|
||||||
|
outstandingCents: number;
|
||||||
|
refundsCents: number;
|
||||||
|
revenueCount: number;
|
||||||
|
refundCount: number;
|
||||||
|
paymentMethodBreakdown: PaymentMethodBreakdown[];
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
|
export type ImpersonationSessionStatus = "active" | "ended" | "expired";
|
||||||
|
|||||||
Generated
+53
-43
@@ -43,9 +43,6 @@ importers:
|
|||||||
stripe:
|
stripe:
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
version: 22.0.1(@types/node@22.19.15)
|
version: 22.0.1(@types/node@22.19.15)
|
||||||
telnyx:
|
|
||||||
specifier: ^1.23.0
|
|
||||||
version: 1.27.0
|
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.3.6
|
version: 4.3.6
|
||||||
@@ -180,7 +177,7 @@ importers:
|
|||||||
version: 22.19.15
|
version: 22.19.15
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.30.4
|
specifier: ^0.30.4
|
||||||
version: 0.30.4
|
version: 0.30.6
|
||||||
tsx:
|
tsx:
|
||||||
specifier: ^4.19.0
|
specifier: ^4.19.0
|
||||||
version: 4.21.0
|
version: 4.21.0
|
||||||
@@ -1699,6 +1696,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
|
'@petamoriken/float16@3.9.3':
|
||||||
|
resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -2830,8 +2830,8 @@ packages:
|
|||||||
dom-accessibility-api@0.6.3:
|
dom-accessibility-api@0.6.3:
|
||||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||||
|
|
||||||
drizzle-kit@0.30.4:
|
drizzle-kit@0.30.6:
|
||||||
resolution: {integrity: sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==}
|
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
drizzle-orm@0.38.4:
|
drizzle-orm@0.38.4:
|
||||||
@@ -2955,6 +2955,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
|
||||||
engines: {node: '>=0.12'}
|
engines: {node: '>=0.12'}
|
||||||
|
|
||||||
|
env-paths@3.0.0:
|
||||||
|
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||||
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
es-abstract@1.24.1:
|
es-abstract@1.24.1:
|
||||||
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3158,6 +3162,11 @@ packages:
|
|||||||
functions-have-names@1.2.3:
|
functions-have-names@1.2.3:
|
||||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||||
|
|
||||||
|
gel@2.2.0:
|
||||||
|
resolution: {integrity: sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ==}
|
||||||
|
engines: {node: '>= 18.0.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
generator-function@2.0.1:
|
generator-function@2.0.1:
|
||||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -3425,6 +3434,10 @@ packages:
|
|||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
|
isexe@3.1.5:
|
||||||
|
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
istanbul-lib-coverage@3.2.2:
|
istanbul-lib-coverage@3.2.2:
|
||||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3606,9 +3619,6 @@ packages:
|
|||||||
lodash.debounce@4.0.8:
|
lodash.debounce@4.0.8:
|
||||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6:
|
|
||||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
|
||||||
|
|
||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
@@ -3841,10 +3851,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
qs@6.15.1:
|
|
||||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
|
||||||
engines: {node: '>=0.6'}
|
|
||||||
|
|
||||||
randombytes@2.1.0:
|
randombytes@2.1.0:
|
||||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||||
|
|
||||||
@@ -4040,6 +4046,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
shell-quote@1.8.3:
|
||||||
|
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
side-channel-list@1.0.0:
|
side-channel-list@1.0.0:
|
||||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4178,10 +4188,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
telnyx@1.27.0:
|
|
||||||
resolution: {integrity: sha512-cVbP3jEW4TbmNL5U0UbZc3OkLg+6dHRnMYByYfJnrGw5ZRn0XKb17Hx3fLMWmGgRFow7eqVP4hlCogbIB6T3+w==}
|
|
||||||
engines: {node: ^6 || >=8}
|
|
||||||
|
|
||||||
temp-dir@2.0.0:
|
temp-dir@2.0.0:
|
||||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -4256,9 +4262,6 @@ packages:
|
|||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
tweetnacl@1.0.3:
|
|
||||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -4348,10 +4351,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
uuid@9.0.1:
|
|
||||||
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
|
||||||
hasBin: true
|
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
@@ -4488,6 +4487,11 @@ packages:
|
|||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
which@4.0.0:
|
||||||
|
resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==}
|
||||||
|
engines: {node: ^16.13.0 || >=18.0.0}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -6219,6 +6223,8 @@ snapshots:
|
|||||||
|
|
||||||
'@opentelemetry/semantic-conventions@1.40.0': {}
|
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||||
|
|
||||||
|
'@petamoriken/float16@3.9.3': {}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -7414,12 +7420,13 @@ snapshots:
|
|||||||
|
|
||||||
dom-accessibility-api@0.6.3: {}
|
dom-accessibility-api@0.6.3: {}
|
||||||
|
|
||||||
drizzle-kit@0.30.4:
|
drizzle-kit@0.30.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@drizzle-team/brocli': 0.10.2
|
'@drizzle-team/brocli': 0.10.2
|
||||||
'@esbuild-kit/esm-loader': 2.6.5
|
'@esbuild-kit/esm-loader': 2.6.5
|
||||||
esbuild: 0.19.12
|
esbuild: 0.19.12
|
||||||
esbuild-register: 3.6.0(esbuild@0.19.12)
|
esbuild-register: 3.6.0(esbuild@0.19.12)
|
||||||
|
gel: 2.2.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -7456,6 +7463,8 @@ snapshots:
|
|||||||
|
|
||||||
entities@6.0.1: {}
|
entities@6.0.1: {}
|
||||||
|
|
||||||
|
env-paths@3.0.0: {}
|
||||||
|
|
||||||
es-abstract@1.24.1:
|
es-abstract@1.24.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
array-buffer-byte-length: 1.0.2
|
array-buffer-byte-length: 1.0.2
|
||||||
@@ -7817,6 +7826,17 @@ snapshots:
|
|||||||
|
|
||||||
functions-have-names@1.2.3: {}
|
functions-have-names@1.2.3: {}
|
||||||
|
|
||||||
|
gel@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
'@petamoriken/float16': 3.9.3
|
||||||
|
debug: 4.4.3
|
||||||
|
env-paths: 3.0.0
|
||||||
|
semver: 7.7.4
|
||||||
|
shell-quote: 1.8.3
|
||||||
|
which: 4.0.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
generator-function@2.0.1: {}
|
generator-function@2.0.1: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
@@ -8081,6 +8101,8 @@ snapshots:
|
|||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
|
isexe@3.1.5: {}
|
||||||
|
|
||||||
istanbul-lib-coverage@3.2.2: {}
|
istanbul-lib-coverage@3.2.2: {}
|
||||||
|
|
||||||
istanbul-lib-report@3.0.1:
|
istanbul-lib-report@3.0.1:
|
||||||
@@ -8249,8 +8271,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash.debounce@4.0.8: {}
|
lodash.debounce@4.0.8: {}
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6: {}
|
|
||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash.sortby@4.7.0: {}
|
lodash.sortby@4.7.0: {}
|
||||||
@@ -8449,10 +8469,6 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
qs@6.15.1:
|
|
||||||
dependencies:
|
|
||||||
side-channel: 1.1.0
|
|
||||||
|
|
||||||
randombytes@2.1.0:
|
randombytes@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
@@ -8687,6 +8703,8 @@ snapshots:
|
|||||||
|
|
||||||
shebang-regex@3.0.0: {}
|
shebang-regex@3.0.0: {}
|
||||||
|
|
||||||
|
shell-quote@1.8.3: {}
|
||||||
|
|
||||||
side-channel-list@1.0.0:
|
side-channel-list@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -8840,14 +8858,6 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
telnyx@1.27.0:
|
|
||||||
dependencies:
|
|
||||||
lodash.isplainobject: 4.0.6
|
|
||||||
qs: 6.15.1
|
|
||||||
safe-buffer: 5.2.1
|
|
||||||
tweetnacl: 1.0.3
|
|
||||||
uuid: 9.0.1
|
|
||||||
|
|
||||||
temp-dir@2.0.0: {}
|
temp-dir@2.0.0: {}
|
||||||
|
|
||||||
tempy@0.6.0:
|
tempy@0.6.0:
|
||||||
@@ -8918,8 +8928,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
tweetnacl@1.0.3: {}
|
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -9016,8 +9024,6 @@ snapshots:
|
|||||||
|
|
||||||
uuid@8.3.2: {}
|
uuid@8.3.2: {}
|
||||||
|
|
||||||
uuid@9.0.1: {}
|
|
||||||
|
|
||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/d3-array': 3.2.2
|
'@types/d3-array': 3.2.2
|
||||||
@@ -9195,6 +9201,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
|
|
||||||
|
which@4.0.0:
|
||||||
|
dependencies:
|
||||||
|
isexe: 3.1.5
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user