@classytic/arc 2.10.8 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/{BaseController-DVNKvoX4.mjs → BaseController-JNV08qOT.mjs} +480 -442
  2. package/dist/{queryCachePlugin-Dumka73q.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
  3. package/dist/adapters/index.d.mts +2 -2
  4. package/dist/adapters/index.mjs +1 -1
  5. package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
  6. package/dist/audit/index.d.mts +1 -1
  7. package/dist/auth/index.d.mts +1 -1
  8. package/dist/auth/index.mjs +5 -5
  9. package/dist/{betterAuthOpenApi--rdY15Ld.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
  10. package/dist/cache/index.d.mts +3 -2
  11. package/dist/cache/index.mjs +3 -3
  12. package/dist/cli/commands/docs.mjs +2 -2
  13. package/dist/cli/commands/generate.mjs +37 -27
  14. package/dist/cli/commands/init.mjs +46 -33
  15. package/dist/cli/commands/introspect.mjs +1 -1
  16. package/dist/context/index.mjs +1 -1
  17. package/dist/core/index.d.mts +3 -3
  18. package/dist/core/index.mjs +4 -3
  19. package/dist/core-DXdSSFW-.mjs +1037 -0
  20. package/dist/createActionRouter-BwaSM0No.mjs +166 -0
  21. package/dist/{createApp-BwnEAO2h.mjs → createApp-DvNYEhpb.mjs} +75 -27
  22. package/dist/docs/index.d.mts +1 -1
  23. package/dist/docs/index.mjs +2 -2
  24. package/dist/{elevation-Dci0AYLT.mjs → elevation-DOFoxoDs.mjs} +1 -1
  25. package/dist/{errorHandler-CSxe7KIM.mjs → errorHandler-BQm8ZxTK.mjs} +1 -1
  26. package/dist/{eventPlugin-ByU4Cv0e.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
  27. package/dist/events/index.d.mts +3 -3
  28. package/dist/events/index.mjs +2 -2
  29. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  30. package/dist/factory/index.d.mts +1 -1
  31. package/dist/factory/index.mjs +2 -2
  32. package/dist/hooks/index.d.mts +1 -1
  33. package/dist/hooks/index.mjs +1 -1
  34. package/dist/idempotency/index.d.mts +3 -3
  35. package/dist/idempotency/index.mjs +1 -1
  36. package/dist/idempotency/redis.d.mts +1 -1
  37. package/dist/{index-C_Noptz-.d.mts → index-BYCqHCVu.d.mts} +2 -2
  38. package/dist/{index-BGbpGVyM.d.mts → index-Cm0vUrr_.d.mts} +699 -494
  39. package/dist/{index-BziRPS4H.d.mts → index-DAushRTt.d.mts} +29 -10
  40. package/dist/index-DsJ1MNfC.d.mts +1179 -0
  41. package/dist/{index-EqQN6p0W.d.mts → index-t8pLpPFW.d.mts} +11 -8
  42. package/dist/index.d.mts +6 -38
  43. package/dist/index.mjs +9 -9
  44. package/dist/integrations/event-gateway.d.mts +1 -1
  45. package/dist/integrations/event-gateway.mjs +1 -1
  46. package/dist/integrations/index.d.mts +2 -2
  47. package/dist/integrations/mcp/index.d.mts +2 -2
  48. package/dist/integrations/mcp/index.mjs +1 -1
  49. package/dist/integrations/mcp/testing.d.mts +1 -1
  50. package/dist/integrations/mcp/testing.mjs +1 -1
  51. package/dist/integrations/streamline.d.mts +46 -5
  52. package/dist/integrations/streamline.mjs +50 -21
  53. package/dist/integrations/websocket-redis.d.mts +1 -1
  54. package/dist/integrations/websocket.d.mts +2 -154
  55. package/dist/integrations/websocket.mjs +292 -224
  56. package/dist/{keys-nWQGUTu1.mjs → keys-CARyUjiR.mjs} +2 -0
  57. package/dist/{loadResources-Bksk8ydA.mjs → loadResources-YNwKHvRA.mjs} +3 -1
  58. package/dist/middleware/index.d.mts +1 -1
  59. package/dist/middleware/index.mjs +1 -1
  60. package/dist/{openapi-DpNpqBmo.mjs → openapi-C0L9ar7m.mjs} +4 -4
  61. package/dist/org/index.d.mts +1 -1
  62. package/dist/permissions/index.d.mts +1 -1
  63. package/dist/permissions/index.mjs +2 -4
  64. package/dist/{permissions-wkqRwicB.mjs → permissions-B4vU9L0Q.mjs} +221 -3
  65. package/dist/{pipe-CGJxqDGx.mjs → pipe-DVoIheVC.mjs} +1 -1
  66. package/dist/pipeline/index.d.mts +1 -1
  67. package/dist/pipeline/index.mjs +1 -1
  68. package/dist/plugins/index.d.mts +4 -4
  69. package/dist/plugins/index.mjs +10 -10
  70. package/dist/plugins/response-cache.mjs +1 -1
  71. package/dist/plugins/tracing-entry.d.mts +1 -1
  72. package/dist/plugins/tracing-entry.mjs +42 -24
  73. package/dist/presets/filesUpload.d.mts +1 -1
  74. package/dist/presets/filesUpload.mjs +3 -3
  75. package/dist/presets/index.d.mts +1 -1
  76. package/dist/presets/index.mjs +1 -1
  77. package/dist/presets/multiTenant.d.mts +1 -1
  78. package/dist/presets/multiTenant.mjs +6 -0
  79. package/dist/presets/search.d.mts +1 -1
  80. package/dist/presets/search.mjs +1 -1
  81. package/dist/{presets-CrwOvuXI.mjs → presets-k604Lj99.mjs} +1 -1
  82. package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
  83. package/dist/{queryCachePlugin-ChLNZvFT.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
  84. package/dist/{redis-MXLp1oOf.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
  85. package/dist/registry/index.d.mts +1 -1
  86. package/dist/registry/index.mjs +2 -2
  87. package/dist/{resourceToTools-BhF3JV5p.mjs → resourceToTools--okX6QBr.mjs} +534 -420
  88. package/dist/routerShared-DeESFp4a.mjs +515 -0
  89. package/dist/schemaIR-BlG9bY7v.mjs +137 -0
  90. package/dist/scope/index.mjs +2 -2
  91. package/dist/testing/index.d.mts +367 -711
  92. package/dist/testing/index.mjs +637 -1434
  93. package/dist/{tracing-xqXzWeaf.d.mts → tracing-DokiEsuz.d.mts} +9 -4
  94. package/dist/types/index.d.mts +3 -3
  95. package/dist/types/index.mjs +1 -3
  96. package/dist/{types-CVdgPXBW.d.mts → types-CgikqKAj.d.mts} +118 -19
  97. package/dist/{types-CVKBssX5.d.mts → types-D9NqiYIw.d.mts} +1 -1
  98. package/dist/utils/index.d.mts +2 -968
  99. package/dist/utils/index.mjs +5 -6
  100. package/dist/utils-D3Yxnrwr.mjs +1639 -0
  101. package/dist/websocket-CyJ1VIFI.d.mts +186 -0
  102. package/package.json +7 -5
  103. package/skills/arc/SKILL.md +123 -38
  104. package/skills/arc/references/testing.md +212 -183
  105. package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
  106. package/dist/core-3MWJosCH.mjs +0 -1459
  107. package/dist/createActionRouter-C8UUB3Px.mjs +0 -249
  108. package/dist/errors-BI8kEKsO.d.mts +0 -140
  109. package/dist/fields-CTMWOUDt.mjs +0 -126
  110. package/dist/queryParser-NR__Qiju.mjs +0 -419
  111. package/dist/types-CDnTEpga.mjs +0 -27
  112. package/dist/utils-LMwVidKy.mjs +0 -947
  113. /package/dist/{HookSystem-BjFu7zf1.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
  114. /package/dist/{ResourceRegistry-CcN2LVrc.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
  115. /package/dist/{actionPermissions-TUVR3uiZ.mjs → actionPermissions-C8YYU92K.mjs} +0 -0
  116. /package/dist/{caching-3h93rkJM.mjs → caching-CheW3m-S.mjs} +0 -0
  117. /package/dist/{errorHandler-2ii4RIYr.d.mts → errorHandler-Co3lnVmJ.d.mts} +0 -0
  118. /package/dist/{errors-BqdUDja_.mjs → errors-D5c-5BJL.mjs} +0 -0
  119. /package/dist/{eventPlugin-D1ThQ1Pp.d.mts → eventPlugin-CUNjYYRY.d.mts} +0 -0
  120. /package/dist/{interface-B-pe8fhj.d.mts → interface-CkkWm5uR.d.mts} +0 -0
  121. /package/dist/{interface-yhyb_pLY.d.mts → interface-Da0r7Lna.d.mts} +0 -0
  122. /package/dist/{memory-DqI-449b.mjs → memory-DikHSvWa.mjs} +0 -0
  123. /package/dist/{metrics-TuOmguhi.mjs → metrics-Csh4nsvv.mjs} +0 -0
  124. /package/dist/{multipartBody-CUQGVlM_.mjs → multipartBody-CvTR1Un6.mjs} +0 -0
  125. /package/dist/{pluralize-CWP6MB39.mjs → pluralize-BneOJkpi.mjs} +0 -0
  126. /package/dist/{redis-stream-bkO88VHx.d.mts → redis-stream-CM8TXTix.d.mts} +0 -0
  127. /package/dist/{registry-B0Wl7uVV.mjs → registry-D63ee7fl.mjs} +0 -0
  128. /package/dist/{replyHelpers-BLojtuvR.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
  129. /package/dist/{requestContext-C38GskNt.mjs → requestContext-CfRkaxwf.mjs} +0 -0
  130. /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
  131. /package/dist/{sse-D8UeDwis.mjs → sse-V7aXc3bW.mjs} +0 -0
  132. /package/dist/{store-helpers-DYYUQbQN.mjs → store-helpers-BhrzxvyQ.mjs} +0 -0
  133. /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
  134. /package/dist/{types-D57iXYb8.mjs → types-DV9WDfeg.mjs} +0 -0
  135. /package/dist/{versioning-B6mimogM.mjs → versioning-CGPjkqAg.mjs} +0 -0
  136. /package/dist/{versioning-CeUXHfjw.d.mts → versioning-M9lNLhO8.d.mts} +0 -0
@@ -1,12 +1,11 @@
1
1
  import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-BhY1OHoH.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
3
  import { _ as isElevated, n as PUBLIC_SCOPE, o as getOrgId, v as isMember } from "./types-AOD8fxIw.mjs";
4
- import { r as simpleEqualityMatcher, t as ArcQueryParser } from "./queryParser-NR__Qiju.mjs";
5
- import { t as buildQueryKey } from "./keys-nWQGUTu1.mjs";
6
- import { n as getUserId } from "./types-CDnTEpga.mjs";
7
- import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CTMWOUDt.mjs";
8
- import { t as getUserRoles } from "./types-D57iXYb8.mjs";
9
- import { r as ForbiddenError } from "./errors-BqdUDja_.mjs";
4
+ import { M as simpleEqualityMatcher, j as getUserId, k as ArcQueryParser } from "./utils-D3Yxnrwr.mjs";
5
+ import { t as buildQueryKey } from "./keys-CARyUjiR.mjs";
6
+ import { M as applyFieldWritePermissions, P as resolveEffectiveRoles } from "./permissions-B4vU9L0Q.mjs";
7
+ import { t as getUserRoles } from "./types-DV9WDfeg.mjs";
8
+ import { r as ForbiddenError } from "./errors-D5c-5BJL.mjs";
10
9
  //#region src/core/AccessControl.ts
11
10
  const log = arcLog("access-control");
12
11
  var AccessControl = class {
@@ -415,24 +414,23 @@ var QueryResolver = class {
415
414
  }
416
415
  };
417
416
  //#endregion
418
- //#region src/core/BaseController.ts
417
+ //#region src/core/BaseCrudController.ts
419
418
  /**
420
419
  * Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
421
- * available in Bun workers, Deno, Cloudflare Workers, or edge runtimes. Fall
422
- * back to queueMicrotask (universal) when setImmediate is absent.
420
+ * available in Bun workers, Deno, Cloudflare Workers, or edge runtimes.
423
421
  */
424
422
  const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
425
423
  /**
426
- * Framework-agnostic base controller implementing IController.
424
+ * Framework-agnostic CRUD controller implementing IController.
427
425
  *
428
- * Composes AccessControl, BodySanitizer, and QueryResolver for clean
429
- * separation of concerns. CRUD methods delegate directly to these
430
- * composed classes no intermediate wrapper methods.
426
+ * Composes AccessControl, BodySanitizer, and QueryResolver. All shared
427
+ * state and helpers are `protected` so the preset mixins (SoftDelete,
428
+ * Tree, Slug, Bulk) can extend cleanly.
431
429
  *
432
- * @template TDoc - The document type
433
- * @template TRepository - The repository type (defaults to RepositoryLike)
430
+ * @template TDoc - The document type.
431
+ * @template TRepository - The repository type (defaults to RepositoryLike).
434
432
  */
435
- var BaseController = class {
433
+ var BaseCrudController = class {
436
434
  repository;
437
435
  schemaOptions;
438
436
  queryParser;
@@ -447,7 +445,14 @@ var BaseController = class {
447
445
  accessControl;
448
446
  /** Composable body sanitization (field permissions, system fields) */
449
447
  bodySanitizer;
450
- /** Composable query resolution (parsing, pagination, sort, select/populate) */
448
+ /**
449
+ * Composable query resolution (parsing, pagination, sort, select/populate).
450
+ *
451
+ * Not `readonly` — `setQueryParser()` rebuilds this resolver to swap in a
452
+ * different parser (e.g. mongokit's `QueryParser`). `defineResource` calls
453
+ * it automatically when a resource supplies both `controller` and
454
+ * `queryParser`.
455
+ */
451
456
  queryResolver;
452
457
  _matchesFilter;
453
458
  _presetFields = {};
@@ -489,11 +494,33 @@ var BaseController = class {
489
494
  this.delete = this.delete.bind(this);
490
495
  }
491
496
  /**
492
- * Get the tenant field name if multi-tenant scoping is enabled.
493
- * Returns `undefined` when `tenantField` is `false` (platform-universal mode).
497
+ * Swap the controller's query parser. Rebuilds the internal `QueryResolver`
498
+ * with the new parser while preserving every other config.
494
499
  *
495
- * Use this in subclass overrides instead of accessing `this.tenantField` directly
496
- * to avoid TypeScript indexing errors with `string | false`.
500
+ * Closes the v2.10.9 gap where `defineResource({ controller, queryParser })`
501
+ * forwarded the parser only to auto-constructed controllers; user-supplied
502
+ * controllers kept their default `ArcQueryParser`. `defineResource` calls
503
+ * this via duck-typing when both `controller` and `queryParser` are
504
+ * supplied — controllers that don't implement `setQueryParser` are left
505
+ * untouched.
506
+ *
507
+ * Idempotent + safe to call repeatedly. Does NOT touch `maxLimit` or
508
+ * `defaultLimit` — those are construction-time decisions.
509
+ */
510
+ setQueryParser(queryParser) {
511
+ this.queryParser = queryParser;
512
+ this.queryResolver = new QueryResolver({
513
+ queryParser: this.queryParser,
514
+ maxLimit: this.maxLimit,
515
+ defaultLimit: this.defaultLimit,
516
+ defaultSort: this.defaultSort,
517
+ schemaOptions: this.schemaOptions,
518
+ tenantField: this.tenantField
519
+ });
520
+ }
521
+ /**
522
+ * Get the tenant field name if multi-tenant scoping is enabled.
523
+ * Returns `undefined` when `tenantField` is `false`.
497
524
  */
498
525
  getTenantField() {
499
526
  return this.tenantField || void 0;
@@ -501,31 +528,15 @@ var BaseController = class {
501
528
  /**
502
529
  * Build top-level tenant options to thread into the repository call.
503
530
  *
504
- * **Why this exists:** repo plugins (e.g. `@classytic/mongokit`'s
505
- * `multiTenantPlugin`) read tenant scope from the TOP of the repository
506
- * operation context — `context.organizationId`, not `context.data.organizationId`
507
- * or `context.context.organizationId`. Without this stamping, a tenant-scoped
508
- * repository throws `Missing 'organizationId' in context for '<op>'` even
509
- * though arc already injected the tenant into the request body.
510
- *
511
- * **What this returns:**
512
- * - `{ [tenantField]: orgId }` when the resource is tenant-scoped and the
513
- * caller's scope carries an org ID (member, service key bound to an org,
514
- * elevated admin impersonating an org).
515
- * - `{}` otherwise — platform-universal resources (`tenantField: false`),
516
- * public/anonymous reads, elevated admins without an org target.
517
- *
518
- * **Call sites:** every `this.repository.*` CRUD entry — `create`, `update`,
519
- * `delete`, `getAll` (via list), plus merged into `QueryOptions` for the
520
- * access-controlled read path (`accessControl.fetchDetailed` → `getById`/`getOne`).
521
- *
522
- * **Name of the field:** uses the instance's own `tenantField` configuration
523
- * (default `organizationId`). Matches mongokit's `multiTenantPlugin` default
524
- * `contextKey` so host apps don't need to override either side.
531
+ * Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant scope
532
+ * from the TOP of the operation context — `context.organizationId`, not
533
+ * `context.data.organizationId`. Without this stamping, a tenant-scoped
534
+ * repo throws "Missing 'organizationId' in context" even when arc has
535
+ * injected the tenant into the request body.
525
536
  *
526
- * Multi-field tenancy (via `multiTenantPreset({ tenantFields: [...] })`)
527
- * resolves additional fields at middleware time and stashes them on
528
- * `_tenantFields` {@link tenantRepoOptions} merges those in too.
537
+ * Returns `{ [tenantField]: orgId }` for tenant-scoped + org-carrying
538
+ * requests, `{}` otherwise. Merges multi-field tenancy from
539
+ * `_tenantFields` (populated by `multiTenantPreset`).
529
540
  */
530
541
  tenantRepoOptions(req) {
531
542
  const out = {};
@@ -549,20 +560,15 @@ var BaseController = class {
549
560
  return this.meta(req)?.arc?.hooks ?? null;
550
561
  }
551
562
  /**
552
- * Resolve the repository primary key for mutation calls (update/delete/restore).
563
+ * Resolve the repository primary key for mutation calls.
553
564
  *
554
- * When the resource declares a custom `idField` (e.g. `slug`, `jobId`, UUID),
555
- * the default behavior is to translate the route id → the fetched doc's `_id`
556
- * because most Mongo repositories key their mutation methods off `_id`.
565
+ * When the resource declares a custom `idField` (slug, jobId, UUID), the
566
+ * default behavior is to translate the route id → the fetched doc's `_id`
567
+ * because most Mongo repositories key mutation methods off `_id`.
557
568
  *
558
- * Exception: if the repository itself exposes a matching `idField` property
559
- * (e.g. MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
560
- * repository already knows how to look up by that field — so we pass the
561
- * route id through unchanged and skip the translation.
562
- *
563
- * This makes `defineResource({ idField: 'id' })` work end-to-end with repos
564
- * that natively support custom primary keys, without breaking the slug-style
565
- * aliasing that Arc 2.6.3 introduced for repos keyed on `_id`.
569
+ * Exception: if the repo exposes a matching `idField` property (e.g.
570
+ * MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
571
+ * repo handles lookup itself pass the route id through unchanged.
566
572
  */
567
573
  resolveRepoId(id, existing) {
568
574
  if (this.idField === "_id") return id;
@@ -574,11 +580,8 @@ var BaseController = class {
574
580
  /**
575
581
  * Centralized 404 response builder. Maps the denial reason from
576
582
  * `fetchDetailed()` into a structured `details.code` so consumers can
577
- * programmatically distinguish "doc doesn't exist" from "doc filtered
578
- * by policy/org scope" without parsing error strings.
579
- *
580
- * Error messages are intentionally vague in the `error` field (don't
581
- * leak whether the doc exists) — the detail is in `details.code` only.
583
+ * distinguish "doc doesn't exist" from "doc filtered by policy/org scope"
584
+ * without parsing error strings.
582
585
  */
583
586
  notFoundResponse(reason = "NOT_FOUND") {
584
587
  const code = reason ?? "NOT_FOUND";
@@ -606,8 +609,8 @@ var BaseController = class {
606
609
  }
607
610
  /**
608
611
  * Extract user/org IDs from request for cache key scoping.
609
- * Only includes orgId when this resource uses tenant-scoped data (tenantField is set).
610
- * Universal resources (tenantField: false) get shared cache keys to avoid fragmentation.
612
+ * Only includes orgId when the resource uses tenant-scoped data (tenantField is set).
613
+ * Universal resources (tenantField: false) get shared cache keys.
611
614
  */
612
615
  cacheScope(req) {
613
616
  return {
@@ -947,407 +950,442 @@ var BaseController = class {
947
950
  status: 200
948
951
  };
949
952
  }
950
- async getBySlug(req) {
951
- const slugField = this._presetFields.slugField ?? "slug";
952
- const slug = req.params[slugField] ?? req.params.slug;
953
- const options = {
954
- ...this.queryResolver.resolve(req, this.meta(req)),
955
- ...this.tenantRepoOptions(req)
956
- };
957
- const repo = this.repository;
958
- let item = null;
959
- if (repo.getBySlug) item = await repo.getBySlug(slug, options);
960
- else if (repo.getOne) {
961
- const filter = {
962
- [slugField]: slug,
963
- ...options?.filter ?? {}
953
+ };
954
+ //#endregion
955
+ //#region src/core/mixins/bulk.ts
956
+ /**
957
+ * BulkMixin — `bulkCreate` / `bulkUpdate` / `bulkDelete` endpoints.
958
+ *
959
+ * Security-critical: every bulk operation routes through the same write
960
+ * permissions, tenant scope, and policy filters as the single-doc paths.
961
+ * Cross-tenant writes are blocked at the controller layer regardless of
962
+ * what middleware the host has wired up.
963
+ *
964
+ * Per-doc lifecycle hooks (`before:create` / `after:create` / etc.) do
965
+ * NOT fire for bulk operations — use the single-doc path if you need
966
+ * them, or subscribe to the bulk lifecycle event from the events plugin.
967
+ *
968
+ * @example
969
+ * ```ts
970
+ * class OrderController extends BulkMixin(BaseCrudController<Order>) {}
971
+ * ```
972
+ */
973
+ function BulkMixin(Base) {
974
+ return class BulkController extends Base {
975
+ async bulkCreate(req) {
976
+ const repo = this.repository;
977
+ if (!repo.createMany) return {
978
+ success: false,
979
+ error: "Repository does not support createMany",
980
+ status: 501
964
981
  };
965
- item = await repo.getOne(filter, options);
966
- } else return {
967
- success: false,
968
- error: "Slug lookup not implemented — repository needs getBySlug() or getOne()",
969
- status: 501
970
- };
971
- if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
972
- return {
973
- success: true,
974
- data: item,
975
- status: 200
976
- };
977
- }
978
- async getDeleted(req) {
979
- const repo = this.repository;
980
- if (!repo.getDeleted) return {
981
- success: false,
982
- error: "Soft delete not implemented",
983
- status: 501
984
- };
985
- const parsed = this.queryResolver.resolve(req, this.meta(req));
986
- return {
987
- success: true,
988
- data: await repo.getDeleted(parsed, parsed),
989
- status: 200
990
- };
991
- }
992
- async restore(req) {
993
- const repo = this.repository;
994
- if (!repo.restore) return {
995
- success: false,
996
- error: "Restore not implemented",
997
- status: 501
998
- };
999
- const id = req.params.id;
1000
- if (!id) return {
1001
- success: false,
1002
- error: "ID parameter is required",
1003
- status: 400
1004
- };
1005
- const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
1006
- if (!existing) return this.notFoundResponse("NOT_FOUND");
1007
- if (!this.accessControl.checkOwnership(existing, req)) return {
1008
- success: false,
1009
- error: "You do not have permission to restore this resource",
1010
- details: { code: "OWNERSHIP_DENIED" },
1011
- status: 403
1012
- };
1013
- const arcContext = this.meta(req);
1014
- const user = req.user;
1015
- const repoId = this.resolveRepoId(id, existing);
1016
- const hooks = this.getHooks(req);
1017
- if (hooks && this.resourceName) try {
1018
- await hooks.executeBefore(this.resourceName, "restore", existing, {
1019
- user,
1020
- context: arcContext,
1021
- meta: { id }
1022
- });
1023
- } catch (err) {
1024
- return {
982
+ const rawItems = req.body?.items;
983
+ if (!Array.isArray(rawItems) || rawItems.length === 0) return {
1025
984
  success: false,
1026
- error: "Hook execution failed",
1027
- details: {
1028
- code: "BEFORE_RESTORE_HOOK_ERROR",
1029
- message: err.message
1030
- },
985
+ error: "Bulk create requires a non-empty items array",
1031
986
  status: 400
1032
987
  };
1033
- }
1034
- const repoRestore = () => repo.restore(repoId);
1035
- let item;
1036
- if (hooks && this.resourceName) item = await hooks.executeAround(this.resourceName, "restore", existing, repoRestore, {
1037
- user,
1038
- context: arcContext,
1039
- meta: { id }
1040
- });
1041
- else item = await repoRestore();
1042
- if (!item) return this.notFoundResponse("NOT_FOUND");
1043
- if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
1044
- user,
1045
- context: arcContext,
1046
- meta: { id }
1047
- });
1048
- return {
1049
- success: true,
1050
- data: item,
1051
- status: 200,
1052
- meta: { message: "Restored successfully" }
1053
- };
1054
- }
1055
- async getTree(req) {
1056
- const repo = this.repository;
1057
- if (!repo.getTree) return {
1058
- success: false,
1059
- error: "Tree structure not implemented",
1060
- status: 501
1061
- };
1062
- const options = this.queryResolver.resolve(req, this.meta(req));
1063
- return {
1064
- success: true,
1065
- data: await repo.getTree(options),
1066
- status: 200
1067
- };
1068
- }
1069
- async getChildren(req) {
1070
- const repo = this.repository;
1071
- if (!repo.getChildren) return {
1072
- success: false,
1073
- error: "Tree structure not implemented",
1074
- status: 501
1075
- };
1076
- const parentField = this._presetFields.parentField ?? "parent";
1077
- const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
1078
- const options = this.queryResolver.resolve(req, this.meta(req));
1079
- return {
1080
- success: true,
1081
- data: await repo.getChildren(parentId, options),
1082
- status: 200
1083
- };
1084
- }
1085
- async bulkCreate(req) {
1086
- const repo = this.repository;
1087
- if (!repo.createMany) return {
1088
- success: false,
1089
- error: "Repository does not support createMany",
1090
- status: 501
1091
- };
1092
- const rawItems = req.body?.items;
1093
- if (!Array.isArray(rawItems) || rawItems.length === 0) return {
1094
- success: false,
1095
- error: "Bulk create requires a non-empty items array",
1096
- status: 400
1097
- };
1098
- const items = rawItems;
1099
- const arcContext = this.meta(req);
1100
- const user = req.user;
1101
- const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
1102
- let scopedItems = sanitizedItems;
1103
- if (this.tenantField) {
1104
- const scope = arcContext?._scope;
1105
- if (scope) {
1106
- if (scope.kind === "public") return {
1107
- success: false,
1108
- error: "Organization context required to bulk-create resources",
1109
- details: { code: "ORG_CONTEXT_REQUIRED" },
1110
- status: 403
1111
- };
1112
- if (!isElevated(scope)) {
1113
- const orgId = getOrgId(scope);
1114
- if (!orgId) return {
988
+ const items = rawItems;
989
+ const arcContext = this.meta(req);
990
+ const user = req.user;
991
+ const sanitizedItems = items.map((item) => this.bodySanitizer.sanitize(item ?? {}, "create", req, arcContext));
992
+ let scopedItems = sanitizedItems;
993
+ if (this.tenantField) {
994
+ const scope = arcContext?._scope;
995
+ if (scope) {
996
+ if (scope.kind === "public") return {
1115
997
  success: false,
1116
998
  error: "Organization context required to bulk-create resources",
1117
999
  details: { code: "ORG_CONTEXT_REQUIRED" },
1118
1000
  status: 403
1119
1001
  };
1120
- const tenantField = this.tenantField;
1121
- scopedItems = sanitizedItems.map((item) => ({
1122
- ...item,
1123
- [tenantField]: orgId
1124
- }));
1002
+ if (!isElevated(scope)) {
1003
+ const orgId = getOrgId(scope);
1004
+ if (!orgId) return {
1005
+ success: false,
1006
+ error: "Organization context required to bulk-create resources",
1007
+ details: { code: "ORG_CONTEXT_REQUIRED" },
1008
+ status: 403
1009
+ };
1010
+ const tenantField = this.tenantField;
1011
+ scopedItems = sanitizedItems.map((item) => ({
1012
+ ...item,
1013
+ [tenantField]: orgId
1014
+ }));
1015
+ }
1125
1016
  }
1126
1017
  }
1127
- }
1128
- const created = await repo.createMany(scopedItems, {
1129
- user,
1130
- context: arcContext
1131
- });
1132
- const requested = items.length;
1133
- const inserted = created.length;
1134
- const skipped = requested - inserted;
1135
- return {
1136
- success: true,
1137
- data: created,
1138
- status: skipped === 0 ? 201 : inserted === 0 ? 422 : 207,
1139
- meta: {
1140
- count: inserted,
1141
- requested,
1142
- inserted,
1143
- skipped,
1144
- ...skipped > 0 && {
1145
- partial: true,
1146
- reason: inserted === 0 ? "all_invalid" : "some_invalid"
1018
+ const created = await repo.createMany(scopedItems, {
1019
+ user,
1020
+ context: arcContext
1021
+ });
1022
+ const requested = items.length;
1023
+ const inserted = created.length;
1024
+ const skipped = requested - inserted;
1025
+ return {
1026
+ success: true,
1027
+ data: created,
1028
+ status: skipped === 0 ? 201 : inserted === 0 ? 422 : 207,
1029
+ meta: {
1030
+ count: inserted,
1031
+ requested,
1032
+ inserted,
1033
+ skipped,
1034
+ ...skipped > 0 && {
1035
+ partial: true,
1036
+ reason: inserted === 0 ? "all_invalid" : "some_invalid"
1037
+ }
1147
1038
  }
1039
+ };
1040
+ }
1041
+ /**
1042
+ * Build a tenant-scoped filter for bulk update/delete.
1043
+ *
1044
+ * Mirrors `AccessControl.buildIdFilter` semantics:
1045
+ * - Always merge `_policyFilters` (from permission middleware)
1046
+ * - `member` scope on a tenant resource → add org filter
1047
+ * - `elevated` scope → no org filter (admin cross-org operation)
1048
+ * - `public` scope on a tenant resource → deny
1049
+ * - No scope at all (unit tests) → leave filter unchanged
1050
+ *
1051
+ * Returns the merged filter, or `null` when access must be denied.
1052
+ */
1053
+ buildBulkFilter(userFilter, req) {
1054
+ const filter = { ...userFilter };
1055
+ const arcContext = this.meta(req);
1056
+ const policyFilters = arcContext?._policyFilters;
1057
+ if (policyFilters) Object.assign(filter, policyFilters);
1058
+ if (this.tenantField) {
1059
+ const scope = arcContext?._scope;
1060
+ if (!scope) return filter;
1061
+ if (scope.kind === "public") return null;
1062
+ if (isElevated(scope)) return filter;
1063
+ const orgId = getOrgId(scope);
1064
+ if (!orgId) return null;
1065
+ filter[this.tenantField] = orgId;
1148
1066
  }
1149
- };
1150
- }
1151
- /**
1152
- * Build a tenant-scoped filter for bulk update/delete.
1153
- *
1154
- * Mirrors `AccessControl.buildIdFilter` semantics for single-doc ops:
1155
- * - Always merge `_policyFilters` (from permission middleware)
1156
- * - When `tenantField` is set AND a `member` scope is present, add the
1157
- * org filter so cross-tenant data can't be touched.
1158
- * - When the scope is `elevated` (platform admin), no org filter is
1159
- * applied — admins can bulk-update across orgs intentionally.
1160
- * - When the scope is `public` on a tenant-scoped resource, deny.
1161
- * - When NO scope is present at all (e.g., direct controller calls in
1162
- * unit tests, or app routes without auth middleware), the controller
1163
- * stays lenient — it's the middleware layer's job to fail-close.
1164
- * Apps that want fail-close on bulk routes should run the multi-tenant
1165
- * preset middleware (or equivalent) ahead of these handlers.
1166
- *
1167
- * Returns the merged filter, or `null` when access must be denied.
1168
- */
1169
- buildBulkFilter(userFilter, req) {
1170
- const filter = { ...userFilter };
1171
- const arcContext = this.meta(req);
1172
- const policyFilters = arcContext?._policyFilters;
1173
- if (policyFilters) Object.assign(filter, policyFilters);
1174
- if (this.tenantField) {
1175
- const scope = arcContext?._scope;
1176
- if (!scope) return filter;
1177
- if (scope.kind === "public") return null;
1178
- if (isElevated(scope)) return filter;
1179
- const orgId = getOrgId(scope);
1180
- if (!orgId) return null;
1181
- filter[this.tenantField] = orgId;
1067
+ return filter;
1182
1068
  }
1183
- return filter;
1184
- }
1185
- /**
1186
- * Sanitize a bulk update data payload through the same write-permission
1187
- * pipeline as single-doc update(). Handles both shapes:
1188
- *
1189
- * - Flat: `{ name: 'x', status: 'y' }`
1190
- * - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 }, $unset: { tag: '' } }`
1191
- *
1192
- * For each operand, runs `bodySanitizer.sanitize('update', ...)` so that
1193
- * system fields, systemManaged/readonly/immutable rules, AND field-level
1194
- * write permissions are enforced. Without this, a tenant-scoped user could
1195
- * pass `{ $set: { organizationId: 'org-b' } }` to move records across orgs.
1196
- *
1197
- * Returns the sanitized payload along with the list of stripped fields for
1198
- * audit/error reporting.
1199
- */
1200
- sanitizeBulkUpdateData(data, req, arcContext) {
1201
- const stripped = /* @__PURE__ */ new Set();
1202
- const keys = Object.keys(data);
1203
- const operatorKeys = keys.filter((k) => k.startsWith("$"));
1204
- const flatKeys = keys.filter((k) => !k.startsWith("$"));
1205
- const isOperatorShape = operatorKeys.length > 0;
1206
- if (isOperatorShape && flatKeys.length > 0) return {
1207
- sanitized: {},
1208
- stripped: [],
1209
- mixedShape: true
1210
- };
1211
- if (!isOperatorShape) {
1212
- const before = new Set(Object.keys(data));
1213
- const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
1214
- for (const key of before) if (!(key in sanitized)) stripped.add(key);
1069
+ /**
1070
+ * Sanitize a bulk update data payload through the same write-permission
1071
+ * pipeline as single-doc update. Handles both shapes:
1072
+ * - Flat: `{ name: 'x', status: 'y' }`
1073
+ * - Mongo operator: `{ $set: { name: 'x' }, $inc: { views: 1 } }`
1074
+ *
1075
+ * Mixed shapes (operator + flat keys) are rejected — mongo silently
1076
+ * drops the flat keys in operator mode, which is a footgun.
1077
+ */
1078
+ sanitizeBulkUpdateData(data, req, arcContext) {
1079
+ const stripped = /* @__PURE__ */ new Set();
1080
+ const keys = Object.keys(data);
1081
+ const operatorKeys = keys.filter((k) => k.startsWith("$"));
1082
+ const flatKeys = keys.filter((k) => !k.startsWith("$"));
1083
+ const isOperatorShape = operatorKeys.length > 0;
1084
+ if (isOperatorShape && flatKeys.length > 0) return {
1085
+ sanitized: {},
1086
+ stripped: [],
1087
+ mixedShape: true
1088
+ };
1089
+ if (!isOperatorShape) {
1090
+ const before = new Set(Object.keys(data));
1091
+ const sanitized = this.bodySanitizer.sanitize(data, "update", req, arcContext);
1092
+ for (const key of before) if (!(key in sanitized)) stripped.add(key);
1093
+ return {
1094
+ sanitized,
1095
+ stripped: [...stripped],
1096
+ mixedShape: false
1097
+ };
1098
+ }
1099
+ const sanitized = {};
1100
+ for (const [op, operand] of Object.entries(data)) {
1101
+ if (!op.startsWith("$") || operand === null || typeof operand !== "object") {
1102
+ sanitized[op] = operand;
1103
+ continue;
1104
+ }
1105
+ const operandObj = operand;
1106
+ const before = new Set(Object.keys(operandObj));
1107
+ const sanitizedOperand = this.bodySanitizer.sanitize(operandObj, "update", req, arcContext);
1108
+ for (const key of before) if (!(key in sanitizedOperand)) stripped.add(key);
1109
+ if (Object.keys(sanitizedOperand).length > 0) sanitized[op] = sanitizedOperand;
1110
+ }
1215
1111
  return {
1216
1112
  sanitized,
1217
1113
  stripped: [...stripped],
1218
1114
  mixedShape: false
1219
1115
  };
1220
1116
  }
1221
- const sanitized = {};
1222
- for (const [op, operand] of Object.entries(data)) {
1223
- if (!op.startsWith("$") || operand === null || typeof operand !== "object") {
1224
- sanitized[op] = operand;
1225
- continue;
1226
- }
1227
- const operandObj = operand;
1228
- const before = new Set(Object.keys(operandObj));
1229
- const sanitizedOperand = this.bodySanitizer.sanitize(operandObj, "update", req, arcContext);
1230
- for (const key of before) if (!(key in sanitizedOperand)) stripped.add(key);
1231
- if (Object.keys(sanitizedOperand).length > 0) sanitized[op] = sanitizedOperand;
1117
+ async bulkUpdate(req) {
1118
+ const repo = this.repository;
1119
+ if (!repo.updateMany) return {
1120
+ success: false,
1121
+ error: "Repository does not support updateMany",
1122
+ status: 501
1123
+ };
1124
+ const body = req.body;
1125
+ if (!body.filter || Object.keys(body.filter).length === 0) return {
1126
+ success: false,
1127
+ error: "Bulk update requires a non-empty filter",
1128
+ status: 400
1129
+ };
1130
+ if (!body.data || Object.keys(body.data).length === 0) return {
1131
+ success: false,
1132
+ error: "Bulk update requires non-empty data",
1133
+ status: 400
1134
+ };
1135
+ const scopedFilter = this.buildBulkFilter(body.filter, req);
1136
+ if (scopedFilter === null) return {
1137
+ success: false,
1138
+ error: "Organization context required for bulk update",
1139
+ details: { code: "ORG_CONTEXT_REQUIRED" },
1140
+ status: 403
1141
+ };
1142
+ const arcContext = this.meta(req);
1143
+ const user = req.user;
1144
+ const { sanitized, stripped, mixedShape } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
1145
+ if (mixedShape) return {
1146
+ success: false,
1147
+ error: "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.",
1148
+ details: { code: "MIXED_UPDATE_SHAPE" },
1149
+ status: 400
1150
+ };
1151
+ if (Object.keys(sanitized).length === 0) return {
1152
+ success: false,
1153
+ error: "Bulk update payload contained only protected fields",
1154
+ details: {
1155
+ code: "ALL_FIELDS_STRIPPED",
1156
+ stripped
1157
+ },
1158
+ status: 400
1159
+ };
1160
+ return {
1161
+ success: true,
1162
+ data: await repo.updateMany(scopedFilter, sanitized, {
1163
+ user,
1164
+ context: arcContext
1165
+ }),
1166
+ status: 200,
1167
+ ...stripped.length > 0 && { meta: { stripped } }
1168
+ };
1232
1169
  }
1233
- return {
1234
- sanitized,
1235
- stripped: [...stripped],
1236
- mixedShape: false
1237
- };
1238
- }
1239
- async bulkUpdate(req) {
1240
- const repo = this.repository;
1241
- if (!repo.updateMany) return {
1242
- success: false,
1243
- error: "Repository does not support updateMany",
1244
- status: 501
1245
- };
1246
- const body = req.body;
1247
- if (!body.filter || Object.keys(body.filter).length === 0) return {
1248
- success: false,
1249
- error: "Bulk update requires a non-empty filter",
1250
- status: 400
1251
- };
1252
- if (!body.data || Object.keys(body.data).length === 0) return {
1253
- success: false,
1254
- error: "Bulk update requires non-empty data",
1255
- status: 400
1256
- };
1257
- const scopedFilter = this.buildBulkFilter(body.filter, req);
1258
- if (scopedFilter === null) return {
1259
- success: false,
1260
- error: "Organization context required for bulk update",
1261
- details: { code: "ORG_CONTEXT_REQUIRED" },
1262
- status: 403
1263
- };
1264
- const arcContext = this.meta(req);
1265
- const user = req.user;
1266
- const { sanitized, stripped, mixedShape } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
1267
- if (mixedShape) return {
1268
- success: false,
1269
- error: "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.",
1270
- details: { code: "MIXED_UPDATE_SHAPE" },
1271
- status: 400
1272
- };
1273
- if (Object.keys(sanitized).length === 0) return {
1274
- success: false,
1275
- error: "Bulk update payload contained only protected fields",
1276
- details: {
1277
- code: "ALL_FIELDS_STRIPPED",
1278
- stripped
1279
- },
1280
- status: 400
1281
- };
1282
- return {
1283
- success: true,
1284
- data: await repo.updateMany(scopedFilter, sanitized, {
1285
- user,
1170
+ /**
1171
+ * Bulk delete by `filter` or `ids`.
1172
+ *
1173
+ * Body shape (one of):
1174
+ * - `{ filter: { status: 'archived' } }` — delete by query filter
1175
+ * - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
1176
+ *
1177
+ * The `ids` form translates to `{ [idField]: { $in: ids } }` using the
1178
+ * resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
1179
+ * UUID). Tenant scope and policy filters are merged in either way.
1180
+ *
1181
+ * Both forms perform a single `repo.deleteMany()` DB call — no per-doc
1182
+ * fetch loop. Per-doc lifecycle hooks do NOT fire.
1183
+ */
1184
+ async bulkDelete(req) {
1185
+ const repo = this.repository;
1186
+ if (!repo.deleteMany) return {
1187
+ success: false,
1188
+ error: "Repository does not support deleteMany",
1189
+ status: 501
1190
+ };
1191
+ const body = req.body;
1192
+ let userFilter;
1193
+ if (body.ids && body.ids.length > 0) {
1194
+ if (body.filter && Object.keys(body.filter).length > 0) return {
1195
+ success: false,
1196
+ error: "Bulk delete accepts either `ids` or `filter`, not both",
1197
+ status: 400
1198
+ };
1199
+ userFilter = { [this.idField]: { $in: body.ids } };
1200
+ } else if (body.filter && Object.keys(body.filter).length > 0) userFilter = body.filter;
1201
+ else return {
1202
+ success: false,
1203
+ error: "Bulk delete requires a non-empty `filter` or `ids` array",
1204
+ status: 400
1205
+ };
1206
+ const scopedFilter = this.buildBulkFilter(userFilter, req);
1207
+ if (scopedFilter === null) return {
1208
+ success: false,
1209
+ error: "Organization context required for bulk delete",
1210
+ details: { code: "ORG_CONTEXT_REQUIRED" },
1211
+ status: 403
1212
+ };
1213
+ const hardHint = req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard";
1214
+ const arcContext = this.meta(req);
1215
+ const options = {
1216
+ user: req.user,
1286
1217
  context: arcContext
1287
- }),
1288
- status: 200,
1289
- ...stripped.length > 0 && { meta: { stripped } }
1290
- };
1291
- }
1292
- /**
1293
- * Bulk delete by `filter` or `ids`.
1294
- *
1295
- * Body shape (one of):
1296
- * - `{ filter: { status: 'archived' } }` — delete by query filter
1297
- * - `{ ids: ['id1', 'id2', 'id3'] }` — delete specific docs by id
1298
- *
1299
- * The `ids` form translates to `{ [idField]: { $in: ids } }` using the
1300
- * resource's `idField` (so it works with custom PKs like `slug`, `jobId`,
1301
- * UUID, etc.). Tenant scope and policy filters are merged in either way,
1302
- * so cross-tenant deletes are blocked at the controller layer.
1303
- *
1304
- * Both forms perform a single `repo.deleteMany()` DB call — no per-doc
1305
- * fetch loop. Per-doc lifecycle hooks (`before:delete`/`after:delete`) do
1306
- * NOT fire for bulk operations; use the single-doc `delete()` if you need
1307
- * them, or subscribe to the bulk lifecycle event from the events plugin.
1308
- */
1309
- async bulkDelete(req) {
1310
- const repo = this.repository;
1311
- if (!repo.deleteMany) return {
1312
- success: false,
1313
- error: "Repository does not support deleteMany",
1314
- status: 501
1315
- };
1316
- const body = req.body;
1317
- let userFilter;
1318
- if (body.ids && body.ids.length > 0) {
1319
- if (body.filter && Object.keys(body.filter).length > 0) return {
1218
+ };
1219
+ if (hardHint) options.mode = "hard";
1220
+ return {
1221
+ success: true,
1222
+ data: await repo.deleteMany(scopedFilter, options),
1223
+ status: 200
1224
+ };
1225
+ }
1226
+ };
1227
+ }
1228
+ //#endregion
1229
+ //#region src/core/mixins/slug.ts
1230
+ function SlugMixin(Base) {
1231
+ return class SlugController extends Base {
1232
+ async getBySlug(req) {
1233
+ const slugField = this._presetFields.slugField ?? "slug";
1234
+ const slug = req.params[slugField] ?? req.params.slug;
1235
+ const options = {
1236
+ ...this.queryResolver.resolve(req, this.meta(req)),
1237
+ ...this.tenantRepoOptions(req)
1238
+ };
1239
+ const repo = this.repository;
1240
+ let item = null;
1241
+ if (repo.getBySlug) item = await repo.getBySlug(slug, options);
1242
+ else if (repo.getOne) {
1243
+ const filter = {
1244
+ [slugField]: slug,
1245
+ ...options?.filter ?? {}
1246
+ };
1247
+ item = await repo.getOne(filter, options);
1248
+ } else return {
1249
+ success: false,
1250
+ error: "Slug lookup not implemented — repository needs getBySlug() or getOne()",
1251
+ status: 501
1252
+ };
1253
+ if (!this.accessControl.validateItemAccess(item, req)) return this.notFoundResponse(item ? "POLICY_FILTERED" : "NOT_FOUND");
1254
+ return {
1255
+ success: true,
1256
+ data: item,
1257
+ status: 200
1258
+ };
1259
+ }
1260
+ };
1261
+ }
1262
+ //#endregion
1263
+ //#region src/core/mixins/softDelete.ts
1264
+ function SoftDeleteMixin(Base) {
1265
+ return class SoftDeleteController extends Base {
1266
+ async getDeleted(req) {
1267
+ const repo = this.repository;
1268
+ if (!repo.getDeleted) return {
1269
+ success: false,
1270
+ error: "Soft delete not implemented",
1271
+ status: 501
1272
+ };
1273
+ const parsed = this.queryResolver.resolve(req, this.meta(req));
1274
+ return {
1275
+ success: true,
1276
+ data: await repo.getDeleted(parsed, parsed),
1277
+ status: 200
1278
+ };
1279
+ }
1280
+ async restore(req) {
1281
+ const repo = this.repository;
1282
+ if (!repo.restore) return {
1320
1283
  success: false,
1321
- error: "Bulk delete accepts either `ids` or `filter`, not both",
1284
+ error: "Restore not implemented",
1285
+ status: 501
1286
+ };
1287
+ const id = req.params.id;
1288
+ if (!id) return {
1289
+ success: false,
1290
+ error: "ID parameter is required",
1322
1291
  status: 400
1323
1292
  };
1324
- userFilter = { [this.idField]: { $in: body.ids } };
1325
- } else if (body.filter && Object.keys(body.filter).length > 0) userFilter = body.filter;
1326
- else return {
1327
- success: false,
1328
- error: "Bulk delete requires a non-empty `filter` or `ids` array",
1329
- status: 400
1330
- };
1331
- const scopedFilter = this.buildBulkFilter(userFilter, req);
1332
- if (scopedFilter === null) return {
1333
- success: false,
1334
- error: "Organization context required for bulk delete",
1335
- details: { code: "ORG_CONTEXT_REQUIRED" },
1336
- status: 403
1337
- };
1338
- const hardHint = req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard";
1339
- const arcContext = this.meta(req);
1340
- const options = {
1341
- user: req.user,
1342
- context: arcContext
1343
- };
1344
- if (hardHint) options.mode = "hard";
1345
- return {
1346
- success: true,
1347
- data: await repo.deleteMany(scopedFilter, options),
1348
- status: 200
1349
- };
1350
- }
1351
- };
1293
+ const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
1294
+ if (!existing) return this.notFoundResponse("NOT_FOUND");
1295
+ if (!this.accessControl.checkOwnership(existing, req)) return {
1296
+ success: false,
1297
+ error: "You do not have permission to restore this resource",
1298
+ details: { code: "OWNERSHIP_DENIED" },
1299
+ status: 403
1300
+ };
1301
+ const arcContext = this.meta(req);
1302
+ const user = req.user;
1303
+ const repoId = this.resolveRepoId(id, existing);
1304
+ const hooks = this.getHooks(req);
1305
+ if (hooks && this.resourceName) try {
1306
+ await hooks.executeBefore(this.resourceName, "restore", existing, {
1307
+ user,
1308
+ context: arcContext,
1309
+ meta: { id }
1310
+ });
1311
+ } catch (err) {
1312
+ return {
1313
+ success: false,
1314
+ error: "Hook execution failed",
1315
+ details: {
1316
+ code: "BEFORE_RESTORE_HOOK_ERROR",
1317
+ message: err.message
1318
+ },
1319
+ status: 400
1320
+ };
1321
+ }
1322
+ const repoRestore = () => repo.restore(repoId);
1323
+ let item;
1324
+ if (hooks && this.resourceName) item = await hooks.executeAround(this.resourceName, "restore", existing, repoRestore, {
1325
+ user,
1326
+ context: arcContext,
1327
+ meta: { id }
1328
+ });
1329
+ else item = await repoRestore();
1330
+ if (!item) return this.notFoundResponse("NOT_FOUND");
1331
+ if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
1332
+ user,
1333
+ context: arcContext,
1334
+ meta: { id }
1335
+ });
1336
+ return {
1337
+ success: true,
1338
+ data: item,
1339
+ status: 200,
1340
+ meta: { message: "Restored successfully" }
1341
+ };
1342
+ }
1343
+ };
1344
+ }
1345
+ //#endregion
1346
+ //#region src/core/mixins/tree.ts
1347
+ function TreeMixin(Base) {
1348
+ return class TreeController extends Base {
1349
+ async getTree(req) {
1350
+ const repo = this.repository;
1351
+ if (!repo.getTree) return {
1352
+ success: false,
1353
+ error: "Tree structure not implemented",
1354
+ status: 501
1355
+ };
1356
+ const options = this.queryResolver.resolve(req, this.meta(req));
1357
+ return {
1358
+ success: true,
1359
+ data: await repo.getTree(options),
1360
+ status: 200
1361
+ };
1362
+ }
1363
+ async getChildren(req) {
1364
+ const repo = this.repository;
1365
+ if (!repo.getChildren) return {
1366
+ success: false,
1367
+ error: "Tree structure not implemented",
1368
+ status: 501
1369
+ };
1370
+ const parentField = this._presetFields.parentField ?? "parent";
1371
+ const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
1372
+ const options = this.queryResolver.resolve(req, this.meta(req));
1373
+ return {
1374
+ success: true,
1375
+ data: await repo.getChildren(parentId, options),
1376
+ status: 200
1377
+ };
1378
+ }
1379
+ };
1380
+ }
1381
+ //#endregion
1382
+ //#region src/core/BaseController.ts
1383
+ /**
1384
+ * Fully-composed controller: `BaseCrudController` + SoftDelete + Tree +
1385
+ * Slug + Bulk. Drop-in replacement for the pre-2.11 god class. The
1386
+ * companion interface above gives every method full generic precision
1387
+ * on `TDoc` via declaration merging.
1388
+ */
1389
+ var BaseController = class extends SoftDeleteMixin(TreeMixin(SlugMixin(BulkMixin(BaseCrudController)))) {};
1352
1390
  //#endregion
1353
- export { AccessControl as i, QueryResolver as n, BodySanitizer as r, BaseController as t };
1391
+ export { BulkMixin as a, BodySanitizer as c, SlugMixin as i, AccessControl as l, TreeMixin as n, BaseCrudController as o, SoftDeleteMixin as r, QueryResolver as s, BaseController as t };