Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/commands/boot.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ There is no cost to re-running this. The cost of acting on stale disciplines is
## Step 3: Health check

- Run `npx tsc --noEmit` — if types fail, note errors.
- Run `npm run lint` — if lint fails, note errors.
- Run `npx vitest run` — if tests fail, note failures.
- Report any issues to the user before proceeding.

Expand Down Expand Up @@ -61,6 +62,7 @@ These are the checks the framework expects to run automatically. If you skip the
**Before every commit (the pre-commit checklist from FRAMEWORK.md):**
- Tests written for money/security/data/public-API/math?
- `tsc --noEmit` passes
- `npm run lint` passes
- `vitest run` passes
- `npm run build` passes (if Next.js routing/layout/server components changed)
- Comments audited — none restating the obvious
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ jobs:
- name: Type check
run: npx tsc --noEmit

- name: Lint
run: npm run lint

- name: Unit tests
run: npx vitest run --coverage

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,14 +176,14 @@ This is a combined RMS (Resource Management System) backend + marketing site for
- **Uploads aren't backed up:** `db-backup.yml` dumps Postgres only. The `uploads_data` volume (writable only since the Dockerfile `chown` fix) has no backup, so restoring a dump leaves `File` rows pointing at missing files.
- **Root-level CMS pages sit behind login:** `proxy.ts` only exempts listed prefixes, so a published `CmsPage` served by `app/[slug]` redirects anonymous visitors to `/login`. Latent — no CMS pages are published.
- **Prod DB password is the public dev default:** `docker-compose.yml` hardcodes `simplerms_dev` for dev *and* prod, and this repo is public. Mitigated by loopback-only port binding plus the cloud security group (only 22/80/443 reachable). Rotate it (move `DATABASE_URL` into the server's `.env.local`) if this box outlives the Holt migration.
- **Lint isn't clean or gated:** `npm run lint` reports 20 errors (internal `<a>` instead of `<Link>`, JSX built inside try/catch, setState inside effects). It crashed outright until the October 2026 ESLint bump, and CI doesn't run it. Fix the errors, then add it to the `test` job.
- **Held majors:** TypeScript stays on 6 (typescript-eslint doesn't support 7.0; support is tracked for 7.1). Stripe stays on SDK 22 and the `dahlia` API. Moving to 23 / `endive` means changing the pinned `apiVersion` in `server/stripe/index.ts` and the webhook route, plus re-testing payment links and webhooks in Stripe test mode. Dependabot ignores both majors.
- **nodemailer forced via `overrides`:** `"nodemailer": "$nodemailer"` forces the whole tree to our direct nodemailer (now 10). next-auth's `@auth/core` declares an **optional** `nodemailer@^7` peer used only by its Email provider, which we don't use (Credentials-only in `server/auth/index.ts`) — runtime-safe, no `--legacy-peer-deps`. Side effect: `npm audit fix` and `npm update` crash on the `$` reference — raise the override floors in `package.json` by hand instead.

### Testing & Deployment

- Tests: `npx vitest run` (invoice tax + utils, must pass before commit)
- Type check: `npx tsc --noEmit` (must pass before commit)
- Lint: `npm run lint` (zero warnings; must pass before commit, gated in CI)
- Deploy: SSH to VPS, `cd /opt/simplerms && git pull && bash deploy.sh`
- Docker: app + db + migrator + mailpit services
- Blog posts seed automatically via `deploy.sh` — it loops over `prisma/seed-blog-*.ts` after every deploy. All blog seeds use upsert with pinned `publishedAt` dates, so this is idempotent — no drift, picks up new posts and edits without manual steps
Expand Down
8 changes: 2 additions & 6 deletions app/(dashboard)/dashboard/crm/clients/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ export default async function ClientDetailPage({ params }: Props) {
const { id } = await params;
const caller = await createCachedCaller();

try {
const client = await caller.crm.clientById(id);
return <ClientDetail initialData={client} />;
} catch {
notFound();
}
const client = await caller.crm.clientById(id).catch(() => notFound());
return <ClientDetail initialData={client} />;
}
52 changes: 24 additions & 28 deletions app/(dashboard)/dashboard/invoices/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,36 +13,32 @@ export default async function EditInvoicePage({ params }: Props) {
const { id } = await params;
const caller = await createCachedCaller();

try {
const invoice = await caller.invoices.byId(id);
const invoice = await caller.invoices.byId(id).catch(() => notFound());

if (invoice.status !== "DRAFT") {
redirect(`/dashboard/invoices/${id}`);
}
if (invoice.status !== "DRAFT") {
redirect(`/dashboard/invoices/${id}`);
}

const [clients, taxRates] = await Promise.all([
caller.crm.listClients({ page: 1, limit: 100 }),
caller.tax.list({ includeInactive: false }),
]);
const [clients, taxRates] = await Promise.all([
caller.crm.listClients({ page: 1, limit: 100 }),
caller.tax.list({ includeInactive: false }),
]);

return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Edit {invoice.invoiceNumber}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Only draft invoices can be edited.
</p>
</div>
<InvoiceEditForm
invoice={invoice}
clients={clients.items}
taxRates={taxRates}
/>
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Edit {invoice.invoiceNumber}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Only draft invoices can be edited.
</p>
</div>
);
} catch {
notFound();
}
<InvoiceEditForm
invoice={invoice}
clients={clients.items}
taxRates={taxRates}
/>
</div>
);
}
8 changes: 2 additions & 6 deletions app/(dashboard)/dashboard/invoices/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ export default async function InvoiceDetailPage({ params }: Props) {
const { id } = await params;
const caller = await createCachedCaller();

try {
const invoice = await caller.invoices.byId(id);
return <InvoiceDetail initialData={invoice} />;
} catch {
notFound();
}
const invoice = await caller.invoices.byId(id).catch(() => notFound());
return <InvoiceDetail initialData={invoice} />;
}
2 changes: 1 addition & 1 deletion app/(dashboard)/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ export default async function DashboardPage() {
{stats.todayAppointments.length > 0 && (
<div className="rounded-xl border">
<div className="flex items-center justify-between border-b px-5 py-3">
<h2 className="text-sm font-semibold">Today's appointments</h2>
<h2 className="text-sm font-semibold">Today&apos;s appointments</h2>
<Link href="/dashboard/scheduling" className="text-xs text-muted-foreground hover:text-foreground">View all</Link>
</div>
<ul className="divide-y">
Expand Down
3 changes: 3 additions & 0 deletions app/(dashboard)/dashboard/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,20 +27,23 @@ export default async function ReportsPage() {
<div className="flex items-center gap-2">
<a
href="/api/reports/export?type=invoices"
download
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
>
<Download className="h-3.5 w-3.5" />
Invoices
</a>
<a
href="/api/reports/export?type=clients"
download
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
>
<Download className="h-3.5 w-3.5" />
Clients
</a>
<a
href="/api/reports/export?type=time"
download
className="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted"
>
<Download className="h-3.5 w-3.5" />
Expand Down
14 changes: 5 additions & 9 deletions app/(dashboard)/dashboard/tickets/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,9 @@ export default async function TicketDetailPage({ params }: Props) {
const { id } = await params;
const caller = await createCachedCaller();

try {
const [ticket, users] = await Promise.all([
caller.tickets.byId(id),
caller.users.list(),
]);
return <TicketDetail initialData={ticket} staffUsers={users} />;
} catch {
notFound();
}
const [ticket, users] = await Promise.all([
caller.tickets.byId(id),
caller.users.list(),
]).catch(() => notFound());
return <TicketDetail initialData={ticket} staffUsers={users} />;
}
2 changes: 1 addition & 1 deletion app/api/files/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export async function POST(request: NextRequest) {
}

const buffer = Buffer.from(await file.arrayBuffer());
const { filename, storagePath, sizeBytes } = await saveFile(buffer, file.name, file.type);
const { filename, storagePath, sizeBytes } = await saveFile(buffer, file.name);

// Optional entity associations from query params.
const clientId = request.nextUrl.searchParams.get("clientId");
Expand Down
5 changes: 3 additions & 2 deletions app/contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
export const dynamic = "force-dynamic";

import type { Metadata } from "next";
import Link from "next/link";
import { SiteNav } from "@/components/site/site-nav";
import { SiteFooter } from "@/components/site/site-footer";
import { SupportForm } from "@/components/site/support-form";
Expand Down Expand Up @@ -114,12 +115,12 @@ export default async function ContactPage() {
Not sure what you need? Book a free 30-minute call. I&apos;ll
tell you what I see, what it would cost, and whether
I&apos;m the right fit.{" "}
<a
<Link
href="/book"
className="text-conviction underline underline-offset-2"
>
Book now →
</a>
</Link>
</p>
</div>
</div>
Expand Down
5 changes: 3 additions & 2 deletions app/support/track/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// app/support/track/page.tsx
export const dynamic = "force-dynamic";

import Link from "next/link";
import { db } from "@/server/db";
import { notFound } from "next/navigation";
import { SiteNav } from "@/components/site/site-nav";
Expand Down Expand Up @@ -117,9 +118,9 @@ export default async function TrackPage({ searchParams }: Props) {
>
<p className="text-sm text-bone/40">
This ticket has been {ticket.status.toLowerCase()}. Need more help?{" "}
<a href="/support" className="text-conviction underline">
<Link href="/support" className="text-conviction underline">
Submit a new ticket
</a>
</Link>
.
</p>
</div>
Expand Down
5 changes: 3 additions & 2 deletions app/terms/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// app/terms/page.tsx
import type { Metadata } from "next";
import Link from "next/link";
import { SiteNav } from "@/components/site/site-nav";
import { SiteFooter } from "@/components/site/site-footer";

Expand Down Expand Up @@ -65,12 +66,12 @@ export default function TermsPage() {

<Section title="Your data">
See our{" "}
<a
<Link
href="/privacy"
className="text-conviction underline underline-offset-2"
>
Privacy Policy
</a>{" "}
</Link>{" "}
for how we handle your information.
</Section>

Expand Down
2 changes: 1 addition & 1 deletion components/reports/reports-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export function ReportsDashboard({ revenue, aging, topClients, ticketStats }: Pr
<p className="text-sm text-muted-foreground">No paid invoices yet.</p>
) : (
<div className="space-y-3">
{topClients.map((tc, i) => {
{topClients.map((tc) => {
const pct = topClients[0].revenue > 0 ? (tc.revenue / topClients[0].revenue) * 100 : 0;
return (
<div key={tc.client.id} className="space-y-1">
Expand Down
48 changes: 28 additions & 20 deletions components/scheduling/availability-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// components/scheduling/availability-editor.tsx
"use client";

import { useEffect, useState } from "react";
import { useState } from "react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand All @@ -20,43 +20,51 @@ interface DayRow {

const CLOSED: DayRow = { enabled: false, startTime: "09:00", endTime: "17:00" };

type SavedWindow = { dayOfWeek: number; startTime: string; endTime: string };

function toRows(saved: SavedWindow[] | undefined): DayRow[] {
const rows = DAYS.map(() => ({ ...CLOSED }));
for (const a of saved ?? []) {
if (a.dayOfWeek >= 0 && a.dayOfWeek <= 6) {
rows[a.dayOfWeek] = { enabled: true, startTime: a.startTime, endTime: a.endTime };
}
}
return rows;
}

export function AvailabilityEditor() {
const services = trpc.scheduling.listServices.useQuery({ publicOnly: false });
const [serviceId, setServiceId] = useState("");
const [pickedServiceId, setPickedServiceId] = useState("");

// Availability is per-service, and availableSlots unions service-scoped rows
// with global ones — so the editor always targets one explicit service rather
// than writing "global" hours that would silently add slots on top.
useEffect(() => {
const first = services.data?.[0];
if (!serviceId && first) setServiceId(first.id);
}, [services.data, serviceId]);
const serviceId = pickedServiceId || services.data?.[0]?.id || "";

const availability = trpc.scheduling.getAvailability.useQuery(
{ serviceId: serviceId || undefined },
{ enabled: serviceId.length > 0 }
);

const [rows, setRows] = useState<DayRow[]>(() => DAYS.map(() => ({ ...CLOSED })));

useEffect(() => {
if (!availability.data) return;
const next = DAYS.map(() => ({ ...CLOSED }));
for (const a of availability.data) {
if (a.dayOfWeek >= 0 && a.dayOfWeek <= 6) {
next[a.dayOfWeek] = { enabled: true, startTime: a.startTime, endTime: a.endTime };
}
}
setRows(next);
}, [availability.data]);
// A draft remembers which fetch it was edited from, so a refetch (after
// saving or switching service) shows the server's hours again.
const [draft, setDraft] = useState<{
source: SavedWindow[] | undefined;
rows: DayRow[];
} | null>(null);
const rows =
draft && draft.source === availability.data ? draft.rows : toRows(availability.data);

const utils = trpc.useUtils();
const save = trpc.scheduling.setAvailability.useMutation({
onSuccess: () => utils.scheduling.getAvailability.invalidate(),
});

function update(index: number, patch: Partial<DayRow>) {
setRows((rs) => rs.map((r, i) => (i === index ? { ...r, ...patch } : r)));
setDraft({
source: availability.data,
rows: rows.map((r, i) => (i === index ? { ...r, ...patch } : r)),
});
}

// Lexical compare is safe on zero-padded HH:MM and catches the end-before-start
Expand All @@ -81,7 +89,7 @@ export function AvailabilityEditor() {
<select
aria-label="Service"
value={serviceId}
onChange={(e) => setServiceId(e.target.value)}
onChange={(e) => setPickedServiceId(e.target.value)}
className="rounded-md border bg-background px-2 py-1 text-sm"
>
{services.data.map((s) => (
Expand Down
5 changes: 3 additions & 2 deletions components/site/consent-analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// dispatches `akritos:open-consent`. After any decision we emit
// `akritos:consent-changed` so that link can reveal itself without a reload.

import Link from "next/link";
import Script from "next/script";
import { useEffect, useState } from "react";

Expand Down Expand Up @@ -88,9 +89,9 @@ function ConsentBanner({ onAccept, onDecline }: { onAccept: () => void; onDeclin
We&apos;d like to use Google Analytics to see which pages are useful. It sets
cookies. You can decline — the site works exactly the same, and we won&apos;t
load anything until you choose. See our{" "}
<a href="/privacy" className="text-conviction underline underline-offset-2">
<Link href="/privacy" className="text-conviction underline underline-offset-2">
privacy policy
</a>
</Link>
.
</p>
{/* Equal-weight choices — no dark pattern. */}
Expand Down
Loading
Loading