Your product
Start here when you are ready to build your own app. You generate a product next to the example keelmint product, set its options in one config file, and add your models, API features and pages - without touching core.
What a product is
One API, one or more clients, its own database and its own users, built on libs/core/*. Core is identical in every product; a product adds its own models, features and branding. keelmint is the example product.
| Part | Path | Tags |
|---|---|---|
| API app | apps/<name>/api (+ api-e2e) | scope:product:<name> |
| Client app(s) | apps/<name>/client (more: apps/<name>/<client>) | scope:product:<name> |
| API features | libs/<name>/api/* | scope:product:<name> |
| Client features | libs/<name>/client/* (+ i18n/{el,en}.json) | scope:product:<name> |
| Database | libs/<name>/db (models, composed schema, migrations, seed) | scope:product:<name> |
| Shared | libs/<name>/shared (productConfig, types shared by api/client) | scope:product:<name> |
| Core | libs/core/{api,client,db,shared} | scope:core |
scope:core code never imports a product (lint rule). A product may import core and itself, never another product. A product never adds columns to User or any other core model; product data lives in product tables that point at users.
Folders inside an api or client lib
Files under src/lib are grouped by kind; a spec sits next to the file it tests.
| Kind | Folder |
|---|---|
Angular component (with its .html/.css and own helpers) | components/<name>/; several components of one screen: components/<area>/<name>/ (components/organization-screen/unit-pane/) |
| Directive, pipe, service, guard, interceptor, store | directives/, pipes/, services/, guards/, interceptors/, stores/ |
provide-* functions, route arrays | providers/, routes/ |
| Nest controller, decorator, filter, strategy, listener, mapper, worker | controllers/, decorators/, filters/, strategies/, listeners/, mappers/, workers/ |
| Request/response classes | dto/ |
| Mail templates, storage drivers | templates/, drivers/ |
| Types only | models/ |
| Plain functions and constants | utils/ |
Nest *.module.ts | src/lib itself |
A sub-area keeps its name inside each kind folder (controllers/google/, services/google/, dto/google/).
Create one
pnpm nx g @core/plugin:product <name> [--client=<client>] [--organizations=multi|single]--organizations (asked when omitted; default multi) goes into product.config.ts:
multi: the platform admin creates organizations, users belong to several and switch between them.single: one built-in organization (default) that everyone belongs to; the switcher is hidden, and the platform admin’s Organizations page shows the one organization (no creating or suspending).
Single to multi later is a config change; multi to single is not supported. See Organizations and units.
It creates:
apps/<name>/api(NestJS app:CoreModule.forRoot,main.tswithconfigureApp,.env.example, tsdown config) andapps/<name>/api-e2e(health, register, admin login);apps/<name>/<client>(Angular app:provideCore,coreRoutes, proxy to the product API;clientunless--clientis given);libs/<name>/db(product.prismafile, composedschema.prisma,prisma.config.ts, an init migration that includes the base roles and the built-indefaultorganization, the six-line client factory, seed);libs/<name>/shared(product.config.ts, side-effect-free@<name>/shared/configentry),libs/<name>/apiandlibs/<name>/client(README,i18n/{el,en}.json);tools/db/init/<name>.sql(creates<name>and<name>_test), path aliases intsconfig.base.jsonand the module-boundary constraint ineslint.config.mjs;apps/<name>/api/Dockerfile, the app’spackage.jsonand theprunetargets, anddeploy/<name>/(Compose, Caddyfile, web image,.env.example), so the product deploys likekeelmint(Deploy); the release workflow picks it up by itsdeploy/<name>/docker-compose.yml;apps/<name>/README.mdwith the first-run steps.
Rules:
- The name is kebab-case, is not
coreortools, and is not an existing product. Long names are refused (<name>_testmust fit Postgres’s 63-byte limit). - A product never shares a database with another one, because its e2e suite truncates
<db>_test. So a name may not end in-test, may not give a database (<db>or<db>_test) that an existing product already uses, and may not bepostgres,template0ortemplate1. - Ports: API
3000 + 10·n, client4200 + 10·n, e2e3001 + 10·n, wherenis the product’s index (keelmintis 0).
First run (also in apps/<name>/README.md):
cp apps/<name>/api/.env.example apps/<name>/api/.env# Fill the three empty secrets in apps/<name>/api/.env, each with its own# `openssl rand -base64 32`: JWT_SECRET, JOBS_ENCRYPTION_KEY, TWO_FACTOR_ENCRYPTION_KEY.# The API refuses to boot while any of them is empty.docker compose up -d db mailpit# Create <name> and <name>_test. Needed whenever the Compose volume already# exists - which it does once you have run the Quick Start.docker compose exec db psql -U postgres -f /docker-entrypoint-initdb.d/<name>.sqlpnpm nx run <name>-db:migratepnpm nx run <name>-db:seedpnpm nx run-many -t serve -p <name>-api <name>-clientThe psql step runs tools/db/init/<name>.sql on the existing volume, after docker compose up -d db (which also picks up the init-folder mount). A fresh volume runs it by itself on first start; running it again then prints an “already exists” error for a database, which is harmless.
Add a client
pnpm nx g @core/plugin:product-client <product> --name=<client>Creates apps/<product>/<client> with provideCore, coreRoutes, a proxy to the product API and the next free client port. It shares the product’s API, database and users (e.g. crm/admin and crm/portal).
Existing Postgres volume
tools/db/init/<name>.sql runs only when the Compose volume is first created. On an existing volume, run it by hand:
docker compose exec db psql -U postgres -f /docker-entrypoint-initdb.d/<name>.sqlThe init folder is a bind mount: after pulling this layout for the first time, run docker compose up -d db once so the container picks up the mount.
Models
- Core models (
libs/core/db/prisma/core/*.prisma) are identical in every product. - Product models go in
libs/<name>/db/prisma/*.prisma. Use// @basefor the shared fields (id, timestamps,isDeleted). - Tenant data: put
// @tenantin a model that belongs to one organization. Every query on it is then scoped to the active organization (a platform admin’s to every organization, or the ones they name: Pages across organizations). Add// @ownedwhen each record belongs to one user (the model declaresuserId String; callers reach their own records, admins with a*Anypermission their members’) or// @unitfor an optional unit (callers reach their direct units’ rows, admins with a*Anythe units in their reach). Read Tenant data before you write one: relations and raw SQL are not scoped. - A product model may point at a core model (
user User @relation(fields: [userId], references: [id])). Compose adds the back-relation to the core model inside its@relationsblock. The name is the camelCase plural of the product model:OrdergivesUser.orders; a unique foreign key (one-to-one) gives the singular,User.order.- The plural is naive: add
s. Irregular plurals are not special-cased:Persongivespersons,Childgiveschilds. - Two relations from one model to the same core model need a relation name:
@relation("assignee", ...)givesordersAssignee. The name is copied onto both sides. This is also the escape hatch when a derived name is ugly: name the relation and the field becomes<plural><RelationName>. - A name that collides with an existing field fails the compose with a message naming both.
- After editing models:
pnpm nx run <name>-db:compose-schema(rewrites the generatedschema.prismaandsoft-delete-models.generated.ts, which also listsTENANT_MODELSandUNIT_MODELS), thenpnpm nx run <name>-db:migrate-dev. Never edit a generatedschema.prismaor inside a// @baseblock.pnpm nx run <name>-db:compose-schema-checkfails when the committed output is stale (it runs in lint). - A change to a core model needs
<p>-db:compose-schemaand a migration in every product: runpnpm nx run <p>-db:compose-schemathenpnpm nx run <p>-db:migrate-devfor each<p>, and commit each product’s migration. A product that skips this fails at runtime and in itscompose-schema-check. - Soft delete: never hard delete users, roles, organizations, memberships, units or todos (and any
@basemodel); usesoftDelete/softDeleteMany.
Pages across organizations
A platform admin has no active organization: on your product’s pages their requests reach every organization (The platform admin). Two things follow for a product page:
- Lists show rows of several organizations to a platform admin, so give them the organization (Keelmint’s todo rows carry
organizationIdandorganizationName) and offer the level filters. - A create needs one organization: a platform admin picks it, and the request names it with
X-Organization-Id(without: 400ORGANIZATION_REQUIRED).
injectLevelFilters (@core/users-data-access) gives a list page the filter bar for the caller’s level - a platform admin: organizations, then the units and members of the chosen ones; anyone else: the units in their reach and the members of the active organization - and the request context that sends the choices as X-Organization-Id, X-Filter-Unit and X-Filter-Member (Tenant data). A list page, after Keelmint’s /todos/all:
export class AllThingsPage { private readonly api = inject(ThingsApiService); private readonly auth = inject(AuthService); // @core/auth-data-access
protected readonly list = injectListState(THING_LIST, (query) => this.api.findAll(query, this.levels.context()), { multiFilters: ['organization', 'unit', 'member'], }); protected readonly levels = injectLevelFilters(this.list, { caller: this.auth, units: true, members: true, permission: 'Things.ReadAny', // a unit admin's unit choices: where they hold it });}<lib-filter-bar [sources]="levels.sources()" [values]="levels.values()" (changed)="levels.changed($event)" />The API service passes the context on (this.http.get(url, { params, context })). The unit and member options come from GET /api/members, so offer them to a caller who is not a platform admin only when they hold Members.Read (Keelmint’s page checks reachesAnywhere(auth, 'Members.Read')). Row edits and deletes go by id and need no context.
For a create, show lib-organization-picker (@core/ui-table) to a platform admin and send the pick with targetOrganization (@core/api-config):
protected readonly platform = computed(() => this.auth.isPlatformAdmin());protected readonly organizations = injectOrganizationFilterSource(); // @core/users-data-accessprotected readonly target = signal<string | null>(null);
create(title: string) { const target = this.platform() ? this.target() : null; this.api.create({ title }, targetOrganization(target)).subscribe();}@if (platform()) {<lib-organization-picker [source]="organizations" [(value)]="target" [invalid]="!target()" />}targetOrganization(null) leaves the request as it is, so members create in their active organization as before. Keelmint’s /todos page is the example.
Configuration
libs/<name>/shared/src/product.config.ts:
export const productConfig = defineProductConfig({ name: 'Acme CRM', // 2FA issuer, document title, layout header, product name in mail logo: 'logo.svg', // optional: sidebar mark, from apps/<name>/client/public registration: 'open', // 'open' | 'invite-only' defaultLanguage: 'en', // 'el' | 'en' organizations: 'multi', // 'multi' | 'single' organizationSignup: { maxPerUser: 3 }, // optional: users create their own organizations unitTypes: ['branch', 'department'], // unit kinds; labels: unitTypes.<key> in i18n systemRolePermissions: { USER: ['Todos.Read'], UNIT_ADMIN: ['Todos.Read'], ORG_ADMIN: ['Todos.Read'] },});name: the TOTP issuer, the page title and the name in the layout header. Control characters are rejected.logo(optional): the mark next to the name in the sidebar, a path the client serves (a file inapps/<name>/client/public) or a URL. Use a square image that reads on a dark background: the sidebar is dark in both themes. Absent: the collapsed sidebar shows the name’s first letter. The browser-tab icons are the client’s own files (favicon.ico, linked fromsrc/index.html).registration: 'invite-only':POST /api/auth/registeranswers 403REGISTRATION_CLOSED; the client hides the register link and route; invitations work as usual.defaultLanguage: language of new users and of the client before login.organizations:'multi'(many organizations, a switcher for members, an Organizations page for the platform admin) or'single'(one built-in organization everyone joins; the platform admin’s Organizations page shows it). Required. InsinglemodePOST /api/organizationsanswers 409SINGLE_ORGANIZATION_MODE. Details: Organizations and units.organizationSignup:{ maxPerUser }(optional, multi mode only): signed-in users create their own organizations and become theirORG_ADMIN(POST /api/organization-signup). Absent: only the platform admin creates organizations. Details: Organizations and units.unitTypes(optional): the kinds a unit can be, in the order the Units page offers them (a new sub-unit defaults to the kind after its parent’s). Keys are lowercase letters, digits and dashes, at most 20. Each is labelled by theunitTypes.<key>translation: core hasbranchanddepartment; a product adds or overrides others inlibs/<name>/client/i18n/<lang>.json. Left out or empty: units have no type. Removing a key leaves units of that type as they are (still shown, no longer offered); edit them to another type.systemRolePermissions(optional): the initial permissions of the built-in roles next to core’s, by key -ORG_ADMIN,UNIT_ADMIN,USER(e.g.{ USER: ['Todos.Read', 'Todos.Update'], UNIT_ADMIN: ['Todos.Read', 'Todos.Update', 'Todos.ReadAny', 'Todos.UpdateAny'], ORG_ADMIN: ['Todos.Read', 'Todos.Update', 'Todos.ReadAny', 'Todos.UpdateAny'] }; the*Anyones exist for an@Entity({ owned: true })controller). Must keepORG_ADMIN⊇UNIT_ADMIN⊇USERand no organization-wide-only permission inUNIT_ADMIN; a permission no route checks fails the boot. Used when a built-in role is created (SystemRolesSyncServicecreates missing ones at boot and never changes existing ones): afterwards the platform admin manages the system roles, and adds more, on/admin/roles(Permissions).billing(optional): Stripe subscriptions per organization - plans with limits and features, the default plan and a trial. Needs the Stripe variables; absent, nothing is limited. Setup: Billing; limits in your own code: Plans and limits.
Google sign-in is a per-product opt-in: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in apps/<p>/api/.env, with that product’s own OAuth client and redirect URI (Google login).
Environment: each product API reads apps/<name>/api/.env (gitignored; copy from .env.example). There is no root .env. The product’s Prisma config, seed and e2e setup load that file too. For product variables of your own, pass a zod schema: CoreModule.forRoot({ env: productEnvSchema }); it is validated together with the core variables (see Configuration).
Storage: the helpdesk (core, every product) stores screenshots through
@core/api-storage. Give each product its own place: its own STORAGE_DIR
(.env.example uses tmp/storage/<name>) or its own bucket. Production needs
STORAGE_DIR on a persistent volume or STORAGE_DRIVER=s3 (Helpdesk).
Client:
PRODUCT_NAV: sidebar links{ labelKey, path, permission?, units?, icon?, section? }.permissionmust be held org-wide, unlessunits: true, which also accepts it in any unit (pair it withunitPermissionGuardon the route; the API scopes the data). The first main item without a permission is also the home page (where login lands). An item’s label also titles the header on its pages (and their sub-paths); an item withouticonshows its first letter when the sidebar is collapsed.section: 'admin'puts it under the Administration heading instead: shown to whoever holds itspermission(platform admins only without one). It only places the link - a page for organization or unit admins goes inroutesbehindpermissionGuard/unitPermissionGuard;adminRouteslive under/adminand are platform-admin-only.coreRoutes({ product, routes, adminRoutes }): core’s auth, profile, admin, notifications and health routes plus the layout shell; yourroutesandadminRoutesrender inside it, before core’s**redirect.- i18n:
libs/<name>/client/i18n/{el,en}.json, merged over core’s, product keys winning. The el/en key-parity test covers both. - Branding: override design-system CSS tokens in the client’s
styles.css.
Deleting users
Core soft-deletes the user and its sessions. Product rows that have no foreign key to users are yours to clean up:
CoreModule.forRoot({ product: productConfig, prisma: createCrmPrisma, onUserSoftDeleted: (tx, userId) => softDeleteOrdersOf(tx, userId),});The hook runs inside the delete’s transaction (typed with the product’s own tx); throwing rolls the whole delete back. Keelmint’s softDeleteTodosOf is the example.
Keeping up with Keelmint
Your product lives in a fork of the Keelmint repo, so you can merge newer Keelmint releases. Keep apps/keelmint and libs/keelmint in the fork (hide them instead of deleting them) and never edit libs/core. The full workflow is in Updating.