Compare commits

..

8 Commits

Author SHA1 Message Date
Flea Flicker 4594bd2307 fix(GRO-655): create corepack cache dir in builder stage
Prevents ENOENT crash in migrate and seed jobs.

Root cause: corepack tries to mkdir /home/node/.cache/node/corepack/v1
but the directory does not exist in the builder stage. This was a
regression in c438f57 where the cache directory was not pre-created.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 21:58:44 +00:00
Flea Flicker d8c0052b54 fix(GRO-634): implement auth & authorization security hardening (8 findings)
- Remove placeholder secret fallback, require BETTER_AUTH_SECRET when AUTH_DISABLED=true
- Fix TOCTOU race in setup: use INSERT...RETURNING for atomic confirmation token creation
- Fix confirmation token replay: atomic UPDATE with WHERE clause prevents double-use
- Add CSRF origin-check middleware for non-safe HTTP methods
- Validate OIDC discovery URL hostname matches configured issuer
- Use timing-safe comparison for iCal authentication tokens
- Add rate limiting (10 req/min per IP) on setup endpoints
- Fix RBAC error messages: correct inversion of privilege check
2026-04-14 17:08:02 +00:00
groombook-cto[bot] 4d1d94296f fix(GRO-631): add tag validation to promote-prod workflow (#282)
CTO review approved. Tag format validation and GHCR image existence check are correct and well-placed.
2026-04-14 16:40:07 +00:00
groombook-cto[bot] c6800a6144 Merge branch 'main' into feature/gro-631-prod-tag-validation 2026-04-14 16:35:46 +00:00
groombook-cto[bot] 000e90a617 feat(GRO-631): add security headers to nginx.conf
feat(GRO-631): add security headers to nginx.conf
2026-04-14 16:25:57 +00:00
Flea Flicker 70e9465b68 fix(GRO-631): add tag validation to promote-prod workflow
- Validate tag format against regex YYYY.MM.DD-sha7 before proceeding
- Verify image exists in GHCR using gh api with packages: read permission
- Add packages: read permission to job permissions block

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 16:22:23 +00:00
Flea Flicker 8c3e0f9554 feat(GRO-631): add security headers to nginx.conf
Add X-Content-Type-Options, X-Frame-Options, Referrer-Policy, X-XSS-Protection,
and Permissions-Policy headers to server block and static assets location.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-14 16:10:04 +00:00
groombook-cto[bot] c438f5772c feat(GRO-607): Stripe Elements payment UI replacing mock flow
* GRO-605: Stripe SDK integration + payment service

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* GRO-606: Add payment API endpoints (pay invoice, payment methods, refunds)

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* feat(GRO-597): Stripe payment backend — schema, service, API, webhooks

Consolidates GRO-605, GRO-606, GRO-608 into a single clean PR:
- GRO-605: Stripe SDK integration + payment service
- GRO-606: Payment API endpoints (pay invoice, payment methods, refunds)
- GRO-608: Stripe webhook handler

Migration consolidation:
- Single 0026_stripe_payment.sql migration adds stripeCustomerId to clients
  and stripe_payment_intent_id, stripe_refund_id, payment_failure_reason to invoices
- Removed duplicate 0027_stripe_identifiers.sql

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* GRO-607: Install Stripe frontend packages

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* GRO-607: Add /portal/config endpoint + rename date field

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* GRO-607: Replace mock payment flow with real Stripe Elements

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-607): Stripe Elements payment UI - lint/type fixes

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-607): remove unused eslint-disable directive in CustomerPortal

Co-Authored-By: Paperclip <noreply@paperclip.ing>

* fix(GRO-607): CTO review fixes — payment security and correctness

- Fix multi-invoice total calculation: use inArray() instead of eq()
  on single ID, sum all invoices not just first
- Add ownership check to payment method deletion: verify the payment
  method belongs to the authenticated Stripe customer before detaching
- Remove duplicate /config endpoint in portal.ts
- Fix webhook Stripe client: use getStripeClient() from payment service
  instead of constructing with WEBHOOK_SECRET
- Remove unnecessary body validator on /invoices/:id/pay route
- Export getStripeClient() for use by stripe-webhooks.ts
- Add inArray import to payment.ts

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-14 08:27:03 +00:00
77 changed files with 943 additions and 6783 deletions
+22
View File
@@ -14,7 +14,29 @@ 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
View File
+1
View File
@@ -12,6 +12,7 @@ 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 && \
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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
View File
@@ -31,11 +31,11 @@ const BASE_APPT = {
// ─── Shared mock DB state ─────────────────────────────────────────────────────
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
let mockAppt: (typeof BASE_APPT & { confirmationToken: string }) | null = BASE_APPT as typeof BASE_APPT & { confirmationToken: string };
let lastUpdate: Record<string, unknown> = {};
function resetMock() {
mockAppt = { ...BASE_APPT };
mockAppt = { ...BASE_APPT, confirmationToken: "valid-token-abc123" } as typeof BASE_APPT & { confirmationToken: string };
lastUpdate = {};
}
@@ -55,19 +55,39 @@ vi.mock("@groombook/db", () => {
}),
}),
update: () => ({
set: (vals: Record<string, unknown>) => ({
where: () => {
lastUpdate = { ...vals };
if (mockAppt) {
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
}
return { returning: () => (mockAppt ? [mockAppt] : []) };
},
}),
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] : [];
},
};
},
};
},
}),
}),
appointments,
eq: () => ({}),
and: (a: unknown, b: unknown, c?: unknown) => (c ? [a, b, c] : [a, b]),
gt: () => ({}),
};
});
+2 -2
View File
@@ -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(/super user privileges required/i);
expect(body.error).toMatch(/role 'receptionist' is not permitted/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(/super user privileges required/i);
expect(body.error).toMatch(/role 'groomer' is not permitted/i);
});
it("allows a manager with multiple allowed roles", async () => {
+21
View File
@@ -28,6 +28,7 @@ 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();
@@ -41,6 +42,23 @@ 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" }));
@@ -50,6 +68,9 @@ 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);
+35 -11
View File
@@ -86,10 +86,15 @@ 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 ?? "placeholder-secret-do-not-use-in-prod",
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
@@ -199,20 +204,36 @@ 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) {
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);
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);
}
} else {
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
}
@@ -287,6 +308,9 @@ export async function initAuth(): Promise<void> {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
cookieAttributes: {
sameSite: "strict",
},
},
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
});
+3 -3
View File
@@ -149,9 +149,9 @@ export function requireRoleOrSuperUser(
}
return c.json(
{
error: staffRow.isSuperUser
? `Forbidden: role '${staffRow.role}' is not permitted`
: "Forbidden: super user privileges required",
error: hasAllowedRole
? "Forbidden: super user privileges required"
: `Forbidden: role '${staffRow.role}' is not permitted`,
},
403
);
-51
View File
@@ -1,51 +0,0 @@
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,
});
});
+38 -49
View File
@@ -255,39 +255,37 @@ 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(eq(appointments.id, appt.id));
.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`);
}
return c.redirect(`${BASE_URL()}/booking/confirmed`);
});
@@ -299,29 +297,9 @@ 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",
@@ -329,7 +307,18 @@ bookRouter.get("/cancel/:token", async (c) => {
confirmationToken: null,
updatedAt: new Date(),
})
.where(eq(appointments.id, appt.id));
.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`);
}
return c.redirect(`${BASE_URL()}/booking/cancelled`);
});
+7 -2
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { randomBytes } from "node:crypto";
import { randomBytes, timingSafeEqual } from "node:crypto";
import {
and,
eq,
@@ -84,7 +84,12 @@ calendarRouter.get("/:staffId.ics", async (c) => {
.where(eq(staff.id, staffId))
.limit(1);
if (!staffMember || staffMember.icalToken !== token) {
if (
!staffMember ||
!staffMember.icalToken ||
staffMember.icalToken.length !== token.length ||
!timingSafeEqual(Buffer.from(staffMember.icalToken), Buffer.from(token))
) {
return c.text("Unauthorized", 401);
}
+1 -24
View File
@@ -4,35 +4,12 @@ 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: e164String().optional(),
phone: z.string().max(50).optional(),
address: z.string().max(500).optional(),
notes: z.string().max(2000).optional(),
});
+2 -1
View File
@@ -13,8 +13,9 @@ import {
clients,
sql,
} from "@groombook/db";
import type { AppEnv } from "../middleware/rbac.js";
export const invoicesRouter = new Hono();
export const invoicesRouter = new Hono<AppEnv>();
const createInvoiceSchema = z.object({
appointmentId: z.string().uuid().optional(),
+21 -47
View File
@@ -35,6 +35,12 @@ 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));
@@ -123,7 +129,7 @@ portalRouter.get("/invoices", async (c) => {
id: inv.id,
status: inv.status,
totalCents: inv.totalCents,
createdAt: inv.createdAt,
date: inv.createdAt,
lineItems: (itemsByInvoice[inv.id] || []).map(li => ({ id: li.id, description: li.description, quantity: li.quantity, unitPriceCents: li.unitPriceCents, totalCents: li.totalCents })),
})));
});
@@ -453,49 +459,12 @@ 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),
});
@@ -530,6 +499,7 @@ 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);
@@ -574,19 +544,23 @@ 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.
+29
View File
@@ -6,6 +6,25 @@ 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) => {
@@ -185,6 +204,11 @@ 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)
@@ -254,6 +278,11 @@ 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)
+112
View File
@@ -0,0 +1,112 @@
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 });
});
-137
View File
@@ -1,137 +0,0 @@
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 });
});
+11 -9
View File
@@ -1,9 +1,9 @@
import Stripe from "stripe";
import { getDb, clients, eq, invoices } from "@groombook/db";
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
let _stripe: Stripe | null | undefined;
function getStripeClient(): Stripe | null {
export function getStripeClient(): Stripe | null {
if (_stripe === undefined) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) return null;
@@ -43,11 +43,13 @@ 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, invoiceIds[0]));
.where(eq(invoices.id, firstInvoiceId));
const [invoice] = invoiceRows;
if (!invoice) return null;
@@ -57,8 +59,8 @@ export async function createPaymentIntent(
const allInvoices = await db
.select({ totalCents: invoices.totalCents })
.from(invoices)
.where(eq(invoices.id, invoiceIds[0]));
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
.where(inArray(invoices.id, invoiceIds));
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
}
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
@@ -82,10 +84,10 @@ export async function createPaymentIntent(
.where(eq(invoices.id, invId));
}
return {
clientSecret: paymentIntent.client_secret!,
paymentIntentId: paymentIntent.id,
};
const clientSecret = paymentIntent.client_secret;
if (!clientSecret) return null;
return { clientSecret, paymentIntentId: paymentIntent.id };
}
export async function processRefund(
+29 -70
View File
@@ -18,10 +18,9 @@ import {
buildReminderEmail,
sendEmail,
} from "./email.js";
import { smsSend } from "./sms.js";
// TCPA-required opt-out text appended to every SMS reminder
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 }[] {
const early = Number(process.env.REMINDER_HOURS_EARLY ?? 24);
const late = Number(process.env.REMINDER_HOURS_LATE ?? 2);
@@ -66,39 +65,23 @@ export async function runReminderCheck(): Promise<void> {
);
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 })
.from(reminderLogs)
.where(
and(
eq(reminderLogs.appointmentId, appt.id),
eq(reminderLogs.reminderType, window.label),
eq(reminderLogs.channel, "email")
eq(reminderLogs.reminderType, window.label)
)
)
.limit(1);
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);
if (existing.length > 0) continue; // already sent
// Fetch related records for the email
const [client] = await db
.select({
name: clients.name,
email: clients.email,
emailOptOut: clients.emailOptOut,
smsOptIn: clients.smsOptIn,
phoneE164: clients.phoneE164,
})
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
.from(clients)
.where(eq(clients.id, appt.clientId))
.limit(1);
@@ -129,6 +112,8 @@ 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");
@@ -138,53 +123,27 @@ export async function runReminderCheck(): Promise<void> {
.where(eq(appointments.id, appt.id));
}
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
)
);
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) {
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);
}
if (sent) {
// Record send — ignore conflicts (race condition between instances)
await db
.insert(reminderLogs)
.values({ appointmentId: appt.id, reminderType: window.label })
.onConflictDoNothing();
}
}
}
-140
View File
@@ -1,140 +0,0 @@
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;
}
+12
View File
@@ -3,10 +3,22 @@ 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
-1
View File
@@ -226,7 +226,6 @@ 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, type Stripe } from "@stripe/stripe-js";
import { loadStripe } 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, setPackages] = useState<{ name: string; remaining: number }[]>([]);
const [packages] = 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,7 +398,10 @@ function PaymentModal({ sessionId, pending, onClose, onSuccess }: PaymentModalPr
const { error: stripeError } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: saveCard ? { setup_future_usage: "off_session" } : undefined,
confirmParams: saveCard
? { setup_future_usage: "off_session" }
: undefined,
redirect: "if_required",
});
if (stripeError) {
-65
View File
@@ -1,65 +0,0 @@
#!/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
-80
View File
@@ -1,80 +0,0 @@
#!/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!"
-78
View File
@@ -1,78 +0,0 @@
#!/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/")
@@ -1,9 +0,0 @@
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");
@@ -0,0 +1,6 @@
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");
@@ -1,6 +0,0 @@
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");
@@ -1 +0,0 @@
ALTER TABLE "business_settings" ADD COLUMN "sms_enabled" boolean NOT NULL DEFAULT false;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,103 +0,0 @@
{
"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": {}
}
+2 -2
View File
@@ -187,8 +187,8 @@
{
"idx": 26,
"version": "7",
"when": 1776035812477,
"tag": "0026_boring_storm",
"when": 1775568867192,
"tag": "0026_stripe_payment",
"breakpoints": true
}
]
+1 -4
View File
@@ -71,10 +71,7 @@ export function buildClient(overrides: Partial<ClientRow> = {}): ClientRow {
address: "1 Main St, Springfield, CA 90000",
notes: null,
emailOptOut: false,
smsOptIn: false,
smsConsentDate: null,
smsOptOutDate: null,
smsConsentText: null,
stripeCustomerId: null,
status: "active",
disabledAt: null,
createdAt: new Date("2025-01-01T00:00:00Z"),
+7 -6
View File
@@ -110,10 +110,7 @@ export const clients = pgTable("clients", {
address: text("address"),
notes: text("notes"),
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"),
status: clientStatusEnum("status").notNull().default("active"),
disabledAt: timestamp("disabled_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
@@ -254,6 +251,9 @@ 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,6 +262,7 @@ 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),
]
);
@@ -308,11 +309,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.channel)]
(t) => [unique().on(t.appointmentId, t.reminderType)]
);
// ─── Impersonation ──────────────────────────────────────────────────────────
-35
View File
@@ -43,9 +43,6 @@ 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
@@ -2115,9 +2112,6 @@ 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==}
@@ -3099,9 +3093,6 @@ 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==}
@@ -4109,9 +4100,6 @@ 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==}
@@ -4200,14 +4188,6 @@ 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'}
@@ -6730,8 +6710,6 @@ snapshots:
dependencies:
tslib: 2.8.1
'@stablelib/base64@1.0.1': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
@@ -7777,8 +7755,6 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-sha256@1.3.0: {}
fast-uri@3.1.0: {}
fast-xml-builder@1.1.4:
@@ -8780,11 +8756,6 @@ 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:
@@ -8887,12 +8858,6 @@ 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:
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB