Imported from tonmoydeb404/next-backend (
AGENTS.md). Install upstream withnpx skills add tonmoydeb404/next-backend. Copyright stays with the author.
AGENTS.md
Instructions for AI coding agents working in the BandiNet monorepo.
Project overview
BandiNet is a Turborepo + pnpm monorepo with two apps and four shared packages:
apps/
publicator/ Next.js (App Router) — internal admin dashboard + the API (backend/api Route Handlers)
website/ Next.js (App Router) — public site (Matchator + Studio)
packages/
db/ Drizzle ORM schema for typed queries (@repo/db, publicator-only consumer)
validators/ Zod schemas, zero backend deps (@repo/validators, shared by all apps)
store/ Redux Toolkit + RTK Query base API (@repo/store, frontend apps only)
supabase/ Supabase JS client factories (@repo/supabase, shared by all apps)
eslint-config/ Shared eslint configs (@repo/eslint-config)
typescript-config/ Shared tsconfig bases (@repo/typescript-config)
docs/ Architecture + per-domain DB schema docs (source of truth, read before big changes)
Full architecture reference: docs/architecture.md, docs/system-diagram.md, DB domain docs in docs/db/.
Tech stack
- Backend API (
apps/publicator/src/app/backend/): Next.js Route Handlers, Drizzle ORM (postgres-jsdriver via@repo/db), Zod for request validation +satisfiesfor compile-time response typing, Supabase (Postgres + Auth + Queues/pgmq),zod-openapi+ Scalar UI (/backend/reference) for API docs, Pino for logging. - Publicator & Website: Next.js 16 (App Router), React 19, Redux Toolkit + RTK Query (via
@repo/store), Tailwind v4, shadcn/ui, Zod v4. - Package manager: pnpm (
packageManager: pnpm@9.0.0), workspaces defined inpnpm-workspace.yaml(apps/*,packages/*). - Build orchestration: Turborepo (
turbo.json) —build,lint,check-types,devpipelines.
Key architectural rules (do not violate)
- Publicator's Route Handlers (
apps/publicator/src/app/backend/api/) are the sole gateway to the database.apps/websitenever calls Supabase directly for data — only@repo/store→ Publicator's API (via anext.config.tsrewrite to publicator's deployed URL). Supabase Auth is used directly by both frontends only for login/MFA. packages/db(Drizzle) is imported byapps/publicatoronly. Never import it fromapps/website.packages/validatorshas zero backend/Node-only dependencies. It must stay safe to import into any app. Nodrizzle-zod, no framework-only helpers there.- API calls go through Next.js rewrites or direct same-origin routes, not cross-origin fetch: publicator serves
/backend/api/v1/*directly (no rewrite needed — it's a real route in that app); website'snext.config.tsrewrites/backend/api/:path*→ publicator's deployed URL (env varNEXT_PUBLIC_PUBLICATOR_BASE_URL). RTK Query'sApiin@repo/storeuses the same-origin/backend/api/v1base URL in both apps. - Env access is always through a validated
envConfig, never rawprocess.envscattered in code — seesrc/config/env.config.tsin each app (publicator's includes server-only vars likeDATABASE_URL/SUPABASE_SECRET_KEY/SUPABASE_JWKS_URLalongside theNEXT_PUBLIC_*ones). - No new domain/module should be scaffolded ahead of an actual need. Only Geography exists end-to-end (db → validators → publicator Route Handlers) as the reference implementation; the other 8 DB domains (see docs/db/) are documented but not yet implemented — follow the Geography pattern when adding a new one, don't pre-stub others.
- Async work uses Supabase Queues (
pgmq), triggered by Vercel Cron hitting a publicator Route Handler under/backend/api/v1/jobs/*— no Redis/BullMQ/persistent worker process (publicator deploys as a stateless Vercel Function). Cron-triggered job endpoints are secured via a sharedCRON_SECRET, notwithAuth's JWT check. profiles/tenants/seatsare created/deleted in sync withauth.usersat the DB level, not in application code — a Postgres trigger (packages/supabase/migrations/) provisions them on signup, andON DELETE CASCADEFKs clean them up on user deletion. Never add API logic to insert/delete these rows on signup/account-removal.- Publicator-facing API routes require AAL2 (2FA). Any Route Handler under
src/app/backend/api/that exists to serve staff/internal use must enforce Supabase AAL2 (TOTP-completed session) viacreateHandler({ auth: true, aal2: true, ... }), not just a valid JWT — staff accounts are MFA-mandatory. Website/customer-facing routes don't require AAL2 unless the specific action demands it. - Avoid
any. Do not useanyoras any/unchecked type casts to silence TypeScript — narrow with proper types, generics, or zod-inferred types instead. If a cast is truly unavoidable (e.g. a third-party type gap), prefer a precise cast (as SpecificType) with a one-line comment explaining why, neveras any.
File naming
- All file names use kebab-case (e.g.
region-details.dto.ts,use-app-selector.ts), across every app and package — no camelCase or PascalCase file names.
Conventions to follow when adding code
packages/validators:src/db/<domain>/<entity>.tsmirrors a DB table 1:1.src/api/<domain>/<resource>/<resource>-details.ts/-list.ts/-common.tshold response/param/query schemas (fixed filenames — don't invent new ones like*-code-param.ts). Response envelopes built once here viabuildResponseSchema/buildPaginatedResponseSchemafromsrc/common/response.ts. Cross-folder imports inside this package use Node subpath imports (#*), not@/*aliases.apps/publicator/src/app/backend/: see Backend API route rules below.apps/publicator/apps/website:src/store/hasstore.ts,hooks.ts,wrapper.tsx,features/<name>/(local UI-only slices). API endpoints live inpackages/store/src/endpoints/<domain>/viaApi.injectEndpoints, typed from@repo/validators.apps/publicator/apps/website: each app has asrc/config/paths.config.tsexporting a singlepathsobject with every route in the app — nested per section, leaf values are either a literal string or a function returning a string for dynamic segments, e.g.:
Never hardcode a naked path string (export const paths = { root: "/", page: "/page", nestedPage: { root: "/nested-page", details: (slug: string) => `/nested-page/${slug}`, }, };"/some/route", template-literal route) in a component,Link,redirect(), or router call — always import frompathsinstead.- Env template files are
example.env(not.env.example) across the repo; local values go in gitignored.env.local.
Frontend page/view structure (apps/publicator, apps/website)
Every route follows an app/ + view/ split — app/ stays a thin routing shell, all real UI lives in view/:
src/
app/
<route>/
page.tsx # thin: renders the matching view's index, no business logic
view/
<route>/
index.tsx # composes the sections/components for this view
sections/ # route-specific page sections
components/ # route-specific components used only by this view
app/<route>/page.tsxshould stay minimal — import and renderview/<route>'sindex.tsx, not implement UI inline.- Shared, cross-route components still live in
src/components/(e.g.components/ui/), not underview/. - Every component (in
view/**orsrc/components/) must stay under 150 lines. Split intosections//sub-components when it grows past that.
Backend API route rules (apps/publicator/src/app/backend/)
- Route file location under
src/app/backend/api/v1/<domain>/<resource>/route.tsis the URL path ([code]→{code}) — no separate path registration. - Define/reuse request+response schemas in
packages/validatorsbefore writing the route file. Never inline a Zod schema in a route file. - Add a
<entity>.repository.tsinpackages/db/src/repositories/for any table without one (framework-agnostic class extendingRepositoryBuilder, constructor takesdb); wire its singleton inapps/publicator/src/lib/api/repositories.ts. Route handlers never touch Drizzle directly. - Every exported method (
GET/POST/etc.) must be built viacreateHandler({...})fromsrc/lib/api/create-handler.ts— never a rawexport async function GET(req) {...}. - Never call
.parse()manually in a route file — passparams/query/bodyschemas tocreateHandlerinstead. auth: truerequires a valid Supabase JWT; addaal2: truefor staff/publicator-only routes. Usepermissions/permissionsLogic(staff routes, checked againstinternal_roles.permissions) andtenantRoles/tenantRolesLogic(tenant-scoped routes, checked against the seat for thex-tenant-idheader) for authorization — never a hand-rolled check inside the handler body.permissionsmust be typedPermission[]from@repo/validators(never raw strings) — add new[resource]:[action]entries torolePermissionsinpackages/validators/src/db/auth/permissions.tsonly as each domain is actually built, not ahead of time.openapi: { summary, tags, responses }is mandatory on every route — read byscripts/generate-openapi.tsto buildpublic/openapi.json.- Return responses via
formatResponse(...) satisfies <ResponseType>+NextResponse.json(...). No runtime response validation —satisfiesis compile-time only. - Throw
NotFoundError/letZodErrorpropagate for errors — never construct an errorNextResponseby hand;createHandlerformats both consistently. - Business logic stays inline in the handler for simple domains (Geography-style CRUD); extract to
src/lib/api/services/<domain>/<name>.service.tsfor complex ones (Grants, Matching). - Regenerate the OpenAPI spec after adding/changing a route:
pnpm --filter publicator generate:openapi(also runs viapredev/prebuild). - After changing
rolePermissions, re-sync thesuper_admininternal role:pnpm --filter publicator sync:roles(idempotent — creates the role if missing, otherwise updates its permissions array; not run automatically on startup, deliberate to avoid permission drift). - Async/queue endpoints (
/backend/api/v1/jobs/*, triggered by Vercel Cron) skipauth/aal2and check a sharedCRON_SECRETheader instead — there's no user JWT on a cron-triggered request.
Common commands
Run from repo root unless noted:
pnpm install # install all workspace deps
pnpm dev # turbo run dev (all apps)
pnpm dev:publicator # dev publicator only (serves the API too)
pnpm dev:website # dev website only
pnpm build # turbo run build (all)
pnpm lint # turbo run lint (all)
pnpm check-types # turbo run check-types (all)
pnpm format # prettier --write across ts/tsx/md
Package-specific:
# packages/db
pnpm --filter @repo/db db:studio # drizzle-kit studio (browse via the src/schema/*.ts types)
# packages/supabase
pnpm supabase:new <name> # supabase migration new <name> (schema source of truth)
pnpm supabase:push # apply pending migrations to the linked project
pnpm supabase:pull # check for drift against the linked project
pnpm supabase:types # regenerate src/types/database.types.ts
# apps/publicator
pnpm --filter publicator generate:openapi # regenerate public/openapi.json (also runs automatically via predev/prebuild)
Validation before finishing a task
- Prefer running the narrowest relevant command first (
pnpm --filter <pkg> check-types,pnpm --filter <pkg> lint), then the rootpnpm check-types/pnpm lintif the change spans packages. - Schema changes are authored as SQL migrations in
packages/supabase/migrations/(the sole schema source of truth, tables through RLS/grants) —packages/db's TS schema is kept in sync by hand to match, for typed publicator queries only. - Every
packages/supabase/migrations/*.sqlfile must be idempotent (safely re-runnable) — never a plain one-shotCREATE/ALTER. Use:CREATE TABLE IF NOT EXISTSwith just the PK, then oneALTER TABLE ... ADD COLUMN IF NOT EXISTSper column (so re-running after a later column addition still applies it, not just the initial create); a guardedDO $$ BEGIN ... EXCEPTION WHEN duplicate_object THEN NULL; END $$;block forCREATE TYPEandALTER TABLE ADD CONSTRAINT(neither supportsIF NOT EXISTS);CREATE INDEX IF NOT EXISTS;CREATE OR REPLACE FUNCTION;DROP TRIGGER IF EXISTS/DROP POLICY IF EXISTSbeforeCREATE TRIGGER/CREATE POLICY(neither supportsIF NOT EXISTSorOR REPLACE). - After editing
packages/validators, check whetherapps/publicator/src/app/backend/api/**/route.tshandlers or theiropenapimetadata need matching updates.
Documentation policy
docs/architecture.mdis the living architecture spec — treat it as authoritative over this file for anything not covered here, and update it when architecture-level decisions change (new domain implemented, new package, pattern change).docs/db/*.mddocuments all 9 planned DB domains; keep in sync whenpackages/dbschema changes.