Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d407b895be | |||
| 53ab415713 | |||
| a330e342e1 | |||
| 0f841e27fc | |||
| a7bcce8b80 | |||
| 5f1582a3b6 | |||
| c76ea93c29 | |||
| cd25d98384 | |||
| e9fceb78b3 | |||
| 0cae8adef8 | |||
| 674626ba1e | |||
| aa5686bed1 | |||
| 903fbf55d5 | |||
| 775e2e544b | |||
| fb9c922182 | |||
| 1cc48f0b88 | |||
| 1b8d7087c0 | |||
| d65d121a5d | |||
| b8fd7ec18f | |||
| 7bf9cf9734 | |||
| bf159f8b1f | |||
| 2f3d4d8d01 | |||
| db9bb31702 | |||
| b38db65dde | |||
| 3178f81b99 | |||
| 544d65959d | |||
| f38bb244a4 | |||
| abee344ca4 |
@@ -340,7 +340,7 @@ jobs:
|
||||
name: Update Infra Image Tags
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -1,218 +1,43 @@
|
||||
# GroomBook
|
||||
# GroomBook Monorepo — Archived
|
||||
|
||||
> **The open-source scheduling and client management platform built specifically for independent pet groomers** — giving you the tools of enterprise software without the enterprise price tag or vendor lock-in.
|
||||
> **This repository has been archived and replaced by standalone repositories.**
|
||||
|
||||
**Built for groomers, not corporations.**
|
||||
## Successor Repositories
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
**Stop chasing confirmations**
|
||||
- **Customer portal** — Clients confirm or cancel appointments on their own. Reduce no-shows with an automated waitlist.
|
||||
|
||||
**Your calendar, your way**
|
||||
- **iCal calendar feed** — Push GroomBook appointments directly into Google Calendar or Apple Calendar. No app switching.
|
||||
|
||||
**Know every pet at a glance**
|
||||
- **Client & pet records** — Detailed profiles with grooming history, preferences, and breed-specific notes. Full appointment notes for context on every regular.
|
||||
- **Quick-find search** — Find clients and pets instantly without digging through spreadsheets.
|
||||
|
||||
**Staff access without stress**
|
||||
- **Role-based access control (RBAC)** — Front desk sees bookings; only you see financials. Right access for every role.
|
||||
|
||||
**Everything else**
|
||||
- **Appointment scheduling** — Calendar management for single or multiple groomers
|
||||
- **Service management** — Pricing, duration, and service catalog
|
||||
- **POS & invoicing** — Payments, tips, and receipt generation
|
||||
- **Automated reminders** — SMS and email notifications
|
||||
- **Reporting dashboard** — Revenue, utilization, and trend analytics
|
||||
- **Staff impersonation** — Managers can view the customer portal as any client, with full audit logging and session controls
|
||||
- **PWA** — Installable on mobile devices, works offline
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Try the Demo
|
||||
|
||||
[**Live Demo**](https://demo.groombook.app) — explore GroomBook without installing anything.
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker Compose (recommended for indie groomers)
|
||||
|
||||
Run GroomBook on your own hardware in minutes. Everything you need is in the box — no subscription, no vendor lock-in.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/groombook/groombook.git
|
||||
cd groombook
|
||||
|
||||
# Start everything (Postgres + database migrations + API + web UI)
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
- **Web UI**: http://localhost:8080
|
||||
- **API**: http://localhost:3000
|
||||
|
||||
The default `docker-compose.yml` sets `AUTH_DISABLED=true` so you can explore the app without configuring an OIDC provider. **Important:** Disable this in any internet-facing deployment.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| Repository | Description |
|
||||
|---|---|
|
||||
| Backend | [Hono](https://hono.dev/) (TypeScript, Node.js) |
|
||||
| Frontend | React 19 + Vite + [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) |
|
||||
| Database | PostgreSQL via [CNPG](https://cloudnative-pg.io/) + [Drizzle ORM](https://orm.drizzle.team/) |
|
||||
| Auth | OIDC via [Authentik](https://goauthentik.io/) |
|
||||
| Infra | Kubernetes (namespace: `groombook`), Flux GitOps |
|
||||
| CI | GitHub Actions (self-hosted `groombook-runners`) |
|
||||
| [groombook/api](https://github.com/groombook/api) | Hono REST API (TypeScript, Node.js) |
|
||||
| [groombook/web](https://github.com/groombook/web) | React PWA frontend |
|
||||
| [groombook/charts](https://github.com/groombook/charts) | Helm charts for Kubernetes deployment |
|
||||
|
||||
## Repository Structure
|
||||
## What Changed
|
||||
|
||||
```
|
||||
groombook/
|
||||
├── apps/
|
||||
│ ├── api/ # Hono REST API
|
||||
│ └── web/ # React PWA
|
||||
├── packages/
|
||||
│ ├── db/ # Drizzle schema + migrations
|
||||
│ └── types/ # Shared TypeScript types
|
||||
├── .github/
|
||||
│ └── workflows/ # CI/CD pipelines
|
||||
└── docker-compose.yml
|
||||
```
|
||||
- **Monorepo split complete** — The former `apps/api`, `apps/web`, and `packages/*` are now standalone repos
|
||||
- **`@groombook/types`** — Inlined directly into `groombook/api` and `groombook/web`
|
||||
- **E2E testing** — Now via Playwright MCP, no standalone repo needed
|
||||
- **CI/CD** — Each repo has its own pipeline; see individual repos for status
|
||||
|
||||
## Getting Started
|
||||
## Migration Notes
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 20
|
||||
- pnpm >= 9 (`npm install -g pnpm`)
|
||||
- Docker & Docker Compose (for local Postgres)
|
||||
|
||||
### Local Development
|
||||
If you were cloning `groombook/groombook` for local development:
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/groombook/groombook.git
|
||||
cd groombook
|
||||
# API
|
||||
git clone https://github.com/groombook/api.git
|
||||
cd api && pnpm install && pnpm dev
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start local Postgres
|
||||
docker compose up postgres -d
|
||||
|
||||
# Run database migrations
|
||||
DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook pnpm db:migrate
|
||||
|
||||
# Start API and Web in parallel
|
||||
pnpm dev
|
||||
# Web (in a new terminal)
|
||||
git clone https://github.com/groombook/web.git
|
||||
cd web && pnpm install && pnpm dev
|
||||
```
|
||||
|
||||
API will be available at http://localhost:3000
|
||||
Web will be available at http://localhost:5173
|
||||
For full Docker Compose setup, see each repo's README.
|
||||
|
||||
### Environment Variables
|
||||
## Archive Info
|
||||
|
||||
#### API (`apps/api/.env`)
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgres://groombook:groombook@localhost:5432/groombook
|
||||
OIDC_ISSUER=https://authentik.example.com
|
||||
OIDC_AUDIENCE=groombook
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Unit tests (vitest)
|
||||
pnpm test
|
||||
|
||||
# E2E tests (Playwright) — requires the full Docker Compose stack to be running
|
||||
docker compose up -d --wait
|
||||
pnpm --filter @groombook/e2e test
|
||||
|
||||
# Open the Playwright UI (interactive test runner)
|
||||
pnpm --filter @groombook/e2e test:ui
|
||||
|
||||
# View the last E2E test report
|
||||
pnpm --filter @groombook/e2e test:report
|
||||
```
|
||||
|
||||
E2E tests target the Docker Compose stack (`http://localhost:8080`). They use API route mocking where needed so happy-path tests are deterministic without requiring seed data.
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
### Production Configuration
|
||||
|
||||
Copy `.env.example` to `.env` and configure:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Key variables to update for production:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `AUTH_DISABLED` | Set to `false` in production |
|
||||
| `OIDC_ISSUER` | Authentik issuer URL |
|
||||
| `OIDC_AUDIENCE` | OAuth2 audience (default: `groombook`) |
|
||||
| `CORS_ORIGIN` | Public URL of the web frontend |
|
||||
|
||||
To use your `.env` file with Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env up --build
|
||||
```
|
||||
|
||||
### Kubernetes (production-grade deployments)
|
||||
|
||||
See the [groombook/infra](https://github.com/groombook/infra) repository for Kubernetes manifests and Flux configuration.
|
||||
|
||||
Groom Book is deployed in the `groombook` Kubernetes namespace using:
|
||||
- **CNPG** for PostgreSQL
|
||||
- **Authentik** for OIDC authentication
|
||||
- **Flux** for GitOps-managed deployments
|
||||
This repository was archived on 2026-05-14 as part of the monorepo decommission ([GRO-1081]).
|
||||
The history is preserved but the repo is read-only.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
GroomBook thrives on contributions from the grooming community. Whether you're a groomer with a feature request, a developer fixing a bug, or someone improving docs — we'd love your help.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
||||
3. Commit your changes
|
||||
4. Open a pull request
|
||||
|
||||
All PRs require CI to pass before merge. See [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
|
||||
|
||||
---
|
||||
|
||||
## Why GroomBook?
|
||||
|
||||
- **Open source** — You own your data. No vendor lock-in.
|
||||
- **Purpose-built** — Features designed for grooming workflows, not generic scheduling.
|
||||
- **Self-hosted or managed** — Run it yourself for free, or pay for hosted support (coming soon).
|
||||
- **Community-driven** — Used and built by actual groomers.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
*For Kubernetes deployments, see [groombook/infra](https://github.com/groombook/infra) (private).*
|
||||
@@ -130,7 +130,17 @@ invoicesRouter.get("/:id", async (c) => {
|
||||
db.select().from(invoiceTipSplits).where(eq(invoiceTipSplits.invoiceId, id)),
|
||||
]);
|
||||
|
||||
return c.json({ ...invoice, lineItems, tipSplits });
|
||||
let cardLast4: string | null = null;
|
||||
let paymentStatus: string | null = null;
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const details = await getPaymentIntentDetails(invoice.stripePaymentIntentId);
|
||||
if (details) {
|
||||
cardLast4 = details.cardLast4;
|
||||
paymentStatus = details.paymentStatus;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ...invoice, lineItems, tipSplits, cardLast4, paymentStatus });
|
||||
});
|
||||
|
||||
// Save tip splits for an invoice (replaces existing splits)
|
||||
@@ -450,9 +460,6 @@ invoicesRouter.post(
|
||||
if (invoice.status !== "paid") {
|
||||
return c.json({ error: "Refund only allowed on paid invoices" }, 422);
|
||||
}
|
||||
if (!invoice.stripePaymentIntentId) {
|
||||
return c.json({ error: "No Stripe payment intent found for this invoice" }, 422);
|
||||
}
|
||||
|
||||
return await db.transaction(async (tx) => {
|
||||
if (body.idempotencyKey) {
|
||||
@@ -465,17 +472,25 @@ invoicesRouter.post(
|
||||
}
|
||||
}
|
||||
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
let refundId: string;
|
||||
|
||||
if (invoice.stripePaymentIntentId) {
|
||||
const result = await processRefund(id, body.amountCents);
|
||||
if (!result) return c.json({ error: "Refund failed" }, 500);
|
||||
refundId = result.refundId;
|
||||
} else {
|
||||
// Manual refund — no Stripe call needed
|
||||
refundId = `manual_${id}_${Date.now()}`;
|
||||
}
|
||||
|
||||
await tx.insert(refunds).values({
|
||||
invoiceId: id,
|
||||
stripeRefundId: result.refundId,
|
||||
stripeRefundId: refundId,
|
||||
idempotencyKey: body.idempotencyKey ?? null,
|
||||
amountCents: body.amountCents ?? null,
|
||||
});
|
||||
|
||||
return c.json({ refundId: result.refundId });
|
||||
return c.json({ refundId });
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -487,7 +487,7 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end" }}>
|
||||
{invoice.status === "paid" && invoice.stripePaymentIntentId && !invoice.stripeRefundId && isManager && (
|
||||
{invoice.status === "paid" && !invoice.stripeRefundId && isManager && (
|
||||
<button onClick={() => setShowRefundDialog(true)} style={{ ...btnStyle, color: "#fff", backgroundColor: "#7c3aed", borderColor: "#7c3aed" }}>
|
||||
Refund
|
||||
</button>
|
||||
@@ -530,6 +530,14 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||
setRefunding(true);
|
||||
setRefundError(null);
|
||||
try {
|
||||
if (refundType === "partial") {
|
||||
const parsed = parseFloat(refundAmount);
|
||||
if (isNaN(parsed) || parsed <= 0) {
|
||||
setRefundError("Please enter a valid amount greater than zero.");
|
||||
setRefunding(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const body = refundType === "partial" ? { amountCents: Math.round(parseFloat(refundAmount) * 100) } : {};
|
||||
const res = await fetch(`/api/invoices/${invoice.id}/refund`, {
|
||||
method: "POST",
|
||||
@@ -557,8 +565,7 @@ const [showRefundDialog, setShowRefundDialog] = useState(false);
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</Modal>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -326,7 +326,7 @@ export function CustomerPortal() {
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-h-screen overflow-x-hidden">
|
||||
<main className="flex-1 min-h-screen overflow-hidden">
|
||||
<div className="hidden md:flex items-center justify-between px-8 py-4 border-b border-stone-200 bg-white">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-stone-800">
|
||||
|
||||
@@ -130,7 +130,7 @@ function BillingPaymentsInner({ sessionId, readOnly }: BillingPaymentsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<div className="flex gap-2 flex-wrap overflow-x-auto">
|
||||
{([
|
||||
{ id: "invoices" as const, label: "Invoices", icon: DollarSign },
|
||||
{ id: "payment" as const, label: "Payment Methods", icon: CreditCard },
|
||||
|
||||
@@ -119,3 +119,10 @@ uri
|
||||
database-url
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Auth secret name — always use groombook-auth (sealed secret name)
|
||||
*/}}
|
||||
{{- define "groombook.authSecretName" -}}
|
||||
{{- printf "%s" "groombook-auth" }}
|
||||
{{- end }}
|
||||
|
||||
@@ -50,6 +50,27 @@ spec:
|
||||
- name: OIDC_AUDIENCE
|
||||
value: {{ .Values.api.env.oidcAudience | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.api.env.internalBaseUrl }}
|
||||
- name: OIDC_INTERNAL_BASE
|
||||
value: {{ .Values.api.env.internalBaseUrl | quote }}
|
||||
{{- end }}
|
||||
- name: BETTER_AUTH_URL
|
||||
value: {{ .Values.api.env.betterAuthUrl | quote }}
|
||||
- name: OIDC_CLIENT_ID
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "groombook.authSecretName" . }}
|
||||
key: OIDC_CLIENT_ID
|
||||
- name: OIDC_CLIENT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "groombook.authSecretName" . }}
|
||||
key: OIDC_CLIENT_SECRET
|
||||
- name: BETTER_AUTH_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "groombook.authSecretName" . }}
|
||||
key: BETTER_AUTH_SECRET
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -18,6 +18,8 @@ api:
|
||||
corsOrigin: ""
|
||||
oidcIssuer: ""
|
||||
oidcAudience: groombook
|
||||
betterAuthUrl: ""
|
||||
internalBaseUrl: ""
|
||||
port: "3000"
|
||||
service:
|
||||
type: ClusterIP
|
||||
|
||||
+10
-1
@@ -883,6 +883,7 @@ async function seed() {
|
||||
let appointmentCount = 0;
|
||||
let invoiceCount = 0;
|
||||
let visitLogCount = 0;
|
||||
let paidInvoiceCounter = 0;
|
||||
|
||||
// Process in batches per client to keep memory manageable
|
||||
const apptBatchSize = 100;
|
||||
@@ -977,6 +978,10 @@ async function seed() {
|
||||
|
||||
const invoiceStatus = rand() < 0.95 ? "paid" as const : "pending" as const;
|
||||
const paidAt = invoiceStatus === "paid" ? new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000) : null;
|
||||
paidInvoiceCounter++;
|
||||
const stripePaymentIntentId = invoiceStatus === "paid"
|
||||
? `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`
|
||||
: null;
|
||||
|
||||
invoiceBatch.push({
|
||||
id: invoiceId,
|
||||
@@ -989,6 +994,7 @@ async function seed() {
|
||||
status: invoiceStatus,
|
||||
paymentMethod: invoiceStatus === "paid" ? pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check" : null,
|
||||
paidAt,
|
||||
stripePaymentIntentId,
|
||||
notes: rand() < 0.05 ? "Added extra service at checkout" : null,
|
||||
});
|
||||
|
||||
@@ -1092,13 +1098,16 @@ async function seed() {
|
||||
const taxCents = Math.round(effectivePrice * 0.08);
|
||||
const totalCents = effectivePrice + taxCents + tipCents;
|
||||
const paidAt = new Date(endTime.getTime() + randInt(5, 30) * 60 * 1000);
|
||||
paidInvoiceCounter++;
|
||||
|
||||
invoiceBatch.push({
|
||||
id: invoiceId, appointmentId: apptId, clientId,
|
||||
subtotalCents: effectivePrice, taxCents, tipCents, totalCents,
|
||||
status: "paid" as const,
|
||||
paymentMethod: pick(["cash", "card", "card", "card", "check"]) as "cash" | "card" | "check",
|
||||
paidAt, notes: null,
|
||||
paidAt,
|
||||
stripePaymentIntentId: `pi_test_seed_${String(paidInvoiceCounter).padStart(6, "0")}`,
|
||||
notes: null,
|
||||
});
|
||||
lineItemBatch.push({
|
||||
id: uuid(), invoiceId, description: svc.name, quantity: 1,
|
||||
|
||||
Reference in New Issue
Block a user