Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d73532054 | |||
| dcf5740489 | |||
| e15977ba82 | |||
| 4079ed9d26 | |||
| 1cc708e6b4 | |||
| 6b300626a0 |
@@ -14,29 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
steps:
|
||||
- name: Validate tag format
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
|
||||
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag format valid: $TAG"
|
||||
|
||||
- name: Verify image exists in GHCR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ inputs.tag }}"
|
||||
# Check that the API image exists — if API was pushed, web/migrate were too
|
||||
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
|
||||
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
|
||||
exit 1
|
||||
fi
|
||||
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
|
||||
|
||||
- name: Generate infra repo token
|
||||
id: infra-token
|
||||
uses: tibdex/github-app-token@v2
|
||||
|
||||
@@ -12,7 +12,6 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build
|
||||
FROM deps AS builder
|
||||
RUN mkdir -p /home/node/.cache/node/corepack
|
||||
COPY packages/ packages/
|
||||
COPY apps/api/ apps/api/
|
||||
RUN pnpm --filter @groombook/types build && \
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"node-cron": "^3.0.3",
|
||||
"nodemailer": "^6.9.16",
|
||||
"stripe": "^22.0.0",
|
||||
|
||||
"telnyx": "^6.41.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -142,8 +142,8 @@ describe("auth init", () => {
|
||||
...originalEnv,
|
||||
AUTH_DISABLED: "true",
|
||||
NODE_ENV: "test",
|
||||
BETTER_AUTH_SECRET: "placeholder-for-test-only",
|
||||
};
|
||||
delete process.env.BETTER_AUTH_SECRET;
|
||||
|
||||
const { initAuth, getAuth } = await reimportAuth();
|
||||
await expect(initAuth()).resolves.toBeUndefined();
|
||||
|
||||
@@ -31,11 +31,11 @@ const BASE_APPT = {
|
||||
|
||||
// ─── Shared mock DB state ─────────────────────────────────────────────────────
|
||||
|
||||
let mockAppt: (typeof BASE_APPT & { confirmationToken: string }) | null = BASE_APPT as typeof BASE_APPT & { confirmationToken: string };
|
||||
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
|
||||
let lastUpdate: Record<string, unknown> = {};
|
||||
|
||||
function resetMock() {
|
||||
mockAppt = { ...BASE_APPT, confirmationToken: "valid-token-abc123" } as typeof BASE_APPT & { confirmationToken: string };
|
||||
mockAppt = { ...BASE_APPT };
|
||||
lastUpdate = {};
|
||||
}
|
||||
|
||||
@@ -55,39 +55,19 @@ vi.mock("@groombook/db", () => {
|
||||
}),
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => {
|
||||
const setVals = vals;
|
||||
return {
|
||||
where: () => {
|
||||
const preUpdate = mockAppt ? { ...mockAppt } : null;
|
||||
const preStatus = preUpdate?.confirmationStatus;
|
||||
const preStart = preUpdate?.startTime;
|
||||
lastUpdate = { ...setVals };
|
||||
const whereMatched =
|
||||
preUpdate != null &&
|
||||
preStatus === "pending" &&
|
||||
preStart != null &&
|
||||
preStart > new Date();
|
||||
if (whereMatched && mockAppt) {
|
||||
mockAppt = { ...mockAppt, ...setVals } as typeof BASE_APPT & { confirmationToken: string };
|
||||
}
|
||||
return {
|
||||
returning: () => {
|
||||
if (!preUpdate) return [];
|
||||
if (preStatus !== "pending") return [];
|
||||
if (preStart && preStart <= new Date()) return [];
|
||||
return whereMatched && mockAppt ? [mockAppt] : [];
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
where: () => {
|
||||
lastUpdate = { ...vals };
|
||||
if (mockAppt) {
|
||||
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
|
||||
}
|
||||
return { returning: () => (mockAppt ? [mockAppt] : []) };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
appointments,
|
||||
eq: () => ({}),
|
||||
and: (a: unknown, b: unknown, c?: unknown) => (c ? [a, b, c] : [a, b]),
|
||||
gt: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -362,7 +362,7 @@ describe("requireRoleOrSuperUser", () => {
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/role 'receptionist' is not permitted/i);
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
});
|
||||
|
||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||
@@ -370,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
|
||||
const res = await app.request("/test");
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/role 'groomer' is not permitted/i);
|
||||
expect(body.error).toMatch(/super user privileges required/i);
|
||||
});
|
||||
|
||||
it("allows a manager with multiple allowed roles", async () => {
|
||||
|
||||
@@ -28,7 +28,6 @@ import { resolveStaffMiddleware, requireRole, requireRoleOrSuperUser, requireSup
|
||||
import { devRouter } from "./routes/dev.js";
|
||||
import { adminSeedRouter } from "./routes/admin/seed.js";
|
||||
import { startReminderScheduler } from "./services/reminders.js";
|
||||
import { webhooksRouter } from "./routes/stripe-webhooks.js";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -42,23 +41,6 @@ app.use(
|
||||
})
|
||||
);
|
||||
|
||||
// CSRF protection for state-changing requests
|
||||
app.use("/api/*", async (c, next) => {
|
||||
const method = c.req.method;
|
||||
if (["GET", "HEAD", "OPTIONS"].includes(method)) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const origin = c.req.header("origin");
|
||||
const trustedOrigin = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||
if (origin && origin !== trustedOrigin) {
|
||||
c.status(403);
|
||||
c.json({ error: "CSRF validation failed: origin mismatch" });
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// Health check (no auth required)
|
||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||
|
||||
@@ -68,9 +50,6 @@ app.route("/api/book", bookRouter);
|
||||
// Public portal routes — client-facing, authenticated via impersonation session header
|
||||
app.route("/api/portal", portalRouter);
|
||||
|
||||
// Public Stripe webhook endpoint — signature-verified, no auth required
|
||||
app.route("/api/webhooks/stripe", webhooksRouter);
|
||||
|
||||
// Dev/demo routes — config is always public, users endpoint is guarded internally
|
||||
app.route("/api/dev", devRouter);
|
||||
|
||||
|
||||
@@ -86,15 +86,10 @@ export async function initAuth(): Promise<void> {
|
||||
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
|
||||
// config so auth.handler exists (middleware bypasses it anyway)
|
||||
if (process.env.AUTH_DISABLED === "true") {
|
||||
if (!BETTER_AUTH_SECRET) {
|
||||
throw new Error(
|
||||
"[FATAL] BETTER_AUTH_SECRET must be set when AUTH_DISABLED=true"
|
||||
);
|
||||
}
|
||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||
authInstance = betterAuth({
|
||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||
secret: BETTER_AUTH_SECRET,
|
||||
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||
baseURL: BETTER_AUTH_URL,
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
@@ -204,36 +199,20 @@ export async function initAuth(): Promise<void> {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
const validateIssuerHost = (url: string, issuerUrl: string): boolean => {
|
||||
try {
|
||||
const discovered = new URL(url);
|
||||
const expected = new URL(issuerUrl);
|
||||
return discovered.hostname === expected.hostname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const authzUrl = discovery.authorization_endpoint;
|
||||
const tokenUrl = discovery.token_endpoint;
|
||||
const userInfoUrl = discovery.userinfo_endpoint;
|
||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||
const validAuthz = validateIssuerHost(authzUrl, providerConfig.issuerUrl);
|
||||
const validToken = validateIssuerHost(tokenUrl, providerConfig.issuerUrl);
|
||||
const validUserInfo = validateIssuerHost(userInfoUrl, providerConfig.issuerUrl);
|
||||
if (!validAuthz || !validToken || !validUserInfo) {
|
||||
console.warn("[auth] OIDC discovery URL host mismatch — possible redirection attack, rejecting");
|
||||
} else {
|
||||
oidcConfig = {
|
||||
authorizationUrl: authzUrl,
|
||||
tokenUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||
: tokenUrl,
|
||||
userInfoUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||
: userInfoUrl,
|
||||
};
|
||||
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||
}
|
||||
oidcConfig = {
|
||||
authorizationUrl: authzUrl,
|
||||
tokenUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||
: tokenUrl,
|
||||
userInfoUrl: providerConfig.internalBaseUrl
|
||||
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||
: userInfoUrl,
|
||||
};
|
||||
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||
} else {
|
||||
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
||||
}
|
||||
@@ -308,9 +287,6 @@ export async function initAuth(): Promise<void> {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes
|
||||
},
|
||||
cookieAttributes: {
|
||||
sameSite: "strict",
|
||||
},
|
||||
},
|
||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||
});
|
||||
|
||||
@@ -149,9 +149,9 @@ export function requireRoleOrSuperUser(
|
||||
}
|
||||
return c.json(
|
||||
{
|
||||
error: hasAllowedRole
|
||||
? "Forbidden: super user privileges required"
|
||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
||||
error: staffRow.isSuperUser
|
||||
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||
: "Forbidden: super user privileges required",
|
||||
},
|
||||
403
|
||||
);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Hono } from "hono";
|
||||
import { getDb, businessSettings, reminderLogs, eq, sql, and, gte, lt } from "@groombook/db";
|
||||
import { requireRole } from "../middleware/rbac.js";
|
||||
import { createSmsProvider } from "../services/sms.js";
|
||||
|
||||
export const adminSmsRouter = new Hono();
|
||||
|
||||
adminSmsRouter.get("/status", requireManager(), async (c) => {
|
||||
const db = getDb();
|
||||
|
||||
const [settings] = await db.select().from(businessSettings).limit(1);
|
||||
|
||||
const provider = createSmsProvider();
|
||||
const smsEnabled = process.env.SMS_ENABLED === "true";
|
||||
const providerName = process.env.SMS_PROVIDER ?? "none";
|
||||
const fromNumber = process.env.TELNYX_FROM_NUMBER ?? null;
|
||||
const connectionStatus = provider ? "connected" : "disconnected";
|
||||
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const statsRows = await db
|
||||
.select({
|
||||
status: reminderLogs.deliveryStatus,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.channel, "sms"),
|
||||
gte(reminderLogs.sentAt, startOfMonth)
|
||||
)
|
||||
)
|
||||
.groupBy(reminderLogs.deliveryStatus);
|
||||
|
||||
const totals = { sent: 0, delivered: 0, failed: 0 };
|
||||
for (const row of statsRows) {
|
||||
if (row.status === "delivered") totals.delivered = row.count;
|
||||
else if (row.status === "failed") totals.failed = row.count;
|
||||
else totals.sent += row.count;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
providerName,
|
||||
fromNumber,
|
||||
connectionStatus,
|
||||
smsEnabled,
|
||||
businessSmsEnabled: settings?.smsEnabled ?? false,
|
||||
stats: totals,
|
||||
});
|
||||
});
|
||||
@@ -255,37 +255,39 @@ bookRouter.get("/confirm/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
// Atomic: consume token and confirm in a single query to prevent replay.
|
||||
// Only future appointments can be confirmed.
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if appointment is in the past
|
||||
if (appt.startTime < new Date()) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Idempotent confirm: if already confirmed, redirect to success
|
||||
if (appt.confirmationStatus === "confirmed") {
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
}
|
||||
|
||||
// Reject if already cancelled
|
||||
if (appt.confirmationStatus === "cancelled") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "confirmed",
|
||||
confirmedAt: new Date(),
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending"),
|
||||
gt(appointments.startTime, new Date())
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!appt) {
|
||||
// Check status for idempotency: already-confirmed → redirect to confirmed
|
||||
const [existing] = await db
|
||||
.select({ confirmationStatus: appointments.confirmationStatus })
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
if (existing?.confirmationStatus === "confirmed") {
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
}
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
.where(eq(appointments.id, appt.id));
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||
});
|
||||
@@ -297,9 +299,29 @@ bookRouter.get("/cancel/:token", async (c) => {
|
||||
const token = c.req.param("token");
|
||||
const db = getDb();
|
||||
|
||||
// Atomic: consume token and cancel in a single query to prevent replay.
|
||||
// Only future appointments can be cancelled.
|
||||
const [appt] = await db
|
||||
.select()
|
||||
.from(appointments)
|
||||
.where(eq(appointments.confirmationToken, token))
|
||||
.limit(1);
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Reject if appointment is in the past
|
||||
if (appt.startTime < new Date()) {
|
||||
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") {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
|
||||
// Single-use cancellation: nullify token after use
|
||||
await db
|
||||
.update(appointments)
|
||||
.set({
|
||||
confirmationStatus: "cancelled",
|
||||
@@ -307,18 +329,7 @@ bookRouter.get("/cancel/:token", async (c) => {
|
||||
confirmationToken: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(appointments.confirmationToken, token),
|
||||
eq(appointments.confirmationStatus, "pending"),
|
||||
gt(appointments.startTime, new Date())
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!appt) {
|
||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||
}
|
||||
.where(eq(appointments.id, appt.id));
|
||||
|
||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
@@ -84,12 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
|
||||
.where(eq(staff.id, staffId))
|
||||
.limit(1);
|
||||
|
||||
if (
|
||||
!staffMember ||
|
||||
!staffMember.icalToken ||
|
||||
staffMember.icalToken.length !== token.length ||
|
||||
!timingSafeEqual(Buffer.from(staffMember.icalToken), Buffer.from(token))
|
||||
) {
|
||||
if (!staffMember || staffMember.icalToken !== token) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,35 @@ import { z } from "zod/v3";
|
||||
import { and, eq, exists, getDb, or, clients, appointments } from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
function normalizeE164(phone: string): string | null {
|
||||
const digits = phone.replace(/\D/g, "");
|
||||
if (digits.length === 10) return `+1${digits}`;
|
||||
if (digits.length === 11 && digits.startsWith("1")) return `+${digits}`;
|
||||
if (digits.length > 11 && digits.startsWith("1")) return `+${digits.slice(0, 11)}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function e164String() {
|
||||
return z.string().transform((v, ctx) => {
|
||||
if (!v) return v as unknown as undefined;
|
||||
const normalized = normalizeE164(v);
|
||||
if (!normalized) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid phone number. Must be a valid E.164 number (e.g. +12125551234).",
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
|
||||
export const clientsRouter = new Hono<AppEnv>();
|
||||
|
||||
const createClientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email().optional(),
|
||||
phone: z.string().max(50).optional(),
|
||||
phone: e164String().optional(),
|
||||
address: z.string().max(500).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
@@ -13,9 +13,8 @@ import {
|
||||
clients,
|
||||
sql,
|
||||
} from "@groombook/db";
|
||||
import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const invoicesRouter = new Hono<AppEnv>();
|
||||
export const invoicesRouter = new Hono();
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
appointmentId: z.string().uuid().optional(),
|
||||
|
||||
@@ -35,12 +35,6 @@ portalRouter.get("/me", async (c) => {
|
||||
return c.json({ id: client.id, name: client.name, email: client.email, phone: client.phone });
|
||||
});
|
||||
|
||||
portalRouter.get("/config", async (c) => {
|
||||
return c.json({
|
||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
portalRouter.get("/services", async (c) => {
|
||||
const db = getDb();
|
||||
const allServices = await db.select().from(services).where(eq(services.active, true));
|
||||
@@ -129,7 +123,7 @@ portalRouter.get("/invoices", async (c) => {
|
||||
id: inv.id,
|
||||
status: inv.status,
|
||||
totalCents: inv.totalCents,
|
||||
date: inv.createdAt,
|
||||
createdAt: inv.createdAt,
|
||||
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
|
||||
})));
|
||||
});
|
||||
@@ -459,12 +453,49 @@ portalRouter.delete("/waitlist/:id", async (c) => {
|
||||
import {
|
||||
createPaymentIntent,
|
||||
listPaymentMethods,
|
||||
attachPaymentMethod,
|
||||
detachPaymentMethod,
|
||||
createSetupIntent,
|
||||
getOrCreateStripeCustomer,
|
||||
getStripeClient,
|
||||
} from "../services/payment.js";
|
||||
|
||||
const payInvoiceSchema = z.object({
|
||||
invoiceId: z.string().uuid(),
|
||||
});
|
||||
|
||||
portalRouter.post(
|
||||
"/invoices/:id/pay",
|
||||
zValidator("json", payInvoiceSchema),
|
||||
async (c) => {
|
||||
const db = getDb();
|
||||
const invoiceId = c.req.param("id");
|
||||
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||
const clientId = await getClientIdFromSession(sessionId);
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const [invoice] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.limit(1);
|
||||
|
||||
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||
if (invoice.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
||||
if (invoice.status === "draft" || invoice.status === "void") {
|
||||
return c.json({ error: "Cannot pay a draft or void invoice" }, 422);
|
||||
}
|
||||
if (invoice.status === "paid") {
|
||||
return c.json({ error: "Invoice is already paid" }, 422);
|
||||
}
|
||||
|
||||
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||
const result = await createPaymentIntent(invoiceId, clientId);
|
||||
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||
}
|
||||
);
|
||||
|
||||
const payMultipleSchema = z.object({
|
||||
invoiceIds: z.array(z.string().uuid()).min(1),
|
||||
});
|
||||
@@ -499,7 +530,6 @@ portalRouter.post(
|
||||
}
|
||||
|
||||
const firstInvoice = invoiceRows[0];
|
||||
if (!firstInvoice) return c.json({ error: "No invoices found" }, 400);
|
||||
const allSameClient = invoiceRows.every(inv => inv.clientId === firstInvoice.clientId);
|
||||
if (!allSameClient) {
|
||||
return c.json({ error: "All invoices must belong to the same client" }, 422);
|
||||
@@ -544,23 +574,19 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
|
||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||
|
||||
const paymentMethodId = c.req.param("id");
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
|
||||
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
|
||||
|
||||
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
|
||||
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
|
||||
return c.json({ error: "Payment method not found" }, 404);
|
||||
}
|
||||
|
||||
const ok = await detachPaymentMethod(paymentMethodId);
|
||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Config endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
portalRouter.get("/config", (c) => {
|
||||
return c.json({
|
||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
||||
// Allows the dev login selector to vend an impersonation session for a client
|
||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
||||
|
||||
@@ -6,25 +6,6 @@ import type { AppEnv } from "../middleware/rbac.js";
|
||||
|
||||
export const setupRouter = new Hono<AppEnv>();
|
||||
|
||||
// Simple in-memory rate limiter: 10 req/min per IP for setup endpoints
|
||||
const setupRateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||
const SETUP_RATE_LIMIT = 10;
|
||||
const SETUP_RATE_WINDOW_MS = 60 * 1000;
|
||||
|
||||
function checkSetupRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = setupRateLimitMap.get(ip);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
setupRateLimitMap.set(ip, { count: 1, resetAt: now + SETUP_RATE_WINDOW_MS });
|
||||
return true;
|
||||
}
|
||||
if (entry.count >= SETUP_RATE_LIMIT) {
|
||||
return false;
|
||||
}
|
||||
entry.count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||
// and whether the auth provider bootstrap step should be shown
|
||||
setupRouter.get("/status", async (c) => {
|
||||
@@ -204,11 +185,6 @@ const authProviderTestSchema = z.object({
|
||||
* After setup completes, this endpoint permanently returns 403.
|
||||
*/
|
||||
setupRouter.post("/auth-provider", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
if (!checkSetupRateLimit(ip)) {
|
||||
return c.json({ error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
@@ -278,11 +254,6 @@ setupRouter.post("/auth-provider", async (c) => {
|
||||
* Only available when needsSetup is true (no super user = fresh install).
|
||||
*/
|
||||
setupRouter.post("/auth-provider/test", async (c) => {
|
||||
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
if (!checkSetupRateLimit(ip)) {
|
||||
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Guard: only allow during fresh install (no super user yet)
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { Hono } from "hono";
|
||||
import Stripe from "stripe";
|
||||
import { eq, getDb, invoices } from "@groombook/db";
|
||||
import { getStripeClient } from "../services/payment.js";
|
||||
|
||||
export const webhooksRouter = new Hono();
|
||||
|
||||
webhooksRouter.post("/stripe", async (c) => {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
||||
}
|
||||
|
||||
const signature = c.req.header("stripe-signature");
|
||||
if (!signature) {
|
||||
return c.json({ error: "Missing signature" }, 401);
|
||||
}
|
||||
|
||||
let rawBody: string;
|
||||
try {
|
||||
rawBody = await c.req.text();
|
||||
} catch {
|
||||
return c.json({ error: "Could not read body" }, 400);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) {
|
||||
return c.json({ error: "Stripe not configured" }, 503);
|
||||
}
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||
return c.json({ error: message }, 401);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
if (event.type === "payment_intent.succeeded") {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
if (pi.metadata?.groombook_invoice_ids) {
|
||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||
for (const invoiceId of invoiceIds) {
|
||||
if (!invoiceId) continue;
|
||||
const [inv] = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, invoiceId))
|
||||
.limit(1);
|
||||
if (!inv) continue;
|
||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: "paid",
|
||||
paymentMethod: "card",
|
||||
paidAt: new Date(),
|
||||
stripePaymentIntentId: pi.id,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "payment_intent.payment_failed") {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
if (pi.metadata?.groombook_invoice_ids) {
|
||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||
for (const invoiceId of invoiceIds) {
|
||||
if (!invoiceId) continue;
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, invoiceId));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.refunded") {
|
||||
const charge = event.data.object as Stripe.Charge;
|
||||
if (typeof charge.payment_intent === "string" && charge.payment_intent) {
|
||||
const [inv] = await db
|
||||
.select({ id: invoices.id })
|
||||
.from(invoices)
|
||||
.where(eq(invoices.stripePaymentIntentId, charge.payment_intent))
|
||||
.limit(1);
|
||||
if (inv) {
|
||||
const refundId =
|
||||
typeof charge.refunded === "boolean" && charge.refunded
|
||||
? `ch_${charge.id}_refund`
|
||||
: null;
|
||||
await db
|
||||
.update(invoices)
|
||||
.set({
|
||||
status: "void",
|
||||
stripeRefundId: refundId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(invoices.id, inv.id));
|
||||
}
|
||||
}
|
||||
} else if (event.type === "charge.dispute.created") {
|
||||
const dispute = event.data.object as Stripe.Dispute;
|
||||
console.error(
|
||||
`[Stripe Webhook] Dispute created for payment intent: ${dispute.payment_intent}`
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ received: true });
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
getDb,
|
||||
clients,
|
||||
reminderLogs,
|
||||
smsSend,
|
||||
} from "@groombook/db";
|
||||
import { TelnyxProvider } from "../services/sms.js";
|
||||
|
||||
export const webhooksRouter = new Hono();
|
||||
|
||||
const telnyxProvider = new TelnyxProvider();
|
||||
|
||||
const STOP_KEYWORDS = new Set(["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"]);
|
||||
const START_KEYWORDS = new Set(["START", "YES", "UNSTOP"]);
|
||||
|
||||
webhooksRouter.post("/sms/inbound", async (c) => {
|
||||
if (!telnyxProvider.validateWebhookSignature(c.req.raw)) {
|
||||
return c.json({ error: "Invalid signature" }, 401);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.json({ error: "Invalid JSON" }, 400);
|
||||
}
|
||||
|
||||
const event = (body.data as Record<string, unknown>)?.event_type ?? body.event_type;
|
||||
const payload = (body.data as Record<string, unknown>) ?? body;
|
||||
|
||||
if (event === "message.received") {
|
||||
const fromField = payload.from;
|
||||
const from = typeof fromField === "object" && fromField !== null
|
||||
? (fromField as Record<string, unknown>).phone_number as string ?? (fromField as Record<string, unknown>).toString()
|
||||
: String(fromField ?? "");
|
||||
const text = String(payload.text ?? payload.body ?? "").trim().toUpperCase();
|
||||
|
||||
if (!from || !text) {
|
||||
return c.json({ error: "Missing from or text" }, 400);
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const [client] = await db
|
||||
.select({ id: clients.id, smsOptIn: clients.smsOptIn })
|
||||
.from(clients)
|
||||
.where(eq(clients.phone, from))
|
||||
.limit(1);
|
||||
|
||||
if (!client) {
|
||||
return c.json({ received: true });
|
||||
}
|
||||
|
||||
if (STOP_KEYWORDS.has(text)) {
|
||||
await db
|
||||
.update(clients)
|
||||
.set({
|
||||
smsOptIn: false,
|
||||
smsOptOutDate: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(clients.id, client.id));
|
||||
return c.json({ received: true });
|
||||
}
|
||||
|
||||
if (START_KEYWORDS.has(text)) {
|
||||
await db
|
||||
.update(clients)
|
||||
.set({
|
||||
smsOptIn: true,
|
||||
smsConsentDate: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(clients.id, client.id));
|
||||
return c.json({ received: true });
|
||||
}
|
||||
|
||||
if (text === "HELP") {
|
||||
const supportUrl = process.env.SUPPORT_URL ?? "https://groombook.app/support";
|
||||
await smsSend(from, `GroomBook appointment reminders. Reply STOP to opt out. For help, visit ${supportUrl}.`);
|
||||
return c.json({ received: true });
|
||||
}
|
||||
|
||||
return c.json({ received: true });
|
||||
}
|
||||
|
||||
if (event === "message.finalized" || event === "message.status") {
|
||||
const status = String(payload.status ?? "");
|
||||
const toField = payload.to;
|
||||
const toNumber = typeof toField === "object" && toField !== null
|
||||
? (toField as Record<string, unknown>).phone_number as string ?? (toField as Record<string, unknown>).toString()
|
||||
: String(toField ?? "");
|
||||
|
||||
if (!status || !toNumber) {
|
||||
return c.json({ received: true });
|
||||
}
|
||||
|
||||
const validDelivery = ["delivered", "sent", "failed", "sending", "queued"];
|
||||
if (!validDelivery.includes(status)) {
|
||||
return c.json({ received: true });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const [client] = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.phone, toNumber))
|
||||
.limit(1);
|
||||
|
||||
if (client) {
|
||||
const [log] = await db
|
||||
.select({ id: reminderLogs.id })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.channel, "sms")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (log) {
|
||||
await db
|
||||
.update(reminderLogs)
|
||||
.set({ deliveryStatus: status })
|
||||
.where(eq(reminderLogs.id, log.id));
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ received: true });
|
||||
}
|
||||
|
||||
return c.json({ received: true });
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import Stripe from "stripe";
|
||||
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
|
||||
import { getDb, clients, eq, invoices } from "@groombook/db";
|
||||
|
||||
let _stripe: Stripe | null | undefined;
|
||||
|
||||
export function getStripeClient(): Stripe | null {
|
||||
function getStripeClient(): Stripe | null {
|
||||
if (_stripe === undefined) {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) return null;
|
||||
@@ -43,13 +43,11 @@ export async function createPaymentIntent(
|
||||
|
||||
const db = getDb();
|
||||
const invoiceIds = Array.isArray(invoiceIdOrIds) ? invoiceIdOrIds : [invoiceIdOrIds];
|
||||
const firstInvoiceId = invoiceIds[0];
|
||||
if (!firstInvoiceId) return null;
|
||||
|
||||
const invoiceRows = await db
|
||||
.select()
|
||||
.from(invoices)
|
||||
.where(eq(invoices.id, firstInvoiceId));
|
||||
.where(eq(invoices.id, invoiceIds[0]));
|
||||
|
||||
const [invoice] = invoiceRows;
|
||||
if (!invoice) return null;
|
||||
@@ -59,8 +57,8 @@ export async function createPaymentIntent(
|
||||
const allInvoices = await db
|
||||
.select({ totalCents: invoices.totalCents })
|
||||
.from(invoices)
|
||||
.where(inArray(invoices.id, invoiceIds));
|
||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
||||
.where(eq(invoices.id, invoiceIds[0]));
|
||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
|
||||
}
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
@@ -84,10 +82,10 @@ export async function createPaymentIntent(
|
||||
.where(eq(invoices.id, invId));
|
||||
}
|
||||
|
||||
const clientSecret = paymentIntent.client_secret;
|
||||
if (!clientSecret) return null;
|
||||
|
||||
return { clientSecret, paymentIntentId: paymentIntent.id };
|
||||
return {
|
||||
clientSecret: paymentIntent.client_secret!,
|
||||
paymentIntentId: paymentIntent.id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function processRefund(
|
||||
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
buildReminderEmail,
|
||||
sendEmail,
|
||||
} from "./email.js";
|
||||
import { smsSend } from "./sms.js";
|
||||
|
||||
// How many hours before the appointment to send each reminder.
|
||||
// Override via env: REMINDER_HOURS_EARLY (default 24) and REMINDER_HOURS_LATE (default 2).
|
||||
// TCPA-required opt-out text appended to every SMS reminder
|
||||
const TCPA_OPT_OUT = "Reply STOP to opt out. Msg & data rates may apply.";
|
||||
function getReminderWindows(): { label: string; hours: number }[] {
|
||||
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
|
||||
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
|
||||
@@ -65,23 +66,39 @@ export async function runReminderCheck(): Promise<void> {
|
||||
);
|
||||
|
||||
for (const appt of upcoming) {
|
||||
// Check if reminder already sent (unique constraint prevents double-send)
|
||||
const existing = await db
|
||||
const [emailLog] = await db
|
||||
.select({ id: reminderLogs.id })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.appointmentId, appt.id),
|
||||
eq(reminderLogs.reminderType, window.label)
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
eq(reminderLogs.channel, "email")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) continue; // already sent
|
||||
const [smsLog] = await db
|
||||
.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
|
||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||
.select({
|
||||
name: clients.name,
|
||||
email: clients.email,
|
||||
emailOptOut: clients.emailOptOut,
|
||||
smsOptIn: clients.smsOptIn,
|
||||
phoneE164: clients.phoneE164,
|
||||
})
|
||||
.from(clients)
|
||||
.where(eq(clients.id, appt.clientId))
|
||||
.limit(1);
|
||||
@@ -112,8 +129,6 @@ export async function runReminderCheck(): Promise<void> {
|
||||
|
||||
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;
|
||||
if (!confirmationToken) {
|
||||
confirmationToken = randomBytes(32).toString("hex");
|
||||
@@ -123,27 +138,53 @@ export async function runReminderCheck(): Promise<void> {
|
||||
.where(eq(appointments.id, appt.id));
|
||||
}
|
||||
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
client.email,
|
||||
{
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
if (!emailLog) {
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
client.email,
|
||||
{
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
confirmationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
// Record send — ignore conflicts (race condition between instances)
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label })
|
||||
.onConflictDoNothing();
|
||||
if (sent) {
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label, channel: "email" })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
if (!smsLog && client.smsOptIn && client.phoneE164) {
|
||||
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.phoneE164, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Telnyx } from "telnyx";
|
||||
|
||||
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 { createHmac } = await import("crypto");
|
||||
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++) {
|
||||
diff |= sigBuf[i] ^ expBuf[i];
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -3,22 +3,10 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
}
|
||||
|
||||
# Proxy API calls to the API service
|
||||
|
||||
@@ -226,6 +226,7 @@ export function CustomerPortal() {
|
||||
)}
|
||||
|
||||
{showReschedule && rescheduleAppointment && (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<RescheduleFlow
|
||||
appointment={rescheduleAppointment as any}
|
||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { loadStripe, type Stripe } from "@stripe/stripe-js";
|
||||
import { Elements, PaymentElement, useStripe, useElements } from "@stripe/react-stripe-js";
|
||||
import { CreditCard, DollarSign, Package, Zap } from "lucide-react";
|
||||
|
||||
@@ -27,7 +27,7 @@ interface BillingPaymentsProps {
|
||||
function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
|
||||
const [packages] = useState<{ name: string; remaining: number }[]>([]);
|
||||
const [packages, setPackages] = useState<{ name: string; remaining: number }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState<"invoices" | "payment" | "packages">("invoices");
|
||||
@@ -398,10 +398,7 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
|
||||
const { error: stripeError } = await stripe.confirmPayment({
|
||||
elements,
|
||||
clientSecret,
|
||||
confirmParams: saveCard
|
||||
? { setup_future_usage: "off_session" }
|
||||
: undefined,
|
||||
redirect: "if_required",
|
||||
confirmParams: saveCard ? { setup_future_usage: "off_session" } : undefined,
|
||||
});
|
||||
|
||||
if (stripeError) {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
|
||||
API_HOST="https://api.minimax.io"
|
||||
API_KEY="$MINIMAX_API_KEY"
|
||||
OUTPUT_DIR="minimax-output"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
# Diverse dog image prompts
|
||||
declare -a PROMPTS=(
|
||||
"A beautiful red Irish Setter with long flowing silky coat, standing proudly in golden hour sunlight, professional pet portrait photography, warm tones"
|
||||
"A fluffy white Pomeranian puppy with thick fluffy coat, sitting alert with bright expression, studio white background, cute grooming"
|
||||
"A black Schnauzer with distinctive full beard and mustache, freshly groomed with neat styling, professional grooming salon setting"
|
||||
"A cream and white Cavalier King Charles Spaniel with silky coat, gentle sad eyes, soft warm indoor lighting, elegant pose"
|
||||
"A brown and white Basset Hound with long droopy ears, lying down in relaxed pose, natural outdoor setting, peaceful expression"
|
||||
"A black and tan miniature Dachshund with glossy coat, alert standing pose, warm studio lighting, detailed paws visible"
|
||||
"A white fluffy Bichon Frise after professional grooming with rounded topknot, happy bouncy expression, bright cheerful background"
|
||||
"A muscular fawn Boxer dog, athletic build, standing confidently outdoors in park, energetic expression, natural lighting"
|
||||
"A blue merle Shetland Sheepdog with alert ears and fluffy coat, running happily, green grass field background, vibrant"
|
||||
"A buff colored Cocker Spaniel with beautiful silky coat, friendly gentle expression, warm natural window lighting, indoor"
|
||||
)
|
||||
|
||||
declare -a FILENAMES=(
|
||||
"dog-setter-red-sunlit.png"
|
||||
"dog-pomeranian-white-studio.png"
|
||||
"dog-schnauzer-black-groomed.png"
|
||||
"dog-cavalier-cream-gentle.png"
|
||||
"dog-basset-brown-white.png"
|
||||
"dog-dachshund-black-tan.png"
|
||||
"dog-bichon-white-groomed.png"
|
||||
"dog-boxer-fawn-athletic.png"
|
||||
"dog-sheepdog-merle-running.png"
|
||||
"dog-cocker-buff-friendly.png"
|
||||
)
|
||||
|
||||
echo "Generating ${#PROMPTS[@]} diverse dog images..."
|
||||
|
||||
for i in "${!PROMPTS[@]}"; do
|
||||
PROMPT="${PROMPTS[$i]}"
|
||||
FILENAME="${FILENAMES[$i]}"
|
||||
|
||||
echo -n "[$((i+1))/${#PROMPTS[@]}] $FILENAME... "
|
||||
|
||||
RESPONSE=$(curl -s -X POST "${API_HOST}/v1/image_generation" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"model\":\"image-01\",\"prompt\":\"${PROMPT}\",\"image_count\":1}")
|
||||
|
||||
# Extract image URL from response
|
||||
IMAGE_URL=$(echo "$RESPONSE" | grep -o '"image_urls":\["\([^"]*\)' | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$IMAGE_URL" ]; then
|
||||
curl -s "$IMAGE_URL" -o "$OUTPUT_DIR/$FILENAME" 2>/dev/null
|
||||
if [ -f "$OUTPUT_DIR/$FILENAME" ] && [ -s "$OUTPUT_DIR/$FILENAME" ]; then
|
||||
echo "✓"
|
||||
else
|
||||
echo "✗ (download failed)"
|
||||
fi
|
||||
else
|
||||
echo "✗ (no URL)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Done! Generated images in $OUTPUT_DIR/"
|
||||
ls -lh "$OUTPUT_DIR"/dog-*.png 2>/dev/null | wc -l
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Use the configured MiniMax API host
|
||||
API_HOST="${MINIMAX_API_HOST:-https://api.minimax.io}"
|
||||
API_KEY="$MINIMAX_API_KEY"
|
||||
|
||||
# Test endpoint - check which one works
|
||||
echo "Testing API endpoints..."
|
||||
echo "API_HOST: $API_HOST"
|
||||
echo "API_KEY: ${API_KEY:0:15}..."
|
||||
|
||||
# Array of diverse dog images to generate
|
||||
declare -a PROMPTS=(
|
||||
"A beautiful red Irish Setter with flowing silky coat, standing proudly in a sunny garden, warm natural lighting, professional pet photography"
|
||||
"A fluffy white Pomeranian with thick coat, sitting alert, bright studio background, cute expression"
|
||||
"A black Schnauzer with distinctive beard, freshly groomed, professional salon setting, dignified pose"
|
||||
"A cream-colored Cavalier King Charles Spaniel, silky coat, gentle expression, soft warm lighting"
|
||||
"A brown and white Basset Hound, long ears, relaxed sitting pose, natural outdoor background"
|
||||
"A black and tan Dachshund, elongated body, alert posture, warm studio lighting"
|
||||
"A white Bichon Frise, fluffy groomed coat, happy expression, bright cheerful background"
|
||||
"A fawn Boxer with muscular build, athletic posture, outdoor park setting, energetic expression"
|
||||
"A merle Shetland Sheepdog, alert ears, running pose, green garden background"
|
||||
"A buff-colored Cocker Spaniel, silky coat, friendly expression, warm natural light"
|
||||
)
|
||||
|
||||
declare -a FILENAMES=(
|
||||
"dog-setter-red-sunny.png"
|
||||
"dog-pomeranian-white-alert.png"
|
||||
"dog-schnauzer-groomed.png"
|
||||
"dog-cavalier-cream.png"
|
||||
"dog-basset-hound-outdoor.png"
|
||||
"dog-dachshund-alert.png"
|
||||
"dog-bichon-frise-happy.png"
|
||||
"dog-boxer-athletic.png"
|
||||
"dog-sheepdog-merle.png"
|
||||
"dog-cocker-spaniel-buff.png"
|
||||
)
|
||||
|
||||
mkdir -p minimax-output
|
||||
|
||||
echo "Generating ${#PROMPTS[@]} diverse dog images..."
|
||||
|
||||
for i in "${!PROMPTS[@]}"; do
|
||||
PROMPT="${PROMPTS[$i]}"
|
||||
FILENAME="${FILENAMES[$i]}"
|
||||
|
||||
echo "[$((i+1))/${#PROMPTS[@]}] Generating: $FILENAME"
|
||||
|
||||
# Make API request
|
||||
RESPONSE=$(curl -s -X POST "${API_HOST}/v1/image_generation" \
|
||||
-H "Authorization: Bearer ${API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"model\": \"image-01\",
|
||||
\"prompt\": \"${PROMPT}\",
|
||||
\"image_count\": 1
|
||||
}")
|
||||
|
||||
# Check if response contains image data
|
||||
if echo "$RESPONSE" | grep -q "data\|image_url\|file_content"; then
|
||||
echo " ✓ Response received"
|
||||
|
||||
# Try to extract and save image data
|
||||
# Different APIs format responses differently
|
||||
IMAGE_DATA=$(echo "$RESPONSE" | grep -o '"file_content":"[^"]*' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$IMAGE_DATA" ]; then
|
||||
echo "$IMAGE_DATA" | base64 -d > "minimax-output/$FILENAME"
|
||||
echo " ✓ Image saved to minimax-output/$FILENAME"
|
||||
else
|
||||
echo " ✗ Could not extract image data"
|
||||
fi
|
||||
else
|
||||
echo " ✗ API response: ${RESPONSE:0:100}"
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Image generation complete!"
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
import base64
|
||||
import requests
|
||||
import os
|
||||
import json
|
||||
|
||||
api_key = os.environ.get("MINIMAX_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("MINIMAX_API_KEY environment variable not set")
|
||||
|
||||
url = "https://api.minimax.io/v1/image_generation"
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
# Ensure output directory exists
|
||||
os.makedirs("minimax-output", exist_ok=True)
|
||||
|
||||
prompts = [
|
||||
{
|
||||
"filename": "dog-puggle-fawn-playful.png",
|
||||
"prompt": "Adorable fawn Puggle puppy with playful expression, compact muscular build, professional pet photography, studio lighting, photorealistic"
|
||||
},
|
||||
{
|
||||
"filename": "dog-puggle-black-sitting.png",
|
||||
"prompt": "Black and tan Puggle with alert sitting posture, pointed beagle-like ears, gentle eyes, professional studio lighting, photorealistic"
|
||||
},
|
||||
{
|
||||
"filename": "dog-puggle-cream-groomed.png",
|
||||
"prompt": "Cream Puggle freshly groomed with fluffy coat, happy expression, lying down comfortably, natural daylight, photorealistic"
|
||||
},
|
||||
{
|
||||
"filename": "dog-puggle-tricolor-outdoor.png",
|
||||
"prompt": "Tricolor Puggle in outdoor garden setting, alert playful pose, natural sunlight, professional pet photography, photorealistic"
|
||||
},
|
||||
{
|
||||
"filename": "dog-puggle-fawn-grooming.png",
|
||||
"prompt": "Fawn Puggle at grooming salon, gentle expression, compact muscular build with beagle-like features, professional grooming setup, warm lighting, photorealistic"
|
||||
}
|
||||
]
|
||||
|
||||
print(f"Generating {len(prompts)} Puggle images...")
|
||||
|
||||
for item in prompts:
|
||||
filename = item["filename"]
|
||||
prompt = item["prompt"]
|
||||
|
||||
print(f"\nGenerating {filename}...")
|
||||
|
||||
payload = {
|
||||
"model": "image-01",
|
||||
"prompt": prompt,
|
||||
"aspect_ratio": "1:1",
|
||||
"response_format": "base64",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
if "data" in data and "image_base64" in data["data"]:
|
||||
images = data["data"]["image_base64"]
|
||||
|
||||
# Save the first (and usually only) image
|
||||
output_path = f"minimax-output/{filename}"
|
||||
with open(output_path, "wb") as f:
|
||||
f.write(base64.b64decode(images[0]))
|
||||
|
||||
file_size = os.path.getsize(output_path)
|
||||
print(f"✓ Saved {filename} ({file_size} bytes)")
|
||||
else:
|
||||
print(f"✗ Unexpected response format: {json.dumps(data, indent=2)}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"✗ Error generating {filename}: {e}")
|
||||
except Exception as e:
|
||||
print(f"✗ Unexpected error for {filename}: {e}")
|
||||
|
||||
print("\n✓ Image generation complete!")
|
||||
print("Files saved to minimax-output/")
|
||||
@@ -0,0 +1,9 @@
|
||||
ALTER TABLE "reminder_logs" DROP CONSTRAINT "reminder_logs_appointment_id_reminder_type_unique";--> statement-breakpoint
|
||||
ALTER TABLE "business_settings" ADD COLUMN "logo_key" text;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD COLUMN "sms_opt_in" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD COLUMN "sms_consent_date" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD COLUMN "sms_opt_out_date" timestamp;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD COLUMN "sms_consent_text" text;--> statement-breakpoint
|
||||
ALTER TABLE "pets" ADD COLUMN "image" text;--> statement-breakpoint
|
||||
ALTER TABLE "reminder_logs" ADD COLUMN "channel" text DEFAULT 'email' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "reminder_logs" ADD CONSTRAINT "reminder_logs_appointment_id_reminder_type_channel_unique" UNIQUE("appointment_id","reminder_type","channel");
|
||||
@@ -1,6 +0,0 @@
|
||||
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");
|
||||
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;
|
||||
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;
|
||||
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "clients" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "idx_clients_stripe_customer_id" UNIQUE("stripe_customer_id");--> statement-breakpoint
|
||||
ALTER TABLE "invoices" ADD COLUMN "stripe_payment_intent_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "invoices" ADD COLUMN "stripe_refund_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "invoices" ADD COLUMN "payment_failure_reason" text;--> statement-breakpoint
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "idx_invoices_stripe_payment_intent_id" UNIQUE("stripe_payment_intent_id");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "business_settings" ADD COLUMN "sms_enabled" boolean NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"id": "0027_stripe_identifiers",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"authProviderConfig": {
|
||||
"name": "auth_provider_config",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"providerId": { "name": "provider_id", "type": "text", "isNullable": false },
|
||||
"displayName": { "name": "display_name", "type": "text", "isNullable": false },
|
||||
"issuerUrl": { "name": "issuer_url", "type": "text", "isNullable": false },
|
||||
"internalBaseUrl": { "name": "internal_base_url", "type": "text", "isNullable": true },
|
||||
"clientId": { "name": "client_id", "type": "text", "isNullable": false },
|
||||
"clientSecret": { "name": "client_secret", "type": "text", "isNullable": false },
|
||||
"scopes": { "name": "scopes", "type": "text", "isNullable": false, "default": "'openid profile email'" },
|
||||
"enabled": { "name": "enabled", "type": "boolean", "isNullable": false, "default": "true" },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {}
|
||||
},
|
||||
"businessSettings": {
|
||||
"name": "business_settings",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"businessName": { "name": "business_name", "type": "text", "isNullable": false, "default": "'GroomBook'" },
|
||||
"logoBase64": { "name": "logo_base64", "type": "text", "isNullable": true },
|
||||
"logoMimeType": { "name": "logo_mime_type", "type": "text", "isNullable": true },
|
||||
"logoKey": { "name": "logo_key", "type": "text", "isNullable": true },
|
||||
"primaryColor": { "name": "primary_color", "type": "text", "isNullable": false, "default": "'#4f8a6f'" },
|
||||
"accentColor": { "name": "accent_color", "type": "text", "isNullable": false, "default": "'#8b7355'" },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"name": { "name": "name", "type": "text", "isNullable": false },
|
||||
"email": { "name": "email", "type": "text", "isNullable": true },
|
||||
"phone": { "name": "phone", "type": "text", "isNullable": true },
|
||||
"address": { "name": "address", "type": "text", "isNullable": true },
|
||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||
"emailOptOut": { "name": "email_opt_out", "type": "boolean", "isNullable": false, "default": "false" },
|
||||
"smsOptIn": { "name": "sms_opt_in", "type": "boolean", "isNullable": false, "default": "false" },
|
||||
"smsConsentDate": { "name": "sms_consent_date", "type": "timestamp", "isNullable": true },
|
||||
"smsOptOutDate": { "name": "sms_opt_out_date", "type": "timestamp", "isNullable": true },
|
||||
"smsConsentText": { "name": "sms_consent_text", "type": "text", "isNullable": true },
|
||||
"stripeCustomerId": { "name": "stripe_customer_id", "type": "text", "isNullable": true },
|
||||
"status": { "name": "status", "type": "client_status", "isNullable": false, "default": "'active'" },
|
||||
"disabledAt": { "name": "disabled_at", "type": "timestamp", "isNullable": true },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "idx_clients_stripe_customer_id": { "columns": ["stripe_customer_id"] } }
|
||||
},
|
||||
"invoices": {
|
||||
"name": "invoices",
|
||||
"columns": {
|
||||
"id": { "name": "id", "type": "uuid", "primaryKey": true, "default": "gen_random_uuid()", "isNullable": false },
|
||||
"appointmentId": { "name": "appointment_id", "type": "uuid", "isNullable": true },
|
||||
"clientId": { "name": "client_id", "type": "uuid", "isNullable": false },
|
||||
"subtotalCents": { "name": "subtotal_cents", "type": "integer", "isNullable": false },
|
||||
"taxCents": { "name": "tax_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||
"tipCents": { "name": "tip_cents", "type": "integer", "isNullable": false, "default": "0" },
|
||||
"totalCents": { "name": "total_cents", "type": "integer", "isNullable": false },
|
||||
"status": { "name": "status", "type": "invoice_status", "isNullable": false, "default": "'draft'" },
|
||||
"paymentMethod": { "name": "payment_method", "type": "payment_method", "isNullable": true },
|
||||
"paidAt": { "name": "paid_at", "type": "timestamp", "isNullable": true },
|
||||
"stripePaymentIntentId": { "name": "stripe_payment_intent_id", "type": "text", "isNullable": true },
|
||||
"stripeRefundId": { "name": "stripe_refund_id", "type": "text", "isNullable": true },
|
||||
"paymentFailureReason": { "name": "payment_failure_reason", "type": "text", "isNullable": true },
|
||||
"notes": { "name": "notes", "type": "text", "isNullable": true },
|
||||
"createdAt": { "name": "created_at", "type": "timestamp", "isNullable": false, "default": "now()" },
|
||||
"updatedAt": { "name": "updated_at", "type": "timestamp", "isNullable": false, "default": "now()" }
|
||||
},
|
||||
"indexes": { "idx_invoices_client_id": { "columns": ["client_id"] }, "idx_invoices_status": { "columns": ["status"] }, "idx_invoices_created_at": { "columns": ["created_at"] } },
|
||||
"foreignKeys": { "invoices_appointment_id_fkey": { "columns": ["appointmentId"], "reference": { "table": "appointments", "columns": ["id"] } }, "invoices_client_id_fkey": { "columns": ["clientId"], "reference": { "table": "clients", "columns": ["id"] } } },
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": { "idx_invoices_stripe_payment_intent_id": { "columns": ["stripe_payment_intent_id"] } }
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"appointment_status": { "name": "appointment_status", "values": ["scheduled", "confirmed", "in_progress", "completed", "cancelled", "no_show"] },
|
||||
"client_status": { "name": "client_status", "values": ["active", "disabled"] },
|
||||
"impersonation_session_status": { "name": "impersonation_session_status", "values": ["active", "ended", "expired"] },
|
||||
"invoice_status": { "name": "invoice_status", "values": ["draft", "pending", "paid", "void"] },
|
||||
"payment_method": { "name": "payment_method", "values": ["cash", "card", "check", "other"] },
|
||||
"staff_role": { "name": "staff_role", "values": ["groomer", "receptionist", "manager"] },
|
||||
"waitlist_status": { "name": "waitlist_status", "values": ["active", "notified", "expired", "cancelled"] }
|
||||
},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
@@ -187,8 +187,8 @@
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1775568867192,
|
||||
"tag": "0026_stripe_payment",
|
||||
"when": 1776035812477,
|
||||
"tag": "0026_boring_storm",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -71,7 +71,10 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
|
||||
address: "1 Main St, Springfield, CA 90000",
|
||||
notes: null,
|
||||
emailOptOut: false,
|
||||
stripeCustomerId: null,
|
||||
smsOptIn: false,
|
||||
smsConsentDate: null,
|
||||
smsOptOutDate: null,
|
||||
smsConsentText: null,
|
||||
status: "active",
|
||||
disabledAt: null,
|
||||
createdAt: new Date("2025-01-01T00:00:00Z"),
|
||||
|
||||
@@ -110,7 +110,10 @@ export const clients = pgTable("clients", {
|
||||
address: text("address"),
|
||||
notes: text("notes"),
|
||||
emailOptOut: boolean("email_opt_out").notNull().default(false),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
smsOptIn: boolean("sms_opt_in").notNull().default(false),
|
||||
smsConsentDate: timestamp("sms_consent_date"),
|
||||
smsOptOutDate: timestamp("sms_opt_out_date"),
|
||||
smsConsentText: text("sms_consent_text"),
|
||||
status: clientStatusEnum("status").notNull().default("active"),
|
||||
disabledAt: timestamp("disabled_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
@@ -251,9 +254,6 @@ export const invoices = pgTable(
|
||||
status: invoiceStatusEnum("status").notNull().default("draft"),
|
||||
paymentMethod: paymentMethodEnum("payment_method"),
|
||||
paidAt: timestamp("paid_at"),
|
||||
stripePaymentIntentId: text("stripe_payment_intent_id"),
|
||||
stripeRefundId: text("stripe_refund_id"),
|
||||
paymentFailureReason: text("payment_failure_reason"),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
@@ -262,7 +262,6 @@ export const invoices = pgTable(
|
||||
index("idx_invoices_client_id").on(t.clientId),
|
||||
index("idx_invoices_status").on(t.status),
|
||||
index("idx_invoices_created_at").on(t.createdAt),
|
||||
index("idx_invoices_stripe_payment_intent_id").on(t.stripePaymentIntentId),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -309,11 +308,11 @@ export const reminderLogs = pgTable(
|
||||
appointmentId: uuid("appointment_id")
|
||||
.notNull()
|
||||
.references(() => appointments.id, { onDelete: "cascade" }),
|
||||
// "confirmation" | "24h" | "2h"
|
||||
reminderType: text("reminder_type").notNull(),
|
||||
channel: text("channel").notNull().default("email"),
|
||||
sentAt: timestamp("sent_at").notNull().defaultNow(),
|
||||
},
|
||||
(t) => [unique().on(t.appointmentId, t.reminderType)]
|
||||
(t) => [unique().on(t.appointmentId, t.reminderType, t.channel)]
|
||||
);
|
||||
|
||||
// ─── Impersonation ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -43,6 +43,9 @@ importers:
|
||||
stripe:
|
||||
specifier: ^22.0.0
|
||||
version: 22.0.1(@types/node@22.19.15)
|
||||
telnyx:
|
||||
specifier: ^6.41.0
|
||||
version: 6.41.0(ws@8.19.0)
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@@ -2112,6 +2115,9 @@ packages:
|
||||
resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@@ -3093,6 +3099,9 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-sha256@1.3.0:
|
||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
@@ -4100,6 +4109,9 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
@@ -4188,6 +4200,14 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
telnyx@6.41.0:
|
||||
resolution: {integrity: sha512-93eKksI6HnLYp8e4DGlpC3SkBAfagblE+uug0FNDLT/+mix3PP0RveoQ/YZeRdxDhjMcoXVgeusJsgFP6PvUdw==}
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
|
||||
temp-dir@2.0.0:
|
||||
resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -6710,6 +6730,8 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
@@ -7755,6 +7777,8 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-sha256@1.3.0: {}
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fast-xml-builder@1.1.4:
|
||||
@@ -8756,6 +8780,11 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
dependencies:
|
||||
'@stablelib/base64': 1.0.1
|
||||
fast-sha256: 1.3.0
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
@@ -8858,6 +8887,12 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
telnyx@6.41.0(ws@8.19.0):
|
||||
dependencies:
|
||||
standardwebhooks: 1.0.0
|
||||
optionalDependencies:
|
||||
ws: 8.19.0
|
||||
|
||||
temp-dir@2.0.0: {}
|
||||
|
||||
tempy@0.6.0:
|
||||
|
||||
|
After Width: | Height: | Size: 70 B |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 55 KiB |