Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f97a19cdd | |||
| a407f866d5 | |||
| 04147f3e6c |
@@ -0,0 +1,27 @@
|
||||
# The current version of the config schema
|
||||
version: 1
|
||||
# What protocol to use when performing git operations. Supported values: ssh, https
|
||||
git_protocol: https
|
||||
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
|
||||
editor:
|
||||
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||
prompt: enabled
|
||||
# Preference for editor-based interactive prompting. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||
prefer_editor_prompt: disabled
|
||||
# A pager program to send command output to, e.g. "less". If blank, will refer to environment. Set the value to "cat" to disable the pager.
|
||||
pager:
|
||||
# Aliases allow you to create nicknames for gh commands
|
||||
aliases:
|
||||
co: pr checkout
|
||||
# The path to a unix socket through which to send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
|
||||
http_unix_socket:
|
||||
# What web browser gh should use when opening URLs. If blank, will refer to environment.
|
||||
browser:
|
||||
# Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled
|
||||
color_labels: disabled
|
||||
# Whether customizable, 4-bit accessible colors should be used. Supported values: enabled, disabled
|
||||
accessible_colors: disabled
|
||||
# Whether an accessible prompter should be used. Supported values: enabled, disabled
|
||||
accessible_prompter: disabled
|
||||
# Whether to use a animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled
|
||||
spinner: enabled
|
||||
@@ -0,0 +1,6 @@
|
||||
github.com:
|
||||
users:
|
||||
groombook-engineer[bot]:
|
||||
oauth_token: ghs_znRlNnhuSsNZp0GejabxpkSUqXC9vt27yl3K
|
||||
user: groombook-engineer[bot]
|
||||
oauth_token: ghs_znRlNnhuSsNZp0GejabxpkSUqXC9vt27yl3K
|
||||
@@ -462,9 +462,45 @@ import {
|
||||
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),
|
||||
});
|
||||
@@ -544,23 +580,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.
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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) {
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!secret) {
|
||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
||||
}
|
||||
|
||||
@@ -23,14 +22,11 @@ webhooksRouter.post("/stripe", async (c) => {
|
||||
return c.json({ error: "Could not read body" }, 400);
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
if (!stripe) {
|
||||
return c.json({ error: "Stripe not configured" }, 503);
|
||||
}
|
||||
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||
return c.json({ error: message }, 401);
|
||||
|
||||
@@ -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;
|
||||
@@ -59,8 +59,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, firstInvoiceId));
|
||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
|
||||
}
|
||||
|
||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
eq,
|
||||
getDb,
|
||||
gte,
|
||||
inArray,
|
||||
lt,
|
||||
appointments,
|
||||
clients,
|
||||
@@ -64,56 +65,66 @@ 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
|
||||
.select({ id: reminderLogs.id })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.appointmentId, appt.id),
|
||||
eq(reminderLogs.reminderType, window.label)
|
||||
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
||||
|
||||
if (appointmentIds.length === 0) continue;
|
||||
|
||||
const sentAppointmentIds = new Set(
|
||||
(
|
||||
await db
|
||||
.select({ appointmentId: reminderLogs.appointmentId })
|
||||
.from(reminderLogs)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderLogs.reminderType, window.label),
|
||||
inArray(reminderLogs.appointmentId, appointmentIds)
|
||||
)
|
||||
)
|
||||
).map((r) => r.appointmentId)
|
||||
);
|
||||
|
||||
const joinedRows = await db
|
||||
.select({
|
||||
appointmentId: appointments.id,
|
||||
startTime: appointments.startTime,
|
||||
clientId: appointments.clientId,
|
||||
petId: appointments.petId,
|
||||
serviceId: appointments.serviceId,
|
||||
staffId: appointments.staffId,
|
||||
confirmationToken: appointments.confirmationToken,
|
||||
clientName: clients.name,
|
||||
clientEmail: clients.email,
|
||||
clientEmailOptOut: clients.emailOptOut,
|
||||
petName: pets.name,
|
||||
serviceName: services.name,
|
||||
staffName: staff.name,
|
||||
})
|
||||
.from(appointments)
|
||||
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||
.where(
|
||||
and(
|
||||
gte(appointments.startTime, windowStart),
|
||||
lt(appointments.startTime, windowEnd),
|
||||
eq(appointments.status, "scheduled")
|
||||
)
|
||||
.limit(1);
|
||||
);
|
||||
|
||||
if (existing.length > 0) continue; // already sent
|
||||
const appointmentMap = new Map<string, typeof joinedRows[number]>();
|
||||
for (const row of joinedRows) {
|
||||
appointmentMap.set(row.appointmentId, row);
|
||||
}
|
||||
|
||||
// Fetch related records for the email
|
||||
const [client] = await db
|
||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, appt.clientId))
|
||||
.limit(1);
|
||||
for (const appt of upcoming) {
|
||||
if (sentAppointmentIds.has(appt.id)) continue;
|
||||
|
||||
if (!client || !client.email || client.emailOptOut) continue;
|
||||
const row = appointmentMap.get(appt.id);
|
||||
if (!row) continue;
|
||||
if (!row.clientEmail || row.clientEmailOptOut) continue;
|
||||
if (!row.petName || !row.serviceName) continue;
|
||||
|
||||
const [pet] = await db
|
||||
.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) 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");
|
||||
@@ -125,12 +136,12 @@ export async function runReminderCheck(): Promise<void> {
|
||||
|
||||
const sent = await sendEmail(
|
||||
buildReminderEmail(
|
||||
client.email,
|
||||
row.clientEmail,
|
||||
{
|
||||
clientName: client.name,
|
||||
petName: pet.name,
|
||||
serviceName: service.name,
|
||||
groomerName,
|
||||
clientName: row.clientName,
|
||||
petName: row.petName,
|
||||
serviceName: row.serviceName,
|
||||
groomerName: row.staffName ?? null,
|
||||
startTime: appt.startTime,
|
||||
},
|
||||
window.hours,
|
||||
@@ -139,7 +150,6 @@ export async function runReminderCheck(): Promise<void> {
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
// Record send — ignore conflicts (race condition between instances)
|
||||
await db
|
||||
.insert(reminderLogs)
|
||||
.values({ appointmentId: appt.id, reminderType: window.label })
|
||||
@@ -172,3 +182,4 @@ export async function runSessionCleanup(): Promise<void> {
|
||||
.delete(session)
|
||||
.where(lt(session.expiresAt, now));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
-1
Submodule infra updated: b667a3f005...d6c0d13d02
Submodule
+1
Submodule infra-repo added at ff42966751
Reference in New Issue
Block a user