feat: contact notes and split contact create/delete permissions

Merged
EV
14d ago
+4825-189

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 takes ORG_CONTACT_VIEW, writing takes ORG_CONTACT_EDIT, and the caller must be able to reach that contact.
  • Split contact permissions (#5468): new ORG_CONTACT_ADD (create) and ORG_CONTACT_DELETE (delete or withdraw). Each is an alternative to ORG_CONTACT_EDIT, never a replacement. Every role that holds ORG_CONTACT_EDIT keeps 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.
  • CommentParentAccessService discovers these providers through Nest's DiscoveryService, 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.
  • CommentService applies 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; a parent relation 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 organizationcontact therefore 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 id in 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 or CHANGE_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 a withDeleted read 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 any Comment found 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 (a select that leaves entity out) is kept only as an id.
      • Each record is checked once per read.
    • The GraphQL createComment mutation accepts only an exact BaseEntityEnum value as entity, as the REST route's DTO already does (@IsEnum(BaseEntityEnum)).
    • The conditions these reads add never put IsNull() inside And(...), because the MikroORM translation of And drops a part that translates to null. 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.

Contact notes policy

  • OrganizationContactCommentAccessPolicy governs OrganizationContact. Reading takes ORG_CONTACT_VIEW, writing takes ORG_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-contact and createOrganizationContact take ORG_CONTACT_ADD or ORG_CONTACT_EDIT.
  • DELETE /organization-contact/:id, DELETE /organization-contact/:id/soft, PUT /organization-contact/:id/recover and their GraphQL fields take ORG_CONTACT_DELETE or ORG_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_ADD would 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_EDIT and integrations create exactly as before.

Migration 1791000003870-GrantOrganizationContactAddDeletePermissions (Postgres, MySQL, SQLite)

  • Grants both new permissions to every role that holds ORG_CONTACT_EDIT today.
  • 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-op ON 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 entity of comments whose spelling the comment-access registry reads as OrganizationContact (such as organizationcontact) to the exact value. The reads that name no record then leave them out on every database, because PostgreSQL and SQLite compare the NOT IN exactly. 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_ADD and PermissionsEnum.ORG_CONTACT_DELETE, both in PermissionGroups.GENERAL.
  • Adds ORGANIZATION_CONTACT_CREATE_PERMISSIONS and ORGANIZATION_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-comments as ga-entity-comments. Its inputs are entity, entityId, entityName, organizationId and tenantId.
  • 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 for ORG_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 includes ORG_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_EDIT or ALL_ORG_EDIT). They now also show to holders of ORG_CONTACT_ADD who can open the contacts pages (ORG_CONTACT_VIEW). The rule is the mayOpenContactAddForm helper in @gauzy/ui-core/core.
  • projectsLinkableToNewContact in 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_NOTE and NOTE_DELETED.
  • Permission labels for ORG_CONTACT_ADD and ORG_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_EDIT the new permissions. Turning one off in Roles & Permissions removes nothing, because ORG_CONTACT_EDIT still 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):
    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
    
    Result: 21 suites passed, 351 tests passed. Jest prints its usual "worker failed to exit gracefully" notice.
  • App (shared comments component, contact Notes tab, contacts page actions, permission labels in all 14 languages, project page specs):
    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
    
    Result: 9 suites passed, 129 tests passed (after merging the integration branch).
  • ui-core (the add-form shortcut rule, the contact form's project-list rule, the quick actions and the header):
    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
    
    Result: 5 suites passed, 109 tests passed.
  • i18n: all 14 language files load with no duplicated key (checked with json.load and an object_pairs_hook).
  • Egress audit: node --test tools/egress-audit/static-hostnames.test.mjs passes (8 tests).
  • Type checks:
    • npx tsc -p packages/contracts/tsconfig.lib.json --noEmit, npx tsc -p packages/core/tsconfig.lib.json --noEmit and npx tsc -p apps/gauzy/tsconfig.app.json --noEmit all exit 0.
    • packages/ui-core/tsconfig.lib.json and packages/desktop-ui-lib/tsconfig.lib.json report only errors that are already on develop: spec files type-checked without jest types, and the google maps namespace. None of those errors is in a file this PR touches.
  • Lint: npx eslint over every changed .ts file. There are no errors on changed lines. The remaining warnings are any in specs, and the unused queryRunner of the no-op down(), which follows the convention of the other migrations.
  • Format: every new file passes prettier --check, and changed lines follow Prettier.

Manual verification still needed

  1. 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.
  2. With a role holding ORG_CONTACT_VIEW only: the tab shows notes without the composer. POST /api/comment with entity=OrganizationContact answers 403.
  3. With a role without ORG_CONTACT_VIEW: there is no Notes tab, and GET /api/comment?where[entity]=OrganizationContact&where[entityId]=<id> answers 403.
  4. A contact of another organization answers 404 on its notes.
  5. On the project page, the Comments tab behaves as before.
  6. Give a custom role ORG_CONTACT_ADD with ORG_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. With ORG_CONTACT_DELETE only, it can delete and restore a contact but not add or edit one.
  7. Run the migrations on Postgres and MySQL. Check that every role with ORG_CONTACT_EDIT now has both new permissions enabled, and that comments stored under another spelling of OrganizationContact now read OrganizationContact.

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: an entity that is not exactly a BaseEntityEnum value now answers 400, as POST /comment already 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

  1. ALL_ORG_EDIT and notes: notes accept exactly what the contact's own routes accept. Those routes take ORG_CONTACT_VIEW to list and ORG_CONTACT_EDIT to edit, and neither accepts ALL_ORG_EDIT, so notes don't either. A spec keeps the two equal.
  2. GraphQL comments list and MCP comment tools: a follow-up (see "Not in this PR").
  3. Restore: accepts ORG_CONTACT_DELETE as an alternative to ORG_CONTACT_EDIT, on REST and GraphQL, with specs.
  4. 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.
  5. Comments component: stays in apps/gauzy for now.

Not in this PR

  • The generic GraphQL comments connection 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 through GET /comment with entity and entityId. A follow-up can push a validated entity/entityId filter into the connection's service read.

Closes #1114
Closes #5468

šŸ¤– Generated with Claude Code