Skip to content

Updating

Keelmint keeps improving after you buy it. Because your product lives next to core instead of inside it, you can merge a newer release into your repo and keep your own code. Read this before your first update, and again whenever a release note mentions your database.

Set up once

Your product lives in your own repository, cloned from Keelmint, with Keelmint’s private repository keelmint/keelmint as a second remote named upstream (Quickstart shows the commands). If you cloned without renaming, add it now:

Terminal window
git remote add upstream https://github.com/keelmint/keelmint.git

Use a clone, not a GitHub fork: a fork of a private repository is deleted when you lose access to it. Your access to upstream lasts as long as your licence and your seat on it (the developers are managed on the claim page linked from your receipt); code you have already fetched stays yours.

To hear about new releases, open keelmint/keelmint on GitHub and choose Watch → Custom → Releases. Every release is a tag (v1.1.0) with release notes; anything that touches your database is also listed under Notes for existing databases.

Three habits keep merges clean:

  • Your code lives in apps/<product> and libs/<product>. Do not edit libs/core; if core needs a change, extend it from your product (config, hooks, your own libraries) or ask for the change upstream.
  • Keep apps/keelmint and libs/keelmint. They are the reference product and upstream keeps changing them; deleting them turns every later merge into modify/delete conflicts. To hide Keelmint, don’t serve, deploy or test it (--exclude='keelmint-*' in CI).
  • Expect conflicts in README.md and CLAUDE.md once you have edited them: keep your text and pick up what upstream added that you want.

Take a release

Terminal window
git fetch upstream --tags
git merge v1.1.0 # the release's tag (or upstream/main for the latest)
pnpm install

Then, for every product <p>:

Terminal window
pnpm nx run <p>-db:compose-schema
pnpm nx run <p>-db:migrate-dev # creates your product's migration for any core model change

Commit those migrations, run pnpm nx affected -t lint,test,build and each product’s e2e suite, and deploy as usual (the migrate step applies the new migrations before the API starts).

Notes for existing databases

Fresh installs need none of this. These notes are for databases created by an earlier version of Keelmint.

  • Upgrading to English as the default language (20261013133706_english_language_default): new users and invitations default to English (users.language and user_invitations.language default to EN; existing rows keep their language). Your product’s migration: run pnpm nx run <product>-db:migrate-dev, which creates the same two ALTER COLUMN ... SET DEFAULT 'EN' statements. Products that set defaultLanguage explicitly see no change at sign-up.
  • Billing arrived with an empty organization_subscriptions table: organizations that existed before it start on the default (free) plan. Give them a plan first: see Billing.
  • Upgrading to the same-organization triggers (20261012100000_same_organization): the database refuses a unit under another organization’s unit, and a membership or invitation given another organization’s role or unit (or PLATFORM_ADMIN). Your product’s migration: migrate-dev sees no schema change; create an empty one (pnpm prisma migrate dev --create-only --name same_organization --config libs/<product>/db/prisma.config.ts), paste libs/core/db/prisma/same-organization.sql into it and apply it. The triggers check new writes only: run the query below (and its unit and invitation variants) first, and fix any row it finds.
  • Upgrading to the tenant review fixes (no migration). Behaviour changes to check in your own clients:
    • On reporter routes (/api/support/tickets…) a staff author has no id, and one with no name shows as “Staff”, never their email.
    • GET /api/members?directUnit= from a unit admin, naming a unit outside their subtree, lists nobody.
    • Nobody takes away more than they hold: changing a member’s roles, removing a member and removing a permission from an organization role answer 403 PERMISSION_NOT_HELD when an assignment or permission taken away is one the caller does not hold (a platform admin has no limit).
    • POST /api/organization/billing/checkout and /portal refuse X-Organization-Id (400 ORGANIZATION_OVERRIDE_NOT_ALLOWED); the Billing tab shows a platform admin only the plan override.
    • A pending invitation whose inviter left, was removed or deleted their account is a dead link (400 INVALID_OR_EXPIRED_TOKEN). Resending an invitation makes the resender its inviter and needs them to hold what it grants (403 PERMISSION_NOT_HELD).
    • Token responses (login, refresh, organization switch, 2FA, Google) carry Cache-Control: no-store.
    • A role assigned in an organization other than its own, or the PLATFORM_ADMIN role, no longer reaches the access token’s permissions. The API never writes such an assignment; data or fixtures inserted by hand that did must use each organization’s own role (SELECT count(*) FROM membership_roles mr JOIN memberships m ON m.id = mr."membershipId" JOIN roles r ON r.id = mr."roleId" WHERE r."organizationId" <> m."organizationId" should be 0).
  • Upgrading to platform admins across organizations (no migration): platform admins no longer have an active organization and work across every organization; everyone gets unit and member filter headers (The platform admin, Tenant data). Behaviour changes to check in your own code and clients:
    • Login, refresh, 2FA and Google sign-in give a platform admin organization: null, whatever their memberships and lastOrganizationId; POST /api/auth/organization answers them 403 PLATFORM_ADMIN_NO_SWITCH. The client has no switcher, Enter button or no-organization page for them: they land on /users, /organization sends them to /organizations, and a notification opens without switching. In single mode they now see /organizations (the one organization).
    • On your @Entity routes a platform admin without X-Organization-Id reads, updates and deletes rows of every organization (a soft-deleted organization’s stay out); X-Organization-Id also takes a comma-separated list. Your list pages show them rows of several organizations: return the organization with each row (Keelmint’s todo rows gain organizationId and organizationName, GET /api/todos included) and add the level filters (Pages across organizations).
    • Creates need one organization: a platform admin’s create without exactly one X-Organization-Id is 400 ORGANIZATION_REQUIRED (it was 403 when they had no active organization), and so is any code that calls TenantContext.requireOrganizationId(). Give your create forms the organization picker. An update that names organizationId under the every-organization scope is refused.
    • X-Filter-Unit / X-Filter-Member are parsed on every route, for everyone: a malformed value (not uuids, more than 50) is 400 VALIDATION_FAILED, and on // @owned / // @unit models they narrow reads, updates and deletes. A client that sent headers with these names for its own purposes must rename them.
    • E2E helpers that switched a platform admin into an organization must send X-Organization-Id instead.
    • The audit log’s sidebar link no longer opens filtered to an organization for a platform admin.
  • Upgrading to admin pages per level (20261010090000_admin_levels): adds ticket_messages.staffScope (enum TicketStaffScope: PLATFORM, ORGANIZATION) and sets it to PLATFORM on every existing staff message; the built-in ORG_ADMIN gets Helpdesk.Read, Helpdesk.ReadOne, Helpdesk.Reply and Helpdesk.UpdateStatus, and UNIT_ADMIN those four and Audit.Read (once; a permission already held is not repeated; organization roles are left alone). Organization admins then answer their reporters’ tickets and unit admins read their people’s audit events (Admin pages per level). Your product’s migration: migrate-dev creates only the enum and the column; append Keelmint’s UPDATE statements (the backfill and the two role updates) to it by hand. The backfill keeps the data consistent (a staff message with no scope is read as a platform one, so earlier internal notes and their images stay hidden from organization staff either way); without the role updates, your admins get no helpdesk. Behaviour changes to check in your own clients:
    • GET /api/members?unit= now means members of that unit or its sub-units, and takes several values; the old meaning (linked to that unit itself) is directUnit. role takes several values too.
    • GET /api/auth/organizations lists only the caller’s memberships, platform admins included; a platform admin finds every organization through GET /api/organizations.
    • GET /api/users returns at most five organizations per user, plus organizationCount.
    • The organization filter of GET /api/audit-events and GET /api/helpdesk/tickets takes several values.
    • A ticket message’s author gains staffKind and organizationName; reporters see “Platform support” instead of “Support”.
    • The Users page moved from /admin/users to /users (the old path and /admin redirect there).
  • Upgrading to data ownership (20261009120000_todo_owned): todos become // @owned and lose their unit: the migration drops todos.unitId (with its foreign key and index), and the built-in ORG_ADMIN and UNIT_ADMIN get Todos.UpdateAny and Todos.DeleteAny (once; organization roles are left alone), so admins edit and delete their members’ todos. GET /api/todos/unit-options is gone. Your own // @unit models change too: a plain permission now reaches only the rows of the caller’s direct units, even when held organization-wide, and organization-level (no unit) rows only with an organization-wide *Any; a list that showed an org admin every row needs an @Action('ReadAny') route (or ReadOne/Update/Delete routes widened by the *Any), and a route without a permission (@Authenticated()) reads none of their rows (Tenant data). A product generated before this version must update its Prisma client factory (libs/<product>/db/src/lib/prisma.client.ts): run pnpm nx run <product>-db:compose-schema (it now writes OWNED_MODELS), call tenantScope(TENANT_MODELS) without UNIT_MODELS, and chain ownershipScope(OWNED_MODELS, UNIT_MODELS, ownershipLookups(base)) right after it in both the main chain and withDeleted, as in Keelmint’s factory. Dropping the old UNIT_MODELS argument alone leaves @unit rows unfiltered inside requests.
  • Upgrading to the role hierarchy (20261009100000_role_hierarchy): adds the built-in UNIT_ADMIN (a copy of ORG_ADMIN without the organization-wide-only permissions) and PLATFORM_ADMIN roles, and organizations.defaultRoleId. Every ORG_ADMIN assigned in a unit (memberships and pending invitations) becomes UNIT_ADMIN. In Keelmint, TODO_MEMBER assignments become USER (which gets the own-todo permissions) and TODO_SUPERVISOR becomes a role of each organization that used it. From this version the boot no longer resets the system roles to the code’s definitions: it only creates a missing built-in, and the platform admin manages them on /admin/roles. A product that defined systemRoles in its config: move those roles’ permissions to systemRolePermissions (USER, UNIT_ADMIN, ORG_ADMIN) or create them as system roles on /admin/roles, and move their assignments with a migration like Keelmint’s.
  • Upgrading to system and organization roles (20261008090000_organization_roles): every live role that is not USER or ORG_ADMIN and is in use becomes one copy per organization using it, with its assignments and pending invitations re-pointed; unused ones are retired. USER and ORG_ADMIN become system roles, and the first boot sets them to the code’s permissions: anything added to them by hand on the old Roles page is removed (logged as a warning and audited SYSTEM_ROLE_SYNCED). Before upgrading, check SELECT name, permissions FROM roles WHERE name IN ('USER','ORG_ADMIN'); give extra permissions back through an organization role. Two live roles whose names differ only in case, used in the same organization, make the migration fail on the new case-insensitive index: rename one first.
  • Organizations (a database from before multi-tenancy): after upgrading, the built-in default organization has no org-wide admin: the migration copies each user’s old roles into their default membership, and none of those is ORG_ADMIN (added by the same migration, held by nobody). The LAST_ORG_ADMIN rule does not trap anyone (an organization that has no admin cannot lose its last one), but until a platform admin opens default (Organizations) and assigns ORG_ADMIN to someone on its Members tab, only the platform admin can manage its members, units, invitations and settings.