Skip to content

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.

PartPathTags
API appapps/<name>/api (+ api-e2e)scope:product:<name>
Client app(s)apps/<name>/client (more: apps/<name>/<client>)scope:product:<name>
API featureslibs/<name>/api/*scope:product:<name>
Client featureslibs/<name>/client/* (+ i18n/{el,en}.json)scope:product:<name>
Databaselibs/<name>/db (models, composed schema, migrations, seed)scope:product:<name>
Sharedlibs/<name>/shared (productConfig, types shared by api/client)scope:product:<name>
Corelibs/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.

KindFolder
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, storedirectives/, pipes/, services/, guards/, interceptors/, stores/
provide-* functions, route arraysproviders/, routes/
Nest controller, decorator, filter, strategy, listener, mapper, workercontrollers/, decorators/, filters/, strategies/, listeners/, mappers/, workers/
Request/response classesdto/
Mail templates, storage driverstemplates/, drivers/
Types onlymodels/
Plain functions and constantsutils/
Nest *.module.tssrc/lib itself

A sub-area keeps its name inside each kind folder (controllers/google/, services/google/, dto/google/).

Create one

Terminal window
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.ts with configureApp, .env.example, tsdown config) and apps/<name>/api-e2e (health, register, admin login);
  • apps/<name>/<client> (Angular app: provideCore, coreRoutes, proxy to the product API; client unless --client is given);
  • libs/<name>/db (product .prisma file, composed schema.prisma, prisma.config.ts, an init migration that includes the base roles and the built-in default organization, the six-line client factory, seed);
  • libs/<name>/shared (product.config.ts, side-effect-free @<name>/shared/config entry), libs/<name>/api and libs/<name>/client (README, i18n/{el,en}.json);
  • tools/db/init/<name>.sql (creates <name> and <name>_test), path aliases in tsconfig.base.json and the module-boundary constraint in eslint.config.mjs;
  • apps/<name>/api/Dockerfile, the app’s package.json and the prune targets, and deploy/<name>/ (Compose, Caddyfile, web image, .env.example), so the product deploys like keelmint (Deploy); the release workflow picks it up by its deploy/<name>/docker-compose.yml;
  • apps/<name>/README.md with the first-run steps.

Rules:

  • The name is kebab-case, is not core or tools, and is not an existing product. Long names are refused (<name>_test must 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 be postgres, template0 or template1.
  • Ports: API 3000 + 10·n, client 4200 + 10·n, e2e 3001 + 10·n, where n is the product’s index (keelmint is 0).

First run (also in apps/<name>/README.md):

Terminal window
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>.sql
pnpm nx run <name>-db:migrate
pnpm nx run <name>-db:seed
pnpm nx run-many -t serve -p <name>-api <name>-client

The 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

Terminal window
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:

Terminal window
docker compose exec db psql -U postgres -f /docker-entrypoint-initdb.d/<name>.sql

The 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 // @base for the shared fields (id, timestamps, isDeleted).
  • Tenant data: put // @tenant in 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 // @owned when each record belongs to one user (the model declares userId String; callers reach their own records, admins with a *Any permission their members’) or // @unit for an optional unit (callers reach their direct units’ rows, admins with a *Any the 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 @relations block. The name is the camelCase plural of the product model:
    • Order gives User.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: Person gives persons, Child gives childs.
    • Two relations from one model to the same core model need a relation name: @relation("assignee", ...) gives ordersAssignee. 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 generated schema.prisma and soft-delete-models.generated.ts, which also lists TENANT_MODELS and UNIT_MODELS), then pnpm nx run <name>-db:migrate-dev. Never edit a generated schema.prisma or inside a // @base block. pnpm nx run <name>-db:compose-schema-check fails when the committed output is stale (it runs in lint).
  • A change to a core model needs <p>-db:compose-schema and a migration in every product: run pnpm nx run <p>-db:compose-schema then pnpm nx run <p>-db:migrate-dev for each <p>, and commit each product’s migration. A product that skips this fails at runtime and in its compose-schema-check.
  • Soft delete: never hard delete users, roles, organizations, memberships, units or todos (and any @base model); use softDelete/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 organizationId and organizationName) 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: 400 ORGANIZATION_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-access
protected 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 in apps/<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 from src/index.html).
  • registration: 'invite-only': POST /api/auth/register answers 403 REGISTRATION_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. In single mode POST /api/organizations answers 409 SINGLE_ORGANIZATION_MODE. Details: Organizations and units.
  • organizationSignup: { maxPerUser } (optional, multi mode only): signed-in users create their own organizations and become their ORG_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 the unitTypes.<key> translation: core has branch and department; a product adds or overrides others in libs/<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 *Any ones exist for an @Entity({ owned: true }) controller). Must keep ORG_ADMIN ⊇ UNIT_ADMIN ⊇ USER and no organization-wide-only permission in UNIT_ADMIN; a permission no route checks fails the boot. Used when a built-in role is created (SystemRolesSyncService creates 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? }. permission must be held org-wide, unless units: true, which also accepts it in any unit (pair it with unitPermissionGuard on 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 without icon shows its first letter when the sidebar is collapsed. section: 'admin' puts it under the Administration heading instead: shown to whoever holds its permission (platform admins only without one). It only places the link - a page for organization or unit admins goes in routes behind permissionGuard/unitPermissionGuard; adminRoutes live under /admin and are platform-admin-only.
  • coreRoutes({ product, routes, adminRoutes }): core’s auth, profile, admin, notifications and health routes plus the layout shell; your routes and adminRoutes render 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.