Niche-first store builder for creator-led commerce (K-pop/artist merch stores first). Manual payment-first (bank transfer, Wise, PayPal) with built-in proof-of-payment review, no Stripe required.
Turborepo monorepo: apps/api (NestJS), apps/web (Next.js), packages/*
(shared db/types/design-tokens/i18n/queue/utils). pnpm workspaces.
- pnpm only. Never
npm/yarn.packageManageris pinned in rootpackage.json, don't touch that pin without being asked. - Latest TypeScript, ESM only across the repo (
"type": "module"in every package). No CommonJS, norequire. Relative imports inapps/apiuse explicit.jsextensions (NodeNext resolution) even though the source is.ts— follow this pattern for new files there. - web never imports
packages/dbor talks to Postgres directly. All data access from the frontend goes throughapiover HTTP. This is the #1 rule keeping multi-tenant isolation enforceable in one place. Don't import@prisma/clientintoweb"just for types." - Every query touching tenant data filters by
storeId. No exceptions. Ownership is checked viaassertOwnership/findOwnedProduct-style helpers in the service layer (seeapps/api/src/modules/products/products.service.ts), mutations must verify the authenticated user owns the store, not just that the tenant-scoped query ran.
Run from repo root; Turbo filters by package.
pnpm install # workspace install
pnpm dev # all apps, parallel (api :3000, web :3001)
pnpm build # turbo run build (respects dependency graph)
pnpm lint # turbo run lint
pnpm fix # format changed files, then run lint
pnpm typecheck # turbo run typecheck
pnpm test # turbo run test, all packages
pnpm db:generate # prisma generate (packages/db)
pnpm turbo run <task> --filter=api # scope to one app, e.g. lint/build/test
pnpm turbo run <task> --filter=web
pnpm turbo run <task> --filter=@biasmarket/db
pnpm --filter api generate:openapi # emit apps/api/openapi.json from live route/DTO metadata
pnpm --filter @biasmarket/types generate # regen packages/types/generated/** from openapi.json (Orval)packages/types holds an Orval-generated SDK client,
grouped one namespace per migrated controller tag (collections, ...) —
apps/web uses it via apiClient.collections.findAll(storeId)-style calls
instead of hand-written fetch wrappers for migrated features (see
apps/web/AGENTS.md's OpenAPI note for the full shape, including why a plain
generated openapi-fetch client was tried first and replaced). Both
apps/api/openapi.json and packages/types/generated/** are committed, not
build-generated — deliberately, to keep web's build/typecheck independent of
apps/api (no turbo cross-package dependency, no live app boot needed in CI).
Regenerate both by hand after changing a migrated module's response DTOs:
pnpm --filter api generate:openapi && pnpm --filter @biasmarket/types generate,
then commit the diff.
Inside apps/api:
pnpm --filter api test # vitest run (unit, src/**/*.spec.ts)
pnpm --filter api test:watch
pnpm --filter api test:e2e # vitest run -c vitest.config.e2e.ts (**/*.e2e-spec.ts, real AppModule)
pnpm --filter api dev # nest start --watchTo run a single test file,
cd apps/api && pnpm exec vitest run path/to/file.spec.ts (or without run to
watch). Unit tests stub @biasmarket/db (see vitest.config.ts alias) with a
fake PrismaService via useValue — they never hit a real database. Only the
e2e config boots the real AppModule.
Inside apps/web:
pnpm --filter web dev # next dev -p 3001
pnpm --filter web test # vitest run, jsdom envDocker (from repo root):
pnpm docker:dev # infra/docker/docker-compose.dev.ymlProduction is deployed only by the blue/green CI/CD flow described in
docs/core/deploy.md; use infra/vps/deploy.sh for a supervised manual
deployment or recovery.
CI (.github/workflows/ci.yml) path-filters per package and runs the applicable
lint/typecheck/build/test tasks independently for api, web, workers, db,
design-tokens, i18n, queue, types, and utils — only changed packages
(and their dependents per the filter rules) run.
apps/
api/ NestJS backend — sole owner of the database
web/ Next.js frontend (storefront + dashboard), calls api over HTTP only
packages/
db/ Prisma schema + generated client (packages/db/generated/prisma)
types/ Shared DTOs/interfaces between api and web
design-tokens/ Portable store palette values/resolution used by web and ready
for mobile
i18n/ ES/EN translation dictionaries used by web
queue/ Shared BullMQ queue names and payload contracts used by api + workers
utils/ Shared pure functions
packages/db re-exports the Prisma v7 generated client from
generated/prisma/client.ts via index.ts. The Prisma engine now uses
@prisma/adapter-pg (driver adapter), wired in PrismaService.
Flat NestJS controller/service/dto per module for most of
apps/api/src/modules/* (addresses, categories, collections, contact,
coupons, couriers, customer-auth, delivery-config, health,
monitoring, notifications, orders, payment-config, pickup-points,
products, restock, stats, store-sections, stores, users, and
whatsapp-templates). The buyer-facing global-account.controller.ts lives
inside customer-auth: it handles global buyer identity, while the neighboring
customer-auth controller handles per-store buyer sessions.
orders is the one module using the DDD-lite layering
(domain/application/infrastructure) described in docs/core/architecture.md —
it owns the payment/fulfillment state machine, which warranted the extra
structure. Don't apply that layering to CRUD-style modules, and don't retrofit
it onto existing flat modules unless asked.
main.ts: globalValidationPipe({ whitelist: true, forbidNonWhitelisted: true }), global prefixapi, CORS locked toWEB_URL.- Auth:
better-authvia@thallesp/nestjs-better-auth, Prisma adapter, email+password,rolefield defaults toseller. Config insrc/auth/auth.config.ts. This is seller/dashboard auth only — storefront buyers use a separate, throttled session-cookie flow inmodules/customer-auth(Customermodel, notUser). - No tenant-resolution middleware yet — the architecture doc's
TenantMiddleware/AsyncLocalStoragedesign is aspirational; current code checks ownership per-request inside each service method instead. - NestJS build is SWC-based (
nest-cli.json:builder: "swc",typeCheck: false) —pnpm typecheckrunstsc --noEmitseparately from the build.
App Router, no src/ — app/, components/, lib/, hooks/, features/ sit
at apps/web root. New feature work (data fetching, forms, feature-local types)
lives under features/<name>/{schemas,api,queries,mutations,components}; app/
stays routing + page composition, components/ui is shadcn primitives,
components/shared holds cross-feature
LoadingState/ErrorState/EmptyState. Stack: @tanstack/react-query for
server state (provider wired in app/[locale]/query-provider.tsx), zod for
runtime validation at the api/ boundary, react-hook-form +
@hookform/resolvers/zod for forms. All major dashboard pages (products,
settings, orders) are migrated to this structure — see apps/web/AGENTS.md
for the full convention, including the generated OpenAPI client used by migrated
features.
Core models: User, Store, Product, ProductVariant, Category,
Collection, StoreSection, Order, OrderItem, OrderPayment,
PaymentMethodConfig, DeliveryMethodConfig, PickupPoint,
Customer, ContactInquiry, Notification, AuditLog, plus better-auth's
Session/Account/Verification. Money fields are Decimal, never Float.
Order implements the state machine from docs/core/security-payments.md §9:
paymentStatus
(PENDING_PAYMENT → PARTIALLY_PAID/PAYMENT_SUBMITTED → VERIFIED/REJECTED, plus
CANCELLED) and fulfillmentStatus
(ORDERING → IN_TRANSIT → READY → COMPLETED) are tracked separately, an order
carries a soft-hold expiresAt that expire-orders.usecase.ts sweeps,
OrderPayment rows record each partial payment toward requiredAmount, and
buyer-submitted proof images are also OrderPayment rows with
source: BUYER_SUBMITTED and reviewStatus: PENDING_REVIEW. See
apps/api/src/modules/orders/domain/order-status.vo.ts and order.entity.ts
for the transition rules. The workers app schedules the BullMQ expiry job and
calls the API's authenticated internal expiry-sweep endpoint.
Tenant resolution today is per-service (assertOwnership(storeId, userId)
checks against Store.ownerId), not global middleware. Slug strategy is
/store/:slug, single domain, no subdomains at MVP.
Single Oracle Cloud VM, blue/green containers for api, web, and workers,
plus shared db, redis, minio, uptime-kuma, and caddy (S3-compatible
object storage — product/logo/payment-proof images, see
apps/api/src/storage/storage.service.ts). Production definitions live in
infra/vps/docker-compose.yml; Caddy does TLS termination for biasmarket.com,
api.biasmarket.com, cdn.biasmarket.com, and status.biasmarket.com.
Migrations run in the explicit candidate phase of infra/vps/deploy.sh, never
automatically on container boot. Full runbook: docs/core/deploy.md.
docs/core/architecture.md— monorepo layout, DDD-lite plan, multi-tenant design, theming system, deployment/scaling pathdocs/core/security-payments.md— validation rules, REST-over-tRPC rationale, manual payment flow state machine (implemented — see the Database section above for where)docs/core/product.md,docs/core/roadmap.md,docs/core/i18n.mddocs/plans/— dated implementation-plan records as work landsapps/web/AGENTS.md— flags that the installed Next.js version has breaking changes vs. training data; checknode_modules/next/dist/docs/before writing Next.js code