Compare commits

..

1 Commits

Author SHA1 Message Date
Pawla Abdul b4fa641ddd chore: add AGENTS.md with engineer task handoff instructions
Engineers were not properly picking up and completing delegated tasks.
This adds structured instructions for Paperclip task lifecycle,
git workflow, and handoff expectations.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 16:34:07 +00:00
18 changed files with 116 additions and 227 deletions
-6
View File
@@ -11,12 +11,6 @@ AUTH_DISABLED=false
OIDC_ISSUER=https://authentik.example.com
OIDC_AUDIENCE=groombook
# ── Setup Wizard ─────────────────────────────────────────────────────────────
# When SKIP_OOBE=true, the setup wizard is bypassed regardless of whether a
# super user exists in the database. Useful in dev/test environments where the
# database has data but the setup wizard would otherwise block access.
SKIP_OOBE=false
# ── API ───────────────────────────────────────────────────────────────────────
PORT=3000
CORS_ORIGIN=http://localhost:8080
+43
View File
@@ -0,0 +1,43 @@
# GroomBook — Agent Instructions
## Repo Layout
- `apps/api` — NestJS backend API
- `apps/web` — React frontend (Vite)
- `apps/e2e` — Playwright E2E tests
- `apps/groombook` — CLI / meta package
- `packages/` — shared libraries
- `charts/` — Helm charts
- `infra/` — infrastructure config
## Development
- Package manager: `pnpm` (workspace monorepo)
- Run E2E tests: `pnpm --filter @groombook/e2e test`
- Run API: `pnpm --filter @groombook/api dev`
- Run web: `pnpm --filter @groombook/web dev`
## Git Workflow
- Branch from `main` using the pattern `fix/<issue-id>-<short-desc>` or `feat/<issue-id>-<short-desc>`
- Push to origin and open a PR against `main`
- If the issue specifies an existing PR/branch, push to that branch instead of creating a new one
- Commit messages: short imperative summary, reference the issue ID
## Task Handoff (Paperclip)
When you receive an assigned task from Paperclip:
1. **Checkout the task** immediately using `POST /api/issues/{issueId}/checkout`
2. **Read the full issue description** and any comments for context
3. **Do the work** — implement the fix/feature as described
4. **Test your changes** — run relevant tests, lint, type-check
5. **Commit and push** your changes to the appropriate branch
6. **Update the task** with status `in_review` and a comment summarizing what you did, including the commit SHA and PR link
7. **Never leave a task in `in_progress` without posting a comment** explaining current state before exiting your heartbeat
If you cannot complete the work (missing info, blocked by another task, environment issue):
- Set the task to `blocked` with a comment explaining what's blocking you
- Tag your manager in the comment if escalation is needed
Do NOT silently drop tasks. Every assigned task must get a status update and comment before your heartbeat ends.
-42
View File
@@ -418,48 +418,6 @@ describe("GET /setup/status — OOBE bootstrap logic", () => {
expect(body.showAuthProviderStep).toBe(false); // DB config already exists
expect(body.authConfigExists).toBe(true);
});
it("SKIP_OOBE=true bypasses setup check regardless of DB state", async () => {
dbStaffRows = []; // no super user
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "true";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.showAuthProviderStep).toBe(false);
expect(body.authConfigExists).toBe(false);
expect(body.authEnvVarsSet).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=1 also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "1";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
it("SKIP_OOBE=yes also bypasses setup check", async () => {
dbStaffRows = [];
dbAuthConfigRows = [];
process.env.SKIP_OOBE = "yes";
const app = makeApp();
const { status, body } = await getStatus(app);
expect(status).toBe(200);
expect(body.needsSetup).toBe(false);
expect(body.skipped).toBe(true);
});
});
describe("POST /setup/auth-provider — OOBE bootstrap", () => {
+1 -7
View File
@@ -105,13 +105,7 @@ api.use("*", resolveStaffMiddleware);
// Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes
// authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths
const authRouter = new Hono();
authRouter.all("/*", (c) => {
try {
return getAuth().handler(c.req.raw);
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
});
authRouter.all("/*", (c) => getAuth().handler(c.req.raw));
api.route("/auth", authRouter);
// ── Role guards ────────────────────────────────────────────────────────────────
+4 -15
View File
@@ -90,12 +90,6 @@ export async function initAuth(): Promise<void> {
database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "database",
},
plugins: [
genericOAuth({
config: [
@@ -176,6 +170,8 @@ export async function initAuth(): Promise<void> {
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
const callbackBase = `${BETTER_AUTH_URL}/api/auth/callback`;
// Build Better-Auth instance using resolved config
authInstance = betterAuth({
database: drizzleAdapter(db, {
@@ -183,15 +179,6 @@ export async function initAuth(): Promise<void> {
}),
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 10,
window: 60,
storage: "database",
},
account: {
storeStateStrategy: "cookie" as const,
},
plugins: [
genericOAuth({
config: [
@@ -218,12 +205,14 @@ export async function initAuth(): Promise<void> {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectURI: `${callbackBase}/google`,
},
} : {}),
...(hasGitHub ? {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectURI: `${callbackBase}/github`,
},
} : {}),
},
+2 -8
View File
@@ -23,6 +23,7 @@ if (process.env.AUTH_DISABLED === "true") {
}
export const authMiddleware: MiddlewareHandler = async (c, next) => {
// Better-Auth's own routes handle their own auth (OAuth callbacks, session mgmt)
if (c.req.path.startsWith("/api/auth/")) {
await next();
return;
@@ -36,14 +37,7 @@ export const authMiddleware: MiddlewareHandler = async (c, next) => {
return;
}
let auth;
try {
auth = getAuth();
} catch {
return c.json({ error: "Authentication not configured" }, 503);
}
const session = await auth.api.getSession({
const session = await getAuth().api.getSession({
headers: c.req.raw.headers,
});
+20 -1
View File
@@ -1,5 +1,5 @@
import type { MiddlewareHandler } from "hono";
import { eq, getDb, staff } from "@groombook/db";
import { and, eq, getDb, isNull, staff } from "@groombook/db";
export type StaffRole = "groomer" | "receptionist" | "manager";
export type StaffRow = typeof staff.$inferSelect;
@@ -90,6 +90,25 @@ export const resolveStaffMiddleware: MiddlewareHandler<AppEnv> = async (
.from(staff)
.where(eq(staff.oidcSub, jwt.sub));
if (!fallbackRow) {
// Auto-link: staff record exists with matching email but no userId — link it now
if (jwt.email) {
const [linkedStaff] = await db
.select()
.from(staff)
.where(and(eq(staff.email, jwt.email), isNull(staff.userId)));
if (linkedStaff) {
await db
.update(staff)
.set({ userId: jwt.sub })
.where(eq(staff.id, linkedStaff.id));
console.log(
`[rbac] Auto-linked staff ${linkedStaff.id} to Better-Auth user ${jwt.sub} via email ${jwt.email}`
);
c.set("staff", linkedStaff);
await next();
return;
}
}
return c.json(
{ error: "Forbidden: no staff record found for authenticated user" },
403
-11
View File
@@ -9,17 +9,6 @@ export const setupRouter = new Hono<AppEnv>();
// 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) => {
const skipOobe = ["true", "1", "yes"].includes((process.env.SKIP_OOBE || "").toLowerCase());
if (skipOobe) {
return c.json({
needsSetup: false,
showAuthProviderStep: false,
authConfigExists: false,
authEnvVarsSet: false,
skipped: true,
});
}
const db = getDb();
// Check if any super user exists
-30
View File
@@ -18,10 +18,6 @@ const createStaffSchema = z.object({
const updateStaffSchema = createStaffSchema.partial().omit({ email: true });
const linkUserSchema = z.object({
userId: z.string().min(1),
});
staffRouter.get("/me", async (c) => {
const staffRow = c.get("staff");
return c.json(staffRow);
@@ -110,32 +106,6 @@ staffRouter.patch("/:id", zValidator("json", updateStaffSchema), async (c) => {
return c.json(row);
});
staffRouter.patch("/:id/link-user", zValidator("json", linkUserSchema), async (c) => {
const db = getDb();
const targetId = c.req.param("id");
const body = c.req.valid("json");
const currentStaff = c.get("staff");
if (currentStaff.role !== "manager" && !currentStaff.isSuperUser) {
return c.json({ error: "Forbidden: only managers or super users can link staff to users" }, 403);
}
const [existing] = await db
.select()
.from(staff)
.where(eq(staff.id, targetId))
.limit(1);
if (!existing) return c.json({ error: "Not found" }, 404);
const [updated] = await db
.update(staff)
.set({ userId: body.userId, updatedAt: new Date() })
.where(eq(staff.id, targetId))
.returning();
return c.json(updated);
});
staffRouter.delete("/:id", async (c) => {
const db = getDb();
const id = c.req.param("id");
+1 -5
View File
@@ -44,10 +44,7 @@ test.beforeEach(async ({ page }) => {
json: { newClients: [], activeInPeriodCount: 0, churnRisk: [], churnRiskTotal: 0 },
});
}
if (url.includes("/api/invoices")) {
return route.fulfill({ json: { data: [], total: 0 } });
}
// Appointments, clients, services, staff, book, etc.
// Appointments, clients, services, staff, invoices, book, etc.
return route.fulfill({ json: [] });
});
});
@@ -85,7 +82,6 @@ test("admin staff page loads", async ({ page }) => {
test("admin invoices page loads", async ({ page }) => {
await page.goto("/admin/invoices");
await page.waitForLoadState("domcontentloaded");
await expect(page.getByText("GroomBook")).toBeVisible();
await expect(page.getByRole("link", { name: "Invoices" })).toBeVisible();
});
@@ -3,13 +3,13 @@ kind: Kustomization
namespace: groombook-uat
images:
- name: ghcr.io/groombook/api
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/web
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/migrate
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
- name: ghcr.io/groombook/seed
newTag: "2026.04.12-15131b7"
newTag: "2026.04.03-90be1be"
resources:
- ../../base
- postgres-sealed-secret.yaml
+1 -1
View File
@@ -15,7 +15,7 @@
"dependencies": {
"@groombook/types": "workspace:*",
"@tailwindcss/vite": "^4.2.2",
"better-auth": "^1.5.6",
"better-auth": "^1.0.0",
"lucide-react": "^0.577.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+36 -80
View File
@@ -1,4 +1,4 @@
import { Routes, Route, Link, useLocation, Navigate, useNavigate } from "react-router-dom";
import { Routes, Route, Link, useLocation, Navigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { AppointmentsPage } from "./pages/Appointments.js";
import { ClientsPage } from "./pages/Clients.js";
@@ -18,31 +18,22 @@ import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js";
import { DevSessionIndicator } from "./components/DevSessionIndicator.js";
import { BrandingProvider, useBranding } from "./BrandingContext.js";
import { GlobalSearch } from "./components/GlobalSearch.js";
import { useSession, signIn, signOut } from "./lib/auth-client.js";
import { useSession, signIn } from "./lib/auth-client.js";
function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const [providers, setProviders] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch("/api/auth/providers")
.then((r) => r.json())
.then((data) => setProviders(data.providers ?? []))
.catch(() => setProviders([]));
const params = new URLSearchParams(window.location.search);
const authError = params.get("error");
if (authError) setError(authError.replace(/_/g, " "));
}, []);
const handleSocialLogin = async (provider: string) => {
setIsLoading(true);
setError(null);
const result = await signIn.social({ provider, callbackURL: window.location.origin });
if (result?.error) {
setError(result.error.message ?? "Sign-in failed");
setIsLoading(false);
}
await signIn.social({ provider, callbackURL: window.location.origin });
};
const isGoogle = providers.includes("google");
@@ -74,11 +65,6 @@ function LoginPage() {
<p style={{ color: "#6b7280", marginBottom: "1.5rem", fontSize: 14 }}>
Sign in to continue
</p>
{error && (
<div style={{ background: "#fef2f2", border: "1px solid #fecaca", borderRadius: 6, padding: "0.5rem 0.75rem", marginBottom: "1rem", color: "#991b1b", fontSize: 13 }}>
{error}
</div>
)}
{isGoogle && (
<button
onClick={() => handleSocialLogin("google")}
@@ -181,7 +167,6 @@ const NAV_LINKS = [
function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const { branding } = useBranding();
const logoSrc = branding.logoBase64 && branding.logoMimeType
@@ -210,7 +195,6 @@ function AdminLayout() {
alignItems: "center",
gap: 8,
marginRight: "1.25rem",
flexShrink: 0,
}}>
{logoSrc && (
<img src={logoSrc} alt="" style={{ width: 24, height: 24, objectFit: "contain" }} />
@@ -224,73 +208,45 @@ function AdminLayout() {
</strong>
</div>
<GlobalSearch />
<div style={{
display: "flex",
overflowX: "auto",
flex: 1,
minWidth: 0,
gap: "0.25rem",
}}>
<Link
to="/admin/book"
style={{
padding: "0.4rem 0.85rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: 600,
color: "#fff",
background: branding.primaryColor,
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
flexShrink: 0,
}}
>
Book
</Link>
{NAV_LINKS.map(({ to, label }) => {
const active =
to === "/admin"
? location.pathname === "/admin"
: location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
style={{
padding: "0.4rem 0.75rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? "#2d6a4f" : "#4b5563",
background: active ? "#ecfdf5" : "transparent",
flexShrink: 0,
}}
>
{label}
</Link>
);
})}
</div>
<button
onClick={async () => {
await signOut();
navigate("/login");
}}
<Link
to="/admin/book"
style={{
flexShrink: 0,
padding: "0.4rem 0.85rem",
borderRadius: 6,
border: "1px solid #e2e8f0",
background: "#fff",
color: "#4b5563",
textDecoration: "none",
fontSize: 13,
fontWeight: 500,
cursor: "pointer",
fontWeight: 600,
color: "#fff",
background: branding.primaryColor,
marginRight: "0.5rem",
boxShadow: "0 1px 2px rgba(79, 138, 111, 0.3)",
}}
>
Logout
</button>
Book
</Link>
{NAV_LINKS.map(({ to, label }) => {
const active =
to === "/admin"
? location.pathname === "/admin"
: location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
style={{
padding: "0.4rem 0.75rem",
borderRadius: 6,
textDecoration: "none",
fontSize: 13,
fontWeight: active ? 600 : 500,
color: active ? "#2d6a4f" : "#4b5563",
background: active ? "#ecfdf5" : "transparent",
}}
>
{label}
</Link>
);
})}
</nav>
<main style={{ padding: "1.25rem 1.5rem" }}>
<Routes>
+2 -2
View File
@@ -41,11 +41,11 @@ export default defineConfig({
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallbackDenylist: [
/^\/api\/auth\//,
/^\/api\/auth\/oauth2\/callback\//,
],
runtimeCaching: [
{
urlPattern: /^http.*\/api\/(?!auth\/).*/i,
urlPattern: /^http.*\/api\/.*/i,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
+1 -1
Submodule infra updated: d6c0d13d02...49575eb4f6
@@ -1,6 +0,0 @@
-- Better-Auth rate limiting table (GRO-574)
CREATE TABLE "rate_limit" (
key TEXT NOT NULL PRIMARY KEY,
count INTEGER NOT NULL,
last_request BIGINT NOT NULL
);
@@ -176,13 +176,6 @@
"when": 1775396067192,
"tag": "0024_invoice_indexes",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1775482467192,
"tag": "0025_rate_limit",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -87,7 +87,7 @@ importers:
specifier: ^4.2.2
version: 4.2.2(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
better-auth:
specifier: ^1.5.6
specifier: ^1.0.0
version: 1.5.6(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0))
lucide-react:
specifier: ^0.577.0