feat(core): bulk creates for tags, tasks, expenses, time logs, organizations and users (single-create path per item; design-name build list → 0)
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.
| Name | REST | Per item | Grant | Cap |
|---|---|---|---|---|
bulkCreateTags | POST /tags/bulk | POST /tags pipe → TagService.create | ALL_ORG_EDIT or ORG_TAGS_ADD | 200 |
bulkCreateTasks | POST /tasks/bulk | TaskCreateCommand (numbering stays the handler's; items applied one at a time) | ALL_ORG_EDIT or ORG_TASK_ADD | 200 |
bulkCreateExpenses | POST /expense/bulk | ExpenseCreateCommand | ORG_EXPENSES_EDIT | 200 |
bulkCreateTimeLogs | POST /timesheet/time-log/bulk | policy guard asked again per item → route pipes → addManualTime (employee, permission, future-date and overlap rules) | ALLOW_MANUAL_TIME + OrganizationPermissionGuard | 200 |
bulkCreateOrganizations | POST /organization/bulk | OrganizationCreateCommand | ALL_ORG_EDIT | 20: each create seeds the organization's defaults |
bulkCreateUsers | POST /user/bulk | UserCreateCommand | ORG_USERS_EDIT | 50: 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 aSUPER_ADMIN_EDITholder 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: falsein@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):
@BulkOperationaccepts 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.
dryRunandmodeare 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(orresolver.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)
| Check | Result |
|---|---|
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 --noEmit | 0 errors |
| static gates | 30 run, failed: 0 (incl. check-graphql-schema, design-name-triage-check) |
| schema snapshot | regenerated; diff = the six mutations and their types |
Merge notes
schema.graphqland the design-name triage files also change in #10654 (Q12b, snapshot only) and #10644 (other lane, snapshot only). Whichever merges later regenerates the snapshot withschema-snapshot.ts --write.- The published copy of
design-name-triage.mdinever-co/ever-gauzy-docsis 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: trueis refused up front with400 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
@BulkOperationaccepts an any-of permission list, read the same way@Permissionsis.dryRunandmodeare refused before the first item; database errors surface by code, never by driver text.