feat(core): bulk creates for tags, tasks, expenses, time logs, organizations and users (single-create path per item; design-name build list → 0)

Merged
EV
11d ago
+4606-122

Follow-ups queue Q12a (owner, 2026-10-09: "build every promised-but-unserved name"). This delivers the last six design names in design-name-triage's build list: bulkCreateTags, bulkCreateTasks, bulkCreateExpenses, bulkCreateTimeLogs, bulkCreateOrganizations and bulkCreateUsers. BUILD_BASELINE goes from 6 to 0, so no promised name is left to build.

Shape

Each name is a REST POST …/bulk route plus a GraphQL mutation, both run by the platform's BulkExecutor from the route's own @BulkOperation. Every item goes through its resource's single-create path, never a bulk insert:

  • Over REST, each item runs the single route's own pipe objects (read from the route's metadata) and then its handler or command.
  • Over GraphQL, each item calls that resolver's own single mutation, so GraphQL bulk is never wider than REST.
NameRESTPer itemGrantCap
bulkCreateTagsPOST /tags/bulkPOST /tags pipe → TagService.createALL_ORG_EDIT or ORG_TAGS_ADD200
bulkCreateTasksPOST /tasks/bulkTaskCreateCommand (numbering stays the handler's; items applied one at a time)ALL_ORG_EDIT or ORG_TASK_ADD200
bulkCreateExpensesPOST /expense/bulkExpenseCreateCommandORG_EXPENSES_EDIT200
bulkCreateTimeLogsPOST /timesheet/time-log/bulkpolicy guard asked again per item → route pipes → addManualTime (employee, permission, future-date and overlap rules)ALLOW_MANUAL_TIME + OrganizationPermissionGuard200
bulkCreateOrganizationsPOST /organization/bulkOrganizationCreateCommandALL_ORG_EDIT20: each create seeds the organization's defaults
bulkCreateUsersPOST /user/bulkUserCreateCommandORG_USERS_EDIT50: role lookup and role-grant check per item

All six take an optional idempotency key, scoped <resource>.bulk.

Users: no privilege escalation. UserCreateCommand is where a role is granted. It drops a body id, pins role/roleId to one id (refusing a pair that disagrees), resolves the role in the caller's tenant, and refuses SUPER_ADMIN to a caller without SUPER_ADMIN_EDIT.

  • A batch runs that decision for every item, so it can grant no role the caller couldn't grant one user at a time.
  • An item asking for one is refused (403) while the others apply. The spec runs the real handler and assertCanAssignRoles, with a control showing a SUPER_ADMIN_EDIT holder succeeds.
  • The route's whitelist keeps password, hash, a stated id and a stated tenant out of the write, and the outcome names created users by id only.

atomic: true is refused for these six with 400 BULK_ATOMIC_UNSUPPORTED, before item 0, with nothing written.

  • Each item runs its resource's single-create path, which writes through its own repositories, so the batch cannot be rolled back as one.
  • Each route declares atomic: false in @BulkOperation, and the shared executor refuses, identically over REST and GraphQL. The Swagger and SDL descriptions say so.
  • Honouring atomic would need a process-wide transaction hook in core's database layer, so it was not shipped here. That work is preserved, unproposed, on feat/core-bulk-atomic-ambient-tx.

Kernel additions (api/bulk-create.ts, bulk-executor.service.ts):

  • @BulkOperation accepts an any-of permission list, like @Permissions.
  • Per-item single-route pipes.
  • Refusals keep their status, code and message. Database errors are reported by code, never by driver text.
  • dryRun and mode are refused before the first item.

Narrower than single calls in one case: the time-log REST bulk route keeps the single route's request-level guard. A caller with no employee record and no organization header is refused at that level, even when every item's organization allows manual time. It is never wider than single calls.

Red first

Each spec was run against develop's controller, resolver and module.

  • The six resources: every new spec fails. Tags 14/14, tasks 10/10, expenses 8/8, time logs 9/9, organizations 7/7, users 9/9, each with TypeError: controller.bulk is not a function (or resolver.bulkCreate… is not a function).
  • Kernel: "admits a caller holding any one grant of a declared list" fails with ApiException: Denied.
  • Atomic refusal: the 13 "refuses an atomic batch before writing anything…" specs fail on the previous head, which applied the batch and answered 200.

Verification (on develop e8d994f3140)

CheckResult
jest: every changed spec, every bulk-kernel spec, all specs of the six modules and api/52 suites, 962 tests: 961 passed. The failure was a 5 s timeout in user.service.published-passwords.spec.ts (bcrypt alongside another worker; untouched by this PR), which passes 13/13 on its own
full core jest (earlier head, same hand-written code)614 suites green
tsc -p packages/core/tsconfig.lib.json --noEmit0 errors
static gates30 run, failed: 0 (incl. check-graphql-schema, design-name-triage-check)
schema snapshotregenerated; diff = the six mutations and their types

Merge notes

  • schema.graphql and the design-name triage files also change in #10654 (Q12b, snapshot only) and #10644 (other lane, snapshot only). Whichever merges later regenerates the snapshot with schema-snapshot.ts --write.
  • The published copy of design-name-triage.md in ever-co/ever-gauzy-docs is not part of this PR.

🤖 Generated with Claude Code


Summary by cubic

Adds bulk creates for tags, tasks, expenses, time logs, organizations, and users over REST (POST .../bulk) and GraphQL, clearing the last six names on the design-name triage build list (BUILD_BASELINE 6 → 0). Every item runs its resource's existing single-create path — same pipes, guards, permissions, and side effects — so a batch behaves like N one-by-one creates.

Batch behavior

  • atomic: true is refused up front with 400 BULK_ATOMIC_UNSUPPORTED: each item writes through its own repositories, so a batch can't be rolled back as one; batches apply what they can and report per-item outcomes.
  • Role checks run per item, so a user batch grants no role the caller couldn't grant one-off (a refused item is 403 while the rest apply).
  • Manual time now enforces the organization policy against the employee's organization the log is written under — not the body's or caller's — on REST and GraphQL, single and batch.
  • Caps: 200 per batch, 20 for organizations, 50 for users.

Kernel

  • @BulkOperation accepts an any-of permission list, read the same way @Permissions is.
  • dryRun and mode are refused before the first item; database errors surface by code, never by driver text.