Imported from xeptor6569/marotto-solutions (
AGENTS.md). Install upstream withnpx skills add xeptor6569/marotto-solutions. Copyright stays with the author.
AGENTS.md
Commands
npm run dev— dev servernpm run build— production buildnpm run lint— ESLint (useseslint-config-nextwith core-web-vitals + typescript)npm test/npm run test:watch— Vitest (node environment,@/path alias)npm run prisma:migrate:dev— local DB migrationnpm run prisma:migrate:deploy— apply pending migrations in prodnpm run prisma:generate— regenerate Prisma client (also runs onpostinstall)
Run order for verification: lint → test → build.
Setup
cp env.example .env— fillDATABASE_URL,NEXTAUTH_SECRET,NEXTAUTH_URL, SMTPdocker compose up -d postgres— Postgres on host port 5433 (not 5432)npm run prisma:migrate:devnpm run dev- Create the first admin via the in-app wizard at
/setup, or scripted:ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='…' node scripts/seed-admin.js(Deploy workflows also run the script whenADMIN_EMAIL/ADMIN_PASSWORDsecrets are set. Signed-in admins can set or change their password under Settings → Account.)
Port alignment: APP_PORT must match NEXTAUTH_URL port or auth redirects break.
Build note: next build requires EMAIL_SERVER to be set (Auth.js nodemailer provider is constructed at build time). Any syntactically valid value works, e.g. EMAIL_SERVER=smtp://localhost:1025 npm run build.
Architecture
Single Next.js 16 app (App Router, React 19, React Compiler enabled). Not a monorepo.
Persistence split
| Data | Storage | Key files |
|---|---|---|
| Auth, Clients, Jobs, Contracts, CalendarEvents, DocumentCounter | PostgreSQL via Prisma | prisma/schema.prisma, src/lib/prisma.ts |
| Invoices, Estimates, Quotes, Receipts, Leads | JSON files (local data/ or remote WebDAV) |
src/lib/data.ts, src/lib/webdav.ts |
| App settings (incl. business profile, branding, public site) | data/config/settings.json |
src/lib/config.ts |
| Uploaded logo | data/branding/ (served via /api/branding/logo) |
src/app/admin/settings/actions.ts |
This hybrid means: document CRUD goes through src/lib/data.ts (filesystem/WebDAV), not Prisma. DB records are only the models in the Prisma schema.
White-label branding
The app is fully white-label: business identity, theme, letterhead, and public-site content are configuration, never hardcoded strings.
src/lib/branding.tsis the single accessor (getBranding()); components read brand values through it or receive them as props from a server component that didsrc/lib/theme-presets.ts— theme presets + Radix color validation; per-visitor light/dark lives in anappearancecookie (src/lib/appearance.ts), SSR-applied as a class on<html>withTheme appearance="inherit"src/lib/legacy-defaults.ts— migration-only: a settings file without abusinesssection (pre-white-label install) is seeded with the original Marotto values at read time; fresh installs get neutral defaults- Printable documents pin a nested
<Theme appearance="light">— paper is always light regardless of screen theme; document colors flow from--doc-*CSS vars
Path alias
@/* maps to ./src/* (configured in both tsconfig.json and vitest.config.ts).
Key source map
src/app/actions.ts— core server actions for documentssrc/app/admin/settings/actions.ts— sectioned settings saves (business, appearance/logo, public site, billing, documents, storage)src/components/settings/— tabbed settings UI (shared by/admin/settings;/settingsredirects there)src/app/admin/layout.tsx— admin shell wrappersrc/components/AdminShell.tsx— shared admin nav (grouped sidebar + mobile bottom bar)src/components/NewInvoiceForm.tsx— shared document editor for all doc typessrc/components/DocumentPreview.tsx— preview/print layersrc/lib/types.ts— shared TypeScript types (DocumentData,BusinessConfig, etc.)src/lib/branding.ts— resolved business/branding/public-site accessorsrc/lib/contracts.ts— recurring contract CRUD and scheduler logicsrc/lib/calendar.ts— calendar event logic, recurrence math (host-timezone independent)src/lib/auth.ts— NextAuth v5 beta setupsrc/lib/health.ts— diagnostics shared by/api/healthand/admin/systemdocs/manual/*.md— user manual, rendered by the in-app Help (/admin/help) and the docs site (/docs/manual);src/lib/help-content.tsis its topic registry (order, descriptions, icons; titles must match the files)src/components/HelpTip.tsx— the "?" contextual help icon (hover on desktop, tap on touch);Fieldin settings takeshelp/helpTopicpropssrc/app/setup/— first-run wizard (only while zero users exist)
Cron endpoints
POST /api/cron/contracts— contract invoice schedulerPOST /api/cron/calendar— calendar reminder emails- Both require
X-Cron-Secretheader matchingCRON_SECRETenv var - Docker sidecar (
cronservice in compose) triggers these on schedule
Stripe endpoints
POST /api/stripe/checkout— create a Checkout Session for a public invoice share token (full balance, amount, %, or equal split)POST /api/stripe/webhook— Stripe webhook; records payment + receipt oncheckout.session.completed- Requires
STRIPE_SECRET_KEY; webhook also requiresSTRIPE_WEBHOOK_SECRET - Shared payment apply logic:
src/lib/invoice-payments.ts; amount helpers:src/lib/stripe-checkout.ts
Testing
- Vitest in node environment,
@/alias resolved,TZ=UTCpinned invitest.config.ts - Tests live in
src/lib/__tests__/— calendar recurrence/timezone math, Stripe amount helpers, payment links, quote intake, config merge/migration (config.test.ts), branding resolution (branding.test.ts) - No test DB setup required; when adding tests that touch Prisma, you need a running Postgres
Prisma notes
npm run prisma:migrate:devfor local schema changes (creates migration files)npm run prisma:migrate:deployin CI/Docker (applies pending migrations)- Prisma client auto-generates on
npm installviapostinstallscript - Deploy workflow has a quirk: resolves a duplicate init migration idempotently before
migrate deploy(see.github/workflows/deploy.yml)
Docker
docker compose up -d --build— full stack (app onAPP_PORT, Postgres on 5433, cron sidecar)- Image base is
node:26-slim(Debian/glibc), not Alpine: the Next.js TypeScript build worker segfaults under musl once the type program is large enough, and Prisma engines need glibc. Keep it Debian. - App runs as unprivileged
nextjsuser;data/dir pre-created with correct ownership output: "standalone"innext.config.tsfor Docker tracing- Persistent volumes:
marotto_data(/app/data),postgres_data - Container names and the Postgres host port come from
STACK_NAME/POSTGRES_PORT; the defaults reproduce the prod values, so plaindocker composeis unchanged - The web container's entrypoint (
docker-entrypoint.sh) runsprisma migrate deploybefore starting the server (CLI installed in its own Dockerfile stage at/opt/prisma-cli, pinned to the lockfile version); a failed migration stops the container.SKIP_MIGRATIONS=1opts out. Prod/dev deploy workflows still migrate from the host first (duplicate-init quirk), so the container's run is a no-op there - Admin create/password reset in Docker:
docker compose exec -e ADMIN_EMAIL=… -e ADMIN_PASSWORD=… web node scripts/seed-admin.js POSTGRES_PASSWORDoverrides the bundled DB password (defaultmarotto_password, only read when the volume is created).env.exampleleavesDATABASE_URLcommented out so containers use the in-network defaultexperimental.serverActions.bodySizeLimitis raised innext.config.tsbecause logo, attachment, and backup-restore uploads go through server actions
User docs
Self-hoster guides live in docs/ (index docs/README.md: getting-started, configuration, deployment, operations, troubleshooting). Keep them in sync when changing env vars, compose services, Settings tabs, or upgrade steps.
They are also rendered as a public docs site: src/app/docs/ (routes), src/lib/docs.ts (page registry, link rewriting, docs.* host detection), src/proxy.ts (rewrites the docs host's /x to /docs/x). A new guide must be added to DOC_PAGES; docs.test.ts checks every registered file exists and every cross-guide #anchor resolves. Relative .md links are rewritten to site routes, other repo files to GitHub.
Dev instance
Second isolated stack at dev.marottosolutions.com. Full guide: docs/dev-environment.md.
- Always pass both files:
docker compose -f docker-compose.yml -f docker-compose.dev.yml … - Isolation is env-only (
STACK_NAME=marotto-dev,APP_PORT=3082,POSTGRES_PORT=5444,COMPOSE_PROJECT_NAME=marotto-dev); there is no separate code path APP_ENV(notNODE_ENV) marks an instance non-production — dev runs a production build on purpose. Seesrc/lib/app-env.ts- Non-production effects: DEV banner,
Disallow: /robots, browser source maps, live Stripe keys rejected - All dev email goes to a mailpit sink; nothing reaches real clients
- Deploy via
.github/workflows/deploy-dev.yml: pushes todevelop, or Run workflow with the branch chosen in the "Use workflow from" dropdown (gh workflow run deploy-dev.yml --ref <branch>); afresh_installcheckbox wipes dev data first. Shares theself-hosted-deployconcurrency group with prod - Env template is
env.dev.example— not a dotfile, because.env*is gitignored
Health endpoint
GET /api/health — anonymous returns { ok, env, commit, time }; admin sessions also get database reachability, active document store (WebDAV vs local JSON), numbering strategy, Stripe mode, and the redacted SMTP target. Use it to check which persistence path is live rather than guessing.
Gotchas
- Document numbers use atomic
DocumentCountertable (in DB) whenDATABASE_URLis set, otherwise fall back to filesystem scanning — don't assume one path - Settings legacy fallback reads
config/settings.jsonifdata/config/settings.jsonmissing; a settings file without abusinesssection is seeded with legacy Marotto branding at read time (seesrc/lib/legacy-defaults.ts) .env*is gitignored;env.exampleis the template (dev usesenv.dev.example) — deliberately not dotfiles so they can be committednpm run lintandnpm testare expected to pass; treat any failure as a regressionnext-authis v5 beta — API may differ from v4 docs- Admin and non-admin routes overlap for some doc types (e.g.
/invoices/*and/admin/invoices/*); use admin routes for operational workflows - Document
warrantyandpaymentOverridesfields live onDocumentDatainsrc/lib/types.ts, not in the DB - Stripe Checkout is preferred over pasted Payment Links when
STRIPE_SECRET_KEYis set; webhook marks invoices paid (do not double-record manually for the same session)
