feat: contact notes and split contact create/delete permissions
Merged
EV
14d ago+4825+4825+0123456789012345678901234567890123456789-189-189-012345678901234567890123456789
Summary
- Contact notes (#1114): the contact page gets a Notes tab. Notes are comments on the contact (
entity: OrganizationContact). They are listed newest first, with author and date, threaded replies and image attachments. The server checks the contact's own permissions on every read and write: reading takesORG_CONTACT_VIEW, writing takesORG_CONTACT_EDIT, and the caller must be able to reach that contact. - Split contact permissions (#5468): new
ORG_CONTACT_ADD(create) andORG_CONTACT_DELETE(delete or withdraw). Each is an alternative toORG_CONTACT_EDIT, never a replacement. Every role that holdsORG_CONTACT_EDITkeeps every right it has, and a migration grants both new permissions to those roles.
Why
- #1114 asks for notes on contacts. The generic comment feature (#1115) already stores comments per record, and the project page already has a comments timeline. This PR reuses both instead of adding a new store.
- #5468 asks for separate create/edit/delete permissions. Projects, tasks, teams and sprints have them already (#5485). Contacts were the remaining case named in the issue.
What changed
packages/core
Comment parent-access policies (new, reusable) in comment/parent-access/
- A provider decorated with
@CommentParentAccessPolicy()governs the comments of one record type. It states:- the permissions needed to read and to write them;
findAccessibleParent(id), which looks up a record the caller can reach.
CommentParentAccessServicediscovers these providers through Nest'sDiscoveryService, the same way the favorites discovery works. A plugin can govern its own record type the same way. The decorator and the types are exported from@gauzy/core.CommentServiceapplies the policies on every path, REST and GraphQL alike: list, count, page, read one, post, edit, withdraw, restore and remove.- Records without a policy behave exactly as before. Their reads gain an always-true
entity NOT IN (<governed types>)condition, and replies gain a parent lookup that only refuses when a governed record is involved. - The comments of a governed record are read one record at a time, so the record's permission check applies. Lists that do not name one record leave them out.
- A comment is stored in its record's organization, or in none when the record belongs to the whole tenant. A reply must stay in its record's thread. A reply states the comment it replies to by
parentId; aparentrelation given instead is read as the same id, so the thread check always applies. A governed type is compared as its policy names it, so a reply to a note stored under another spelling of the type is accepted. - An edit cannot move a governed comment to another record or organization. An edit also cannot move another record's comment onto a governed record.
- Record types are matched the way database collations compare text: case, accents, spaces, punctuation and ignorable characters are ignored. A stored row spelled
organizationcontacttherefore follows the contact policy, and a new comment must spell a governed type exactly as its policy does. - A read by id stays on the comment in the path. An
idin the criteria is pinned to it. - Asked with
withDeleted, a governed record's withdrawn comments are shown to their author, and to callers who hold the record's write permission orCHANGE_SELECTED_EMPLOYEE. Other readers see the comments that are not withdrawn, as before. When the read keeps withdrawn comments to their author, it states its criteria on the comments alone, loads no relations (relations,loadRelationIds,join) and names no other record, because what awithDeletedread joins includes every author's withdrawn rows. - Comments loaded beside the ones a read answers are kept to what the caller may read:
parent,replies, and anyCommentfound deeper in the result. This covers threads that crossed records before a record type had a policy.- A governed record's comment needs that record's read permission, on a record the caller reaches. A withdrawn one is kept only for its author or for a caller with the record's write permission.
- A refused single relation reads
null, and a refused list element is left out. A related comment loaded without its record type (aselectthat leavesentityout) is kept only as an id. - Each record is checked once per read.
- The GraphQL
createCommentmutation accepts only an exactBaseEntityEnumvalue asentity, as the REST route's DTO already does (@IsEnum(BaseEntityEnum)). - The conditions these reads add never put
IsNull()insideAnd(...), because the MikroORM translation ofAnddrops a part that translates tonull. Specs run the criteria through that translation (DB_ORM=mikro-orm). - The employee-filter bypass for governed reads runs in an isolated request-context store (
RequestContext.runIsolated), so it never reaches a comment read that another GraphQL field runs at the same time.
- Records without a policy behave exactly as before. Their reads gain an always-true
Contact notes policy
OrganizationContactCommentAccessPolicygovernsOrganizationContact. Reading takesORG_CONTACT_VIEW, writing takesORG_CONTACT_EDIT. These are exactly the permissions of the contact's own list/count and edit routes, and a spec reads the route metadata to keep them equal.- The contact is looked up through the contact service's own read, so its tenant and organization boundary applies. A contact the caller cannot reach answers 404; a missing permission answers 403.
- Every reader of a contact sees all of its notes. Lists of other comment types stay limited to the employee's own, as before.
Split contact permissions
POST /organization-contactandcreateOrganizationContacttakeORG_CONTACT_ADDorORG_CONTACT_EDIT.DELETE /organization-contact/:id,DELETE /organization-contact/:id/soft,PUT /organization-contact/:id/recoverand their GraphQL fields takeORG_CONTACT_DELETEorORG_CONTACT_EDIT. A restore undoes a withdrawal, so whoever may withdraw a contact may restore it.- Update stays
ORG_CONTACT_EDIT.
Create-only mode (for callers with ORG_CONTACT_ADD but not ORG_CONTACT_EDIT)
- The contact create is an upsert that cascades into related rows, so without this mode
ORG_CONTACT_ADDwould also let a caller edit. - In create-only mode the create cannot:
- name an existing contact or existing contact details;
- give its new contact details to an existing employee, candidate or contact (those links are the inverse side of a one-to-one, so saving them would re-point that record);
- attach existing invoices, payments, expenses, incomes or time logs;
- take over projects that already have a contact.
- Unassigned projects it names are linked by id only. Holders of
ORG_CONTACT_EDITand integrations create exactly as before.
Migration 1791000003870-GrantOrganizationContactAddDeletePermissions (Postgres, MySQL, SQLite)
- Grants both new permissions to every role that holds
ORG_CONTACT_EDITtoday. - Switches on the rows the role-permission reload writes switched off, but never rows a tenant admin attributed.
- Reads once per grant and writes in bounded multi-row batches (at most 900 bind parameters per statement, as the permission reload does). It runs before the API starts, so it never makes one round trip per role.
- Inserts are duplicate-safe, like the permission reload's: a grant another API process inserted after the read is kept as it is (
ON CONFLICT DO NOTHING; on MySQL, a no-opON DUPLICATE KEY UPDATE). The update that switches a row on repeats the conditions that chose it, so a decision recorded after the read is kept.
Migration 1791000003880-NormalizeOrganizationContactCommentType (Postgres, MySQL, SQLite)
- Rewrites the stored
entityof comments whose spelling the comment-access registry reads asOrganizationContact(such asorganizationcontact) to the exact value. The reads that name no record then leave them out on every database, because PostgreSQL and SQLite compare theNOT INexactly. On MySQL it also updates by the exact value, which reaches every spelling the column's collation finds equal. - New comments can no longer be stored under another spelling, so it runs once. Running it again changes nothing, and
down()is a no-op. - The registry and the migration share one spelling rule (
comment/parent-access/record-type-spelling.ts). down()is a no-op, like the platform's other permission migrations.
Defaults: SUPER_ADMIN and ADMIN get both new permissions for new tenants.
packages/contracts
- Adds
PermissionsEnum.ORG_CONTACT_ADDandPermissionsEnum.ORG_CONTACT_DELETE, both inPermissionGroups.GENERAL. - Adds
ORGANIZATION_CONTACT_CREATE_PERMISSIONSandORGANIZATION_CONTACT_DELETE_PERMISSIONS, so the API and every UI read one definition.
apps/gauzy
Reusable comments component
- The project page's Comments tab moves to
pages/shared/entity-commentsasga-entity-comments. Its inputs areentity,entityId,entityName,organizationIdandtenantId. - New optional inputs:
newestFirst: the composer goes above the list.canWrite: a reader sees the comments without the composer, reply or delete.labels: the wording of the empty state, placeholder, send button and delete toast.
- Box ids are unique per instance.
- The project page renders exactly as before. The component stays in the app because it builds on the project page's helpers and styles.
Contact page
- New Notes tab. Its notes are created and loaded on the tab's first opening, and kept when another tab opens. It shows for
ORG_CONTACT_VIEW; the composer shows forORG_CONTACT_EDIT. Both follow the permissions as they load, so on a direct link to the page the tab appears once the permissions arrive.
Contact form
- For a new contact created without
ORG_CONTACT_EDIT(create-only), the project list offers only projects that have no contact yet, the ones the server will link. The server still refuses any other project, and the form shows the server's message in its error toast.
Contacts list
- Each action has its own any-of permission list,
CONTACT_ACTION_PERMISSIONS, and every list includesORG_CONTACT_EDIT:- Add: ADD or EDIT.
- Delete: DELETE or EDIT.
- Edit and Invite: EDIT.
- View: also shown to the readers the contacts pages already admit (
ORG_CONTACT_VIEW,ALL_ORG_EDIT), so they can reach a contact's notes.
Roles & Permissions: both new permissions appear in the Sales section.
packages/ui-core and packages/desktop-ui-lib
- Inline create in the contact selector and the desktop timer's client selector follow the create list (ADD or EDIT).
- The header's "Create lead / customer / client" quick actions (and their configurable shortcuts, which follow that menu) and the sidebar's "+" links open a contacts page's add form. They keep showing to everyone who saw them before (
ORG_CONTACT_EDITorALL_ORG_EDIT). They now also show to holders ofORG_CONTACT_ADDwho can open the contacts pages (ORG_CONTACT_VIEW). The rule is themayOpenContactAddFormhelper in@gauzy/ui-core/core. projectsLinkableToNewContactin the same file is the contact form's project-list rule.
i18n (all 14 languages)
- New keys
CONTACTS_PAGE.NOTES,NO_NOTES,NOTE_PLACEHOLDER,ADD_NOTEandNOTE_DELETED. - Permission labels for
ORG_CONTACT_ADDandORG_CONTACT_DELETE, and the line under each toggle that says what it lets a role do (ORGANIZATIONS_PAGE.PERMISSIONS.DESCRIPTIONS).
e2e
- The SUPER_ADMIN and ADMIN expected-permission lists include the two new permissions.
Flags/settings and defaults
- No feature flag.
- The notes feature is additive: a new tab, and a new record type for comments.
- The permission split only adds alternatives: the routes accept what they accepted before, plus the new grants.
- The migration gives existing roles that hold
ORG_CONTACT_EDITthe new permissions. Turning one off in Roles & Permissions removes nothing, becauseORG_CONTACT_EDITstill allows the same actions.
Tests
Commands run locally on Windows with Node 26:
- Core (comment policies, contact notes policy, contact permissions, create-only mode, route gating, both migrations on SQLite):
Result: 21 suites passed, 351 tests passed. Jest prints its usual "worker failed to exit gracefully" notice.NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/core/jest.config.ts --rootDir packages/core \ src/lib/comment src/lib/organization-contact src/lib/shared/guards/mutating-route-permissions \ src/lib/database/organization-contact src/lib/core/context/request-context.spec.ts - App (shared comments component, contact Notes tab, contacts page actions, permission labels in all 14 languages, project page specs):
Result: 9 suites passed, 129 tests passed (after merging the integration branch).npx jest -c apps/gauzy/jest.config.ts --rootDir apps/gauzy \ src/app/pages/projects src/app/pages/contacts src/app/pages/shared src/app/pages/settings/roles-permissions - ui-core (the add-form shortcut rule, the contact form's project-list rule, the quick actions and the header):
Result: 5 suites passed, 109 tests passed.npx jest -c packages/ui-core/jest.config.ts --rootDir packages/ui-core core/src/lib/utils core/src/lib/services/quick-actions theme/src/lib/components/header - i18n: all 14 language files load with no duplicated key (checked with
json.loadand anobject_pairs_hook). - Egress audit:
node --test tools/egress-audit/static-hostnames.test.mjspasses (8 tests). - Type checks:
npx tsc -p packages/contracts/tsconfig.lib.json --noEmit,npx tsc -p packages/core/tsconfig.lib.json --noEmitandnpx tsc -p apps/gauzy/tsconfig.app.json --noEmitall exit 0.packages/ui-core/tsconfig.lib.jsonandpackages/desktop-ui-lib/tsconfig.lib.jsonreport only errors that are already ondevelop: spec files type-checked without jest types, and thegooglemaps namespace. None of those errors is in a file this PR touches.
- Lint:
npx eslintover every changed.tsfile. There are no errors on changed lines. The remaining warnings areanyin specs, and the unusedqueryRunnerof the no-opdown(), which follows the convention of the other migrations. - Format: every new file passes
prettier --check, and changed lines follow Prettier.
Manual verification still needed
- Open a contact and switch to the Notes tab. Add a note, reply to it, attach an image, delete it, reload, and check that newest-first order holds.
- With a role holding
ORG_CONTACT_VIEWonly: the tab shows notes without the composer.POST /api/commentwithentity=OrganizationContactanswers 403. - With a role without
ORG_CONTACT_VIEW: there is no Notes tab, andGET /api/comment?where[entity]=OrganizationContact&where[entityId]=<id>answers 403. - A contact of another organization answers 404 on its notes.
- On the project page, the Comments tab behaves as before.
- Give a custom role
ORG_CONTACT_ADDwithORG_CONTACT_VIEW. It can add a contact (with new contact details and unassigned projects), and the form's project list shows only unassigned projects. It cannot edit or delete a contact. WithORG_CONTACT_DELETEonly, it can delete and restore a contact but not add or edit one. - Run the migrations on Postgres and MySQL. Check that every role with
ORG_CONTACT_EDITnow has both new permissions enabled, and that comments stored under another spelling ofOrganizationContactnow readOrganizationContact.
Known limitations
- Create-only mode checks that each linked project has no contact, then saves. Two requests that run at the same moment can still both pass that check. Closing the gap needs a conditional claim inside one transaction across the project and contact writes. In the worst case, a project that was unassigned when checked ends up on a contact the caller just created.
Risk and rollback
- Comments: records without a policy keep today's behaviour, and the specs cover the project comments as a regression. The only governed type is
OrganizationContact, which had no UI before. - Existing comments on contacts: comments posted on a contact through the comments API before this change become visible as notes to everyone who can view that contact.
- GraphQL
createComment: anentitythat is not exactly aBaseEntityEnumvalue now answers 400, asPOST /commentalready did. The web app posts comments through REST. - Permissions: any-of lists. Nobody loses access, and the migration only inserts or enables rows.
- Rollback: revert the PR. The granted permission rows stay but are unused by the old code.
Decisions
ALL_ORG_EDITand notes: notes accept exactly what the contact's own routes accept. Those routes takeORG_CONTACT_VIEWto list andORG_CONTACT_EDITto edit, and neither acceptsALL_ORG_EDIT, so notes don't either. A spec keeps the two equal.- GraphQL
commentslist and MCP comment tools: a follow-up (see "Not in this PR"). - Restore: accepts
ORG_CONTACT_DELETEas an alternative toORG_CONTACT_EDIT, on REST and GraphQL, with specs. - Project list for create-only callers: done in the contact form. The server still refuses any project the caller can't link, and the form shows its message.
- Comments component: stays in
apps/gauzyfor now.
Not in this PR
- The generic GraphQL
commentsconnection reads with no criteria and filters in memory, so it never lists contact notes. The MCP comment tools leave them out too unless one contact is named. Notes are read throughGET /commentwithentityandentityId. A follow-up can push a validatedentity/entityIdfilter into the connection's service read.
š¤ Generated with Claude Code