@classytic/arc 2.11.3 → 2.13.1
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.
- package/README.md +27 -18
- package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
- package/dist/EventTransport-CT_52aWU.d.mts +34 -0
- package/dist/EventTransport-DLWoUMHy.mjs +103 -0
- package/dist/{QueryCache-DOBNHBE0.d.mts → QueryCache-D41bfdBB.d.mts} +1 -1
- package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +1 -1
- package/dist/auth/audit.d.mts +199 -0
- package/dist/auth/audit.mjs +288 -0
- package/dist/auth/index.d.mts +5 -5
- package/dist/auth/index.mjs +117 -191
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
- package/dist/buildHandler-olo-gt94.mjs +610 -0
- package/dist/cache/index.d.mts +3 -3
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/describe.d.mts +89 -13
- package/dist/cli/commands/describe.mjs +56 -2
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +147 -48
- package/dist/cli/commands/init.d.mts +13 -0
- package/dist/cli/commands/init.mjs +237 -112
- package/dist/cli/commands/introspect.mjs +8 -1
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +5 -5
- package/dist/core-D72ia0EH.mjs +1399 -0
- package/dist/{createActionRouter-u3ql2EDo.mjs → createActionRouter-CEvzKcy8.mjs} +7 -20
- package/dist/createAggregationRouter-CyecOxnO.mjs +114 -0
- package/dist/{createApp-BFxtdKy6.mjs → createApp-XX2-N0Yd.mjs} +31 -27
- package/dist/defineEvent-D5h7EvAx.mjs +188 -0
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
- package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
- package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
- package/dist/errors-j4aJm1Wg.mjs +184 -0
- package/dist/{eventPlugin-KrFIQ097.mjs → eventPlugin-CaKTYkYM.mjs} +35 -137
- package/dist/{eventPlugin-CUNjYYRY.d.mts → eventPlugin-qXpqTebY.d.mts} +57 -7
- package/dist/events/index.d.mts +164 -5
- package/dist/events/index.mjs +133 -209
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +204 -31
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +2 -2
- package/dist/{fields-C8Y0XLAu.d.mts → fields-COhcH3fk.d.mts} +23 -2
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +3 -3
- package/dist/idempotency/index.mjs +1 -20
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-BYCqHCVu.d.mts → index-BTqLEvhu.d.mts} +164 -4
- package/dist/{index-6u4_Gg6G.d.mts → index-BtW7qYwa.d.mts} +661 -281
- package/dist/{index-BdXnTPRj.d.mts → index-Ds61mrJE.d.mts} +50 -4
- package/dist/{index-DdQ3O9Pg.d.mts → index-Dz5IKsrE.d.mts} +360 -219
- package/dist/index.d.mts +6 -7
- package/dist/index.mjs +9 -10
- package/dist/integrations/event-gateway.d.mts +2 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +60 -11
- package/dist/integrations/streamline.mjs +75 -85
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +1 -1
- package/dist/integrations/websocket.mjs +2 -8
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +2 -2
- package/dist/migrations/index.d.mts +23 -3
- package/dist/migrations/index.mjs +0 -7
- package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
- package/dist/{openapi-BGUn7Ki1.mjs → openapi-CiOMVW1p.mjs} +143 -13
- package/dist/org/index.d.mts +2 -2
- package/dist/org/index.mjs +1 -1
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +3 -3
- package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
- package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +18 -33
- package/dist/plugins/index.mjs +33 -13
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +5 -5
- package/dist/presets/filesUpload.mjs +6 -9
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +2 -2
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +6 -8
- package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
- package/dist/{queryCachePlugin-BUXBSm4F.d.mts → queryCachePlugin-CqMdLI2-.d.mts} +2 -2
- package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
- package/dist/{redis-Cm1gnRDf.d.mts → redis-DiMkdHEl.d.mts} +1 -1
- package/dist/redis-stream-D6HzR1Z_.d.mts +232 -0
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
- package/dist/{resourceToTools-ByZpgjeH.mjs → resourceToTools-C5coh64w.mjs} +224 -71
- package/dist/{routerShared-BqLRb5l7.mjs → routerShared-D6_fEGHh.mjs} +40 -36
- package/dist/{schemaIR-BlG9bY7v.mjs → schemaIR-7Vl611Qs.mjs} +1 -1
- package/dist/schemas/index.d.mts +100 -30
- package/dist/schemas/index.mjs +86 -29
- package/dist/scim/index.d.mts +264 -0
- package/dist/scim/index.mjs +963 -0
- package/dist/scope/index.d.mts +3 -3
- package/dist/scope/index.mjs +4 -4
- package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
- package/dist/{store-helpers-BhrzxvyQ.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
- package/dist/testing/index.d.mts +2 -8
- package/dist/testing/index.mjs +16 -24
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/types/index.d.mts +4 -4
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-BH7dEGvU.d.mts → types-BvqwCCSx.d.mts} +77 -29
- package/dist/{types-tgR4Pt8F.d.mts → types-CTYvcwHe.d.mts} +195 -1
- package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
- package/dist/{types-9beEMe25.d.mts → types-DQHFc8PM.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +5 -5
- package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
- package/dist/{versioning-M9lNLhO8.d.mts → versioning-DTTvc80y.d.mts} +1 -1
- package/package.json +24 -34
- package/skills/arc/SKILL.md +521 -785
- package/skills/arc/references/agent-auth.md +238 -0
- package/skills/arc/references/api-reference.md +187 -0
- package/skills/arc/references/auth.md +354 -7
- package/skills/arc/references/enterprise-auth.md +94 -0
- package/skills/arc/references/events.md +8 -6
- package/skills/arc/references/mcp.md +2 -2
- package/skills/arc/references/multi-tenancy.md +11 -2
- package/skills/arc/references/production.md +10 -9
- package/skills/arc/references/scim.md +247 -0
- package/skills/arc/references/testing.md +1 -1
- package/skills/arc-code-review/SKILL.md +141 -0
- package/skills/arc-code-review/references/anti-patterns.md +911 -0
- package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
- package/skills/arc-code-review/references/migration-recipes.md +700 -0
- package/skills/arc-code-review/references/mongokit-migration.md +386 -0
- package/skills/arc-code-review/references/scaffolding.md +230 -0
- package/skills/arc-code-review/references/severity.md +127 -0
- package/dist/EventTransport-CfVEGaEl.d.mts +0 -293
- package/dist/adapters/index.d.mts +0 -3
- package/dist/adapters/index.mjs +0 -2
- package/dist/adapters-D0tT2Tyo.mjs +0 -949
- package/dist/auth/mongoose.d.mts +0 -191
- package/dist/auth/mongoose.mjs +0 -73
- package/dist/core-DnUsRpuX.mjs +0 -1049
- package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
- package/dist/errorHandler-Co3lnVmJ.d.mts +0 -114
- package/dist/errors-D5c-5BJL.mjs +0 -232
- package/dist/index-BbMrcvGp.d.mts +0 -362
- package/dist/redis-stream-CM8TXTix.d.mts +0 -110
- /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
- /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
- /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
- /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
- /package/dist/{elevation-s5ykdNHr.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
- /package/dist/{externalPaths-Bapitwvd.d.mts → externalPaths-BD5nw6St.d.mts} +0 -0
- /package/dist/{interface-CkkWm5uR.d.mts → interface-DfLGcus7.d.mts} +0 -0
- /package/dist/{interface-Da0r7Lna.d.mts → interface-beEtJyWM.d.mts} +0 -0
- /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
- /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
- /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
- /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
- /package/dist/{pluralize-BneOJkpi.mjs → pluralize-DQgqgifU.mjs} +0 -0
- /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
- /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
- /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
- /package/dist/{sessionManager-D-oNWHz3.d.mts → sessionManager-C4Le_UB3.d.mts} +0 -0
- /package/dist/{storage-BwGQXUpd.d.mts → storage-Dfzt4VTl.d.mts} +0 -0
- /package/dist/{tracing-DokiEsuz.d.mts → tracing-QJVprktp.d.mts} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
- /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
- /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
- /package/dist/{websocket-CyJ1VIFI.d.mts → websocket-ChC2rqe1.d.mts} +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-
|
|
1
|
+
import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-Cxde4rpC.mjs";
|
|
2
2
|
import { arcLog } from "./logger/index.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { t as
|
|
6
|
-
import {
|
|
7
|
-
import { t as getUserRoles } from "./types-
|
|
8
|
-
import {
|
|
3
|
+
import { d as createDomainError, f as createError, i as NotFoundError, r as ForbiddenError } from "./errors-j4aJm1Wg.mjs";
|
|
4
|
+
import { b as isMember, c as getOrgId, n as PUBLIC_SCOPE, y as isElevated } from "./types-C_s5moIu.mjs";
|
|
5
|
+
import { N as simpleEqualityMatcher, _ as ArcQueryParser, r as scheduleBackground, t as getUserId } from "./utils-_h9B3c57.mjs";
|
|
6
|
+
import { d as applyFieldWritePermissions, p as resolveEffectiveRoles } from "./permissions-ohQyv50e.mjs";
|
|
7
|
+
import { t as getUserRoles } from "./types-D57iXYb8.mjs";
|
|
8
|
+
import { t as buildQueryKey } from "./keys-CGcCbNyu.mjs";
|
|
9
9
|
//#region src/core/AccessControl.ts
|
|
10
10
|
const log = arcLog("access-control");
|
|
11
11
|
var AccessControl = class {
|
|
@@ -195,7 +195,10 @@ var AccessControl = class {
|
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
197
|
if (this.idField !== "_id") {
|
|
198
|
-
if (typeof repository.getOne !== "function") throw
|
|
198
|
+
if (typeof repository.getOne !== "function") throw createDomainError("arc.adapter.capability_required", `Resource with idField="${this.idField}" requires repository.getOne() to look up by custom field. Arc's BaseController cannot fall back to getById() because it would query by _id.`, 501, {
|
|
199
|
+
capability: "getOne",
|
|
200
|
+
idField: this.idField
|
|
201
|
+
});
|
|
199
202
|
}
|
|
200
203
|
const item = await repository.getById(id, queryOptions);
|
|
201
204
|
if (!item) return {
|
|
@@ -311,6 +314,15 @@ var QueryResolver = class {
|
|
|
311
314
|
this.tenantField = config.tenantField !== void 0 ? config.tenantField : DEFAULT_TENANT_FIELD;
|
|
312
315
|
}
|
|
313
316
|
/**
|
|
317
|
+
* Swap the underlying parser. Mutates in place so the resolver instance
|
|
318
|
+
* stays referentially stable (hosts capturing a `queryResolver` ref via
|
|
319
|
+
* `defineResource({ controller })` keep that ref valid). Single source of
|
|
320
|
+
* truth — pairs with `BaseCrudController.setQueryParser()`.
|
|
321
|
+
*/
|
|
322
|
+
setParser(parser) {
|
|
323
|
+
this.queryParser = parser;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
314
326
|
* Resolve a request into parsed query options -- ONE parse per request.
|
|
315
327
|
* Combines what was previously _buildContext + _parseQueryOptions + _applyFilters.
|
|
316
328
|
*/
|
|
@@ -416,11 +428,6 @@ var QueryResolver = class {
|
|
|
416
428
|
//#endregion
|
|
417
429
|
//#region src/core/BaseCrudController.ts
|
|
418
430
|
/**
|
|
419
|
-
* Portable "run on next tick" scheduler. `setImmediate` is Node-only — not
|
|
420
|
-
* available in Bun workers, Deno, Cloudflare Workers, or edge runtimes.
|
|
421
|
-
*/
|
|
422
|
-
const scheduleBackground = typeof setImmediate === "function" ? (cb) => void setImmediate(cb) : (cb) => queueMicrotask(cb);
|
|
423
|
-
/**
|
|
424
431
|
* Framework-agnostic CRUD controller implementing IController.
|
|
425
432
|
*
|
|
426
433
|
* Composes AccessControl, BodySanitizer, and QueryResolver. All shared
|
|
@@ -436,8 +443,6 @@ var BaseCrudController = class {
|
|
|
436
443
|
queryParser;
|
|
437
444
|
maxLimit;
|
|
438
445
|
defaultLimit;
|
|
439
|
-
/** `undefined` means "no default sort" (caller passed `false`). */
|
|
440
|
-
defaultSort;
|
|
441
446
|
resourceName;
|
|
442
447
|
tenantField;
|
|
443
448
|
idField = "_id";
|
|
@@ -448,7 +453,7 @@ var BaseCrudController = class {
|
|
|
448
453
|
/**
|
|
449
454
|
* Composable query resolution (parsing, pagination, sort, select/populate).
|
|
450
455
|
*
|
|
451
|
-
* Not `readonly`
|
|
456
|
+
* Not `readonly` — `setQueryParser()` rebuilds this resolver to swap in a
|
|
452
457
|
* different parser (e.g. mongokit's `QueryParser`). `defineResource` calls
|
|
453
458
|
* it automatically when a resource supplies both `controller` and
|
|
454
459
|
* `queryParser`.
|
|
@@ -463,7 +468,6 @@ var BaseCrudController = class {
|
|
|
463
468
|
this.queryParser = options.queryParser ?? getDefaultQueryParser();
|
|
464
469
|
this.maxLimit = options.maxLimit ?? 100;
|
|
465
470
|
this.defaultLimit = options.defaultLimit ?? 20;
|
|
466
|
-
this.defaultSort = options.defaultSort === false ? void 0 : options.defaultSort ?? "-createdAt";
|
|
467
471
|
this.resourceName = options.resourceName;
|
|
468
472
|
this.tenantField = options.tenantField !== void 0 ? options.tenantField : DEFAULT_TENANT_FIELD;
|
|
469
473
|
this.idField = options.idField ?? repository?.idField ?? "_id";
|
|
@@ -494,29 +498,19 @@ var BaseCrudController = class {
|
|
|
494
498
|
this.delete = this.delete.bind(this);
|
|
495
499
|
}
|
|
496
500
|
/**
|
|
497
|
-
* Swap the controller's query parser.
|
|
498
|
-
*
|
|
501
|
+
* Swap the controller's query parser. Mutates the existing `QueryResolver`
|
|
502
|
+
* in place via `QueryResolver.setParser()` — the resolver instance stays
|
|
503
|
+
* referentially stable, and there is no second copy of `defaultSort` /
|
|
504
|
+
* `tenantField` / `schemaOptions` for the swap to drift away from.
|
|
499
505
|
*
|
|
500
506
|
* Closes the v2.10.9 gap where `defineResource({ controller, queryParser })`
|
|
501
|
-
* forwarded the parser only to auto-constructed controllers
|
|
502
|
-
*
|
|
503
|
-
*
|
|
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.
|
|
507
|
+
* forwarded the parser only to auto-constructed controllers. `defineResource`
|
|
508
|
+
* calls this via duck-typing when both `controller` and `queryParser` are
|
|
509
|
+
* supplied; controllers that don't implement it are left untouched.
|
|
509
510
|
*/
|
|
510
511
|
setQueryParser(queryParser) {
|
|
511
512
|
this.queryParser = queryParser;
|
|
512
|
-
this.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
|
-
});
|
|
513
|
+
this.queryResolver.setParser(queryParser);
|
|
520
514
|
}
|
|
521
515
|
/**
|
|
522
516
|
* Get the tenant field name if multi-tenant scoping is enabled.
|
|
@@ -526,22 +520,46 @@ var BaseCrudController = class {
|
|
|
526
520
|
return this.tenantField || void 0;
|
|
527
521
|
}
|
|
528
522
|
/**
|
|
529
|
-
* Build
|
|
523
|
+
* Build the canonical repo-options bag from the Fastify request.
|
|
524
|
+
*
|
|
525
|
+
* Forwards the cross-kit canonical set (see repo-core's
|
|
526
|
+
* `STANDARD_REPO_OPTION_KEYS`) into every CRUD repo call so kit
|
|
527
|
+
* plugins (multi-tenant, audit, audit-trail, observability) get
|
|
528
|
+
* what they need without per-resource wiring:
|
|
529
|
+
*
|
|
530
|
+
* - **Tenant scope** — `[tenantField]: orgId` from `RequestScope`.
|
|
531
|
+
* Plugin-scoped repos (mongokit's `multiTenantPlugin`) read tenant
|
|
532
|
+
* scope from the TOP of the options bag, not `data.organizationId`.
|
|
533
|
+
* Without this stamping, a tenant-scoped repo throws "Missing
|
|
534
|
+
* 'organizationId' in context" even when arc has injected the
|
|
535
|
+
* tenant into the request body.
|
|
536
|
+
* Multi-field tenancy from `_tenantFields` (populated by
|
|
537
|
+
* `multiTenantPreset`) is merged in.
|
|
530
538
|
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
* `
|
|
534
|
-
*
|
|
535
|
-
* injected the tenant into the request body.
|
|
539
|
+
* - **Audit attribution** — `userId` + `user` from the authenticated
|
|
540
|
+
* actor. Mongokit's audit-log / audit-trail plugins read these
|
|
541
|
+
* into the `who` column; sqlitekit's audit plugin reads the same
|
|
542
|
+
* names. No host-side forwarding needed.
|
|
536
543
|
*
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
544
|
+
* - **Trace correlation** — `requestId` from Fastify's request id
|
|
545
|
+
* for stitching logs / events / downstream calls.
|
|
546
|
+
*
|
|
547
|
+
* - **`session` is intentionally NOT auto-set.** Sessions are tied
|
|
548
|
+
* to explicit transaction scopes the controller doesn't manage;
|
|
549
|
+
* pass `session` inline at the call site when running inside a
|
|
550
|
+
* `withTransaction` helper.
|
|
551
|
+
*
|
|
552
|
+
* Method kept named `tenantRepoOptions` for back-compat with hosts
|
|
553
|
+
* that spread `...this.tenantRepoOptions(req)` (10+ call sites in
|
|
554
|
+
* arc, plus host overrides). The bag has always grown over time —
|
|
555
|
+
* hosts that don't want audit forwarding never read those keys.
|
|
540
556
|
*/
|
|
541
557
|
tenantRepoOptions(req) {
|
|
558
|
+
const cached = req._tenantRepoOptions;
|
|
559
|
+
if (cached) return cached;
|
|
542
560
|
const out = {};
|
|
561
|
+
const scope = this.meta(req)?._scope;
|
|
543
562
|
if (this.tenantField) {
|
|
544
|
-
const scope = this.meta(req)?._scope;
|
|
545
563
|
const orgId = scope ? getOrgId(scope) : void 0;
|
|
546
564
|
if (orgId) out[this.tenantField] = orgId;
|
|
547
565
|
}
|
|
@@ -549,6 +567,13 @@ var BaseCrudController = class {
|
|
|
549
567
|
if (presetFields && typeof presetFields === "object") {
|
|
550
568
|
for (const [key, value] of Object.entries(presetFields)) if (value != null && out[key] == null) out[key] = value;
|
|
551
569
|
}
|
|
570
|
+
const userId = getUserId(req.user);
|
|
571
|
+
if (userId) out.userId = userId;
|
|
572
|
+
if (req.user) out.user = req.user;
|
|
573
|
+
const requestId = req.id;
|
|
574
|
+
if (requestId) out.requestId = requestId;
|
|
575
|
+
Object.freeze(out);
|
|
576
|
+
req._tenantRepoOptions = out;
|
|
552
577
|
return out;
|
|
553
578
|
}
|
|
554
579
|
/** Extract typed Arc internal metadata from request */
|
|
@@ -563,12 +588,12 @@ var BaseCrudController = class {
|
|
|
563
588
|
* Resolve the repository primary key for mutation calls.
|
|
564
589
|
*
|
|
565
590
|
* When the resource declares a custom `idField` (slug, jobId, UUID), the
|
|
566
|
-
* default behavior is to translate the route id
|
|
591
|
+
* default behavior is to translate the route id → the fetched doc's `_id`
|
|
567
592
|
* because most Mongo repositories key mutation methods off `_id`.
|
|
568
593
|
*
|
|
569
594
|
* Exception: if the repo exposes a matching `idField` property (e.g.
|
|
570
595
|
* MongoKit's `new Repository(Model, [], {}, { idField: 'id' })`), the
|
|
571
|
-
* repo handles lookup itself
|
|
596
|
+
* repo handles lookup itself — pass the route id through unchanged.
|
|
572
597
|
*/
|
|
573
598
|
resolveRepoId(id, existing) {
|
|
574
599
|
if (this.idField === "_id") return id;
|
|
@@ -578,24 +603,50 @@ var BaseCrudController = class {
|
|
|
578
603
|
return String(existing["_id"] ?? id);
|
|
579
604
|
}
|
|
580
605
|
/**
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
606
|
+
* Read-side preflight for mutable-target operations (`update`, `delete`).
|
|
607
|
+
*
|
|
608
|
+
* Bundles the four steps that every mutation must do before touching the
|
|
609
|
+
* repo: (1) extract `:id`, (2) fetch under access control + tenant scope,
|
|
610
|
+
* (3) verify ownership, (4) translate the route id to the repo's primary
|
|
611
|
+
* key. Returning `{id, existing, repoId}` keeps the call sites a single
|
|
612
|
+
* line and makes drift between `update` and `delete` structurally
|
|
613
|
+
* impossible — there is one preflight, one denial-reason mapping, one
|
|
614
|
+
* ownership check.
|
|
615
|
+
*
|
|
616
|
+
* Pass `extraFetchOptions` for callers (e.g. soft-delete restore) that
|
|
617
|
+
* need to widen the fetch (`{ includeDeleted: true }`).
|
|
585
618
|
*/
|
|
586
|
-
|
|
587
|
-
const
|
|
619
|
+
async loadMutableTarget(req, extraFetchOptions) {
|
|
620
|
+
const id = this.requireIdParam(req);
|
|
621
|
+
const baseOptions = this.tenantRepoOptions(req);
|
|
622
|
+
const fetchOptions = extraFetchOptions ? {
|
|
623
|
+
...baseOptions,
|
|
624
|
+
...extraFetchOptions
|
|
625
|
+
} : baseOptions;
|
|
626
|
+
const { doc, reason } = await this.accessControl.fetchDetailed(id, req, this.repository, fetchOptions);
|
|
627
|
+
if (!doc) this.throwNotFound(reason);
|
|
628
|
+
if (!this.accessControl.checkOwnership(doc, req)) throw new ForbiddenError("You do not have permission to modify this resource");
|
|
588
629
|
return {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
POLICY_FILTERED: "Resource not found",
|
|
593
|
-
ORG_SCOPE_DENIED: "Resource not found"
|
|
594
|
-
}[code] ?? "Resource not found",
|
|
595
|
-
status: 404,
|
|
596
|
-
details: { code }
|
|
630
|
+
id,
|
|
631
|
+
existing: doc,
|
|
632
|
+
repoId: this.resolveRepoId(id, doc)
|
|
597
633
|
};
|
|
598
634
|
}
|
|
635
|
+
/**
|
|
636
|
+
* Centralized 404 thrower. Maps the denial reason from `fetchDetailed()`
|
|
637
|
+
* into a `NotFoundError` so consumers can distinguish "doc doesn't
|
|
638
|
+
* exist" from "doc filtered by policy/org scope" via the error
|
|
639
|
+
* `details.code` set by the global error handler.
|
|
640
|
+
*/
|
|
641
|
+
throwNotFound(reason = "NOT_FOUND") {
|
|
642
|
+
const code = reason ?? "NOT_FOUND";
|
|
643
|
+
const err = new NotFoundError(this.resourceName ?? "Resource");
|
|
644
|
+
err.details = {
|
|
645
|
+
...err.details ?? {},
|
|
646
|
+
code
|
|
647
|
+
};
|
|
648
|
+
throw err;
|
|
649
|
+
}
|
|
599
650
|
/** Resolve cache config for a specific operation, merging per-op overrides */
|
|
600
651
|
resolveCacheConfig(operation) {
|
|
601
652
|
const cfg = this._cacheConfig;
|
|
@@ -621,47 +672,217 @@ var BaseCrudController = class {
|
|
|
621
672
|
})() : void 0
|
|
622
673
|
};
|
|
623
674
|
}
|
|
624
|
-
|
|
625
|
-
|
|
675
|
+
/** Shared `x-cache` response envelope builder. */
|
|
676
|
+
cacheResponse(data, cacheStatus) {
|
|
677
|
+
return {
|
|
678
|
+
data,
|
|
679
|
+
status: 200,
|
|
680
|
+
headers: { "x-cache": cacheStatus }
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/** Required route-id helper shared by get/update/delete. Throws on missing id. */
|
|
684
|
+
requireIdParam(req) {
|
|
685
|
+
const id = req.params.id;
|
|
686
|
+
if (!id) throw createError(400, "ID parameter is required");
|
|
687
|
+
return id;
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Normalizes `repo.exists()` return shapes across adapters. Per
|
|
691
|
+
* StandardRepo's contract, `exists` may return `boolean`, `{ _id }`,
|
|
692
|
+
* or `null` — every truthy non-null shape collapses to `true`.
|
|
693
|
+
*/
|
|
694
|
+
isExistsTruthy(result) {
|
|
695
|
+
return result !== null && result !== false && result !== void 0;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Run `executeBefore` then `executeAround` (or just the executor if no
|
|
699
|
+
* hooks are wired). Returns the around-phase result directly. Throws an
|
|
700
|
+
* `ArcError` (status 400, code `BEFORE_<OP>_HOOK_ERROR`) when the
|
|
701
|
+
* before-hook fails — the global error handler emits the canonical
|
|
702
|
+
* `ErrorContract` shape.
|
|
703
|
+
*
|
|
704
|
+
* The caller runs `executeAfter` separately via `runAfterHook` — typically
|
|
705
|
+
* after success-checking the result (delete checks `isDeleteSuccess`,
|
|
706
|
+
* update checks `if (!item)`).
|
|
707
|
+
*
|
|
708
|
+
* **Knobs:**
|
|
709
|
+
* - `meta` — passed verbatim into `executeBefore` / `executeAround` opts.
|
|
710
|
+
* - `pipeProcessedData` (default `true`) — whether `executeBefore`'s
|
|
711
|
+
* return value flows into `executeAround` as the data parameter.
|
|
712
|
+
* Set `false` for delete (current behaviour: discards before's
|
|
713
|
+
* return, passes original input to around).
|
|
714
|
+
*/
|
|
715
|
+
async runHookedOpUntilResult(req, args, executor) {
|
|
716
|
+
const hooks = this.getHooks(req);
|
|
717
|
+
const hookOpts = {
|
|
718
|
+
user: req.user,
|
|
719
|
+
context: this.meta(req),
|
|
720
|
+
...args.meta ? { meta: args.meta } : {}
|
|
721
|
+
};
|
|
722
|
+
const pipeProcessed = args.pipeProcessedData !== false;
|
|
723
|
+
let processedData = args.input;
|
|
724
|
+
if (hooks && this.resourceName) try {
|
|
725
|
+
const beforeReturn = await hooks.executeBefore(this.resourceName, args.op, args.input, hookOpts);
|
|
726
|
+
if (pipeProcessed) processedData = beforeReturn;
|
|
727
|
+
} catch (err) {
|
|
728
|
+
throw createError(400, "Hook execution failed", {
|
|
729
|
+
code: `BEFORE_${args.op.toUpperCase()}_HOOK_ERROR`,
|
|
730
|
+
message: err.message
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
let result;
|
|
734
|
+
if (hooks && this.resourceName) result = await hooks.executeAround(this.resourceName, args.op, processedData, () => executor(processedData), hookOpts);
|
|
735
|
+
else result = await executor(processedData);
|
|
736
|
+
return result;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Run `executeAfter` for the given op + data. No-op when hooks aren't
|
|
740
|
+
* wired or `resourceName` isn't set. Caller passes the data shape it
|
|
741
|
+
* wants downstream after-handlers to receive — typically the result for
|
|
742
|
+
* create/update, the original input (`existing`) for delete.
|
|
743
|
+
*/
|
|
744
|
+
async runAfterHook(req, op, data, meta) {
|
|
745
|
+
const hooks = this.getHooks(req);
|
|
746
|
+
if (!hooks || !this.resourceName) return;
|
|
747
|
+
const user = req.user;
|
|
748
|
+
const arcContext = this.meta(req);
|
|
749
|
+
await hooks.executeAfter(this.resourceName, op, data, {
|
|
750
|
+
user,
|
|
751
|
+
context: arcContext,
|
|
752
|
+
...meta ? { meta } : {}
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
/** Cached `list()` flow with SWR semantics. Returns null when cache is disabled. */
|
|
756
|
+
async withListCache(req, options) {
|
|
626
757
|
const cacheConfig = this.resolveCacheConfig("list");
|
|
627
758
|
const qc = req.server?.queryCache;
|
|
628
|
-
if (cacheConfig
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if (status === "stale") {
|
|
640
|
-
scheduleBackground(() => {
|
|
641
|
-
this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
|
|
642
|
-
});
|
|
643
|
-
return {
|
|
644
|
-
success: true,
|
|
645
|
-
data,
|
|
646
|
-
status: 200,
|
|
647
|
-
headers: { "x-cache": "STALE" }
|
|
648
|
-
};
|
|
649
|
-
}
|
|
650
|
-
const result = await this.executeListQuery(options, req);
|
|
651
|
-
await qc.set(key, result, cacheConfig);
|
|
652
|
-
return {
|
|
653
|
-
success: true,
|
|
654
|
-
data: result,
|
|
655
|
-
status: 200,
|
|
656
|
-
headers: { "x-cache": "MISS" }
|
|
657
|
-
};
|
|
759
|
+
if (!cacheConfig || !qc) return null;
|
|
760
|
+
const version = await qc.getResourceVersion(this.resourceName);
|
|
761
|
+
const { userId, orgId } = this.cacheScope(req);
|
|
762
|
+
const key = buildQueryKey(this.resourceName, "list", version, options, userId, orgId);
|
|
763
|
+
const { data, status } = await qc.get(key);
|
|
764
|
+
if (status === "fresh") return this.cacheResponse(data, "HIT");
|
|
765
|
+
if (status === "stale") {
|
|
766
|
+
scheduleBackground(() => {
|
|
767
|
+
this.executeListQuery(options, req).then((fresh) => qc.set(key, fresh, cacheConfig)).catch(() => {});
|
|
768
|
+
});
|
|
769
|
+
return this.cacheResponse(data, "STALE");
|
|
658
770
|
}
|
|
771
|
+
const result = await this.executeListQuery(options, req);
|
|
772
|
+
await qc.set(key, result, cacheConfig);
|
|
773
|
+
return this.cacheResponse(result, "MISS");
|
|
774
|
+
}
|
|
775
|
+
/** Cached `get()` flow with SWR semantics. Returns null when cache is disabled. */
|
|
776
|
+
async withGetCache(req, id, options) {
|
|
777
|
+
const cacheConfig = this.resolveCacheConfig("byId");
|
|
778
|
+
const qc = req.server?.queryCache;
|
|
779
|
+
if (!cacheConfig || !qc) return null;
|
|
780
|
+
const version = await qc.getResourceVersion(this.resourceName);
|
|
781
|
+
const { userId, orgId } = this.cacheScope(req);
|
|
782
|
+
const key = buildQueryKey(this.resourceName, "get", version, {
|
|
783
|
+
id,
|
|
784
|
+
...options
|
|
785
|
+
}, userId, orgId);
|
|
786
|
+
const { data, status } = await qc.get(key);
|
|
787
|
+
if (status === "fresh") return this.cacheResponse(data, "HIT");
|
|
788
|
+
if (status === "stale") {
|
|
789
|
+
scheduleBackground(() => {
|
|
790
|
+
this.executeGetQuery(id, options, req).then(({ doc: fresh }) => {
|
|
791
|
+
if (fresh) qc.set(key, fresh, cacheConfig);
|
|
792
|
+
}).catch(() => {});
|
|
793
|
+
});
|
|
794
|
+
return this.cacheResponse(data, "STALE");
|
|
795
|
+
}
|
|
796
|
+
const { doc, reason } = await this.executeGetQuery(id, options, req);
|
|
797
|
+
if (!doc) this.throwNotFound(reason);
|
|
798
|
+
await qc.set(key, doc, cacheConfig);
|
|
799
|
+
return this.cacheResponse(doc, "MISS");
|
|
800
|
+
}
|
|
801
|
+
async list(req) {
|
|
802
|
+
const dispatch = this.dispatchResourceVerb(req);
|
|
803
|
+
if (dispatch) return dispatch;
|
|
804
|
+
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
805
|
+
const cached = await this.withListCache(req, options);
|
|
806
|
+
if (cached) return cached;
|
|
659
807
|
return {
|
|
660
|
-
success: true,
|
|
661
808
|
data: await this.executeListQuery(options, req),
|
|
662
809
|
status: 200
|
|
663
810
|
};
|
|
664
811
|
}
|
|
812
|
+
/**
|
|
813
|
+
* Resource-dispatch verbs router. Returns `null` when the request is
|
|
814
|
+
* a regular list query, otherwise returns the dispatch promise.
|
|
815
|
+
*
|
|
816
|
+
* Verbs (mutually exclusive — first match wins):
|
|
817
|
+
* - `?_count=true` → `{ count: number }` via `repo.count()`
|
|
818
|
+
* - `?_distinct=field` → `unknown[]` via `repo.distinct(field)`
|
|
819
|
+
* - `?_exists=true` → `{ exists: boolean }` via `repo.exists()`
|
|
820
|
+
*
|
|
821
|
+
* All verbs share the resolved filter (parsed query + policy filters
|
|
822
|
+
* + tenant scope). Adapters that don't ship the underlying repo
|
|
823
|
+
* method get a `501` so failures surface loudly instead of falling
|
|
824
|
+
* back to a full table scan.
|
|
825
|
+
*/
|
|
826
|
+
dispatchResourceVerb(req) {
|
|
827
|
+
const query = req.query;
|
|
828
|
+
if (!query) return null;
|
|
829
|
+
const isTruthyFlag = (value) => value !== void 0 && value !== "" && value !== "false" && value !== false;
|
|
830
|
+
if (isTruthyFlag(query._count)) return this.dispatchCount(req);
|
|
831
|
+
const distinctField = query._distinct;
|
|
832
|
+
if (typeof distinctField === "string" && distinctField.length > 0) return this.dispatchDistinct(req, distinctField);
|
|
833
|
+
if (isTruthyFlag(query._exists)) return this.dispatchExists(req);
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
/** Resolve filter + tenant/audit options for a dispatch verb. */
|
|
837
|
+
resolveDispatchScope(req) {
|
|
838
|
+
return {
|
|
839
|
+
filter: this.queryResolver.resolve(req, this.meta(req)).filters ?? {},
|
|
840
|
+
options: this.tenantRepoOptions(req)
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/** `?_count=true` → `repo.count(filter)` */
|
|
844
|
+
async dispatchCount(req) {
|
|
845
|
+
const repo = this.repository;
|
|
846
|
+
if (typeof repo.count !== "function") throw createError(501, "_count is not supported: the resource's storage adapter does not implement repo.count()");
|
|
847
|
+
const { filter, options } = this.resolveDispatchScope(req);
|
|
848
|
+
return {
|
|
849
|
+
data: { count: await repo.count(filter, options) },
|
|
850
|
+
status: 200
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
/** `?_distinct=field` → `repo.distinct(field, filter)` */
|
|
854
|
+
async dispatchDistinct(req, field) {
|
|
855
|
+
if (!this.isFieldExposedForRead(field)) throw createError(400, `_distinct field "${field}" is not allowed (hidden or system-managed)`);
|
|
856
|
+
const repo = this.repository;
|
|
857
|
+
if (typeof repo.distinct !== "function") throw createError(501, "_distinct is not supported: the resource's storage adapter does not implement repo.distinct()");
|
|
858
|
+
const { filter, options } = this.resolveDispatchScope(req);
|
|
859
|
+
return {
|
|
860
|
+
data: await repo.distinct(field, filter, options),
|
|
861
|
+
status: 200
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
/** `?_exists=true` → `repo.exists(filter)` */
|
|
865
|
+
async dispatchExists(req) {
|
|
866
|
+
const repo = this.repository;
|
|
867
|
+
if (typeof repo.exists !== "function") throw createError(501, "_exists is not supported: the resource's storage adapter does not implement repo.exists()");
|
|
868
|
+
const { filter, options } = this.resolveDispatchScope(req);
|
|
869
|
+
const result = await repo.exists(filter, options);
|
|
870
|
+
return {
|
|
871
|
+
data: { exists: this.isExistsTruthy(result) },
|
|
872
|
+
status: 200
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* True when `field` is safe to expose via `_distinct`. Mirrors the
|
|
877
|
+
* `select` allowlist — fields marked `hidden` or `systemManaged` in
|
|
878
|
+
* `schemaOptions.fieldRules` are NOT exposed (would leak password
|
|
879
|
+
* hashes, internal flags, etc).
|
|
880
|
+
*/
|
|
881
|
+
isFieldExposedForRead(field) {
|
|
882
|
+
const rules = this.schemaOptions.fieldRules?.[field];
|
|
883
|
+
if (!rules) return true;
|
|
884
|
+
return !(rules.hidden || rules.systemManaged);
|
|
885
|
+
}
|
|
665
886
|
/** Execute list query through hooks (extracted for cache revalidation) */
|
|
666
887
|
async executeListQuery(options, req) {
|
|
667
888
|
const hooks = this.getHooks(req);
|
|
@@ -676,59 +897,16 @@ var BaseCrudController = class {
|
|
|
676
897
|
}) : await repoGetAll();
|
|
677
898
|
}
|
|
678
899
|
async get(req) {
|
|
679
|
-
const id = req
|
|
680
|
-
if (!id) return {
|
|
681
|
-
success: false,
|
|
682
|
-
error: "ID parameter is required",
|
|
683
|
-
status: 400
|
|
684
|
-
};
|
|
900
|
+
const id = this.requireIdParam(req);
|
|
685
901
|
const options = {
|
|
686
902
|
...this.queryResolver.resolve(req, this.meta(req)),
|
|
687
903
|
...this.tenantRepoOptions(req)
|
|
688
904
|
};
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
if (cacheConfig && qc) {
|
|
692
|
-
const version = await qc.getResourceVersion(this.resourceName);
|
|
693
|
-
const { userId, orgId } = this.cacheScope(req);
|
|
694
|
-
const key = buildQueryKey(this.resourceName, "get", version, {
|
|
695
|
-
id,
|
|
696
|
-
...options
|
|
697
|
-
}, userId, orgId);
|
|
698
|
-
const { data, status } = await qc.get(key);
|
|
699
|
-
if (status === "fresh") return {
|
|
700
|
-
success: true,
|
|
701
|
-
data,
|
|
702
|
-
status: 200,
|
|
703
|
-
headers: { "x-cache": "HIT" }
|
|
704
|
-
};
|
|
705
|
-
if (status === "stale") {
|
|
706
|
-
scheduleBackground(() => {
|
|
707
|
-
this.executeGetQuery(id, options, req).then(({ doc: fresh }) => {
|
|
708
|
-
if (fresh) qc.set(key, fresh, cacheConfig);
|
|
709
|
-
}).catch(() => {});
|
|
710
|
-
});
|
|
711
|
-
return {
|
|
712
|
-
success: true,
|
|
713
|
-
data,
|
|
714
|
-
status: 200,
|
|
715
|
-
headers: { "x-cache": "STALE" }
|
|
716
|
-
};
|
|
717
|
-
}
|
|
718
|
-
const { doc: cached, reason: cacheReason } = await this.executeGetQuery(id, options, req);
|
|
719
|
-
if (!cached) return this.notFoundResponse(cacheReason);
|
|
720
|
-
await qc.set(key, cached, cacheConfig);
|
|
721
|
-
return {
|
|
722
|
-
success: true,
|
|
723
|
-
data: cached,
|
|
724
|
-
status: 200,
|
|
725
|
-
headers: { "x-cache": "MISS" }
|
|
726
|
-
};
|
|
727
|
-
}
|
|
905
|
+
const cached = await this.withGetCache(req, id, options);
|
|
906
|
+
if (cached) return cached;
|
|
728
907
|
const { doc, reason } = await this.executeGetQuery(id, options, req);
|
|
729
|
-
if (!doc)
|
|
908
|
+
if (!doc) this.throwNotFound(reason);
|
|
730
909
|
return {
|
|
731
|
-
success: true,
|
|
732
910
|
data: doc,
|
|
733
911
|
status: 200
|
|
734
912
|
};
|
|
@@ -760,188 +938,71 @@ var BaseCrudController = class {
|
|
|
760
938
|
if (this.tenantField && createOrgId) data[this.tenantField] = createOrgId;
|
|
761
939
|
const userId = getUserId(req.user);
|
|
762
940
|
if (userId) data.createdBy = userId;
|
|
763
|
-
const hooks = this.getHooks(req);
|
|
764
941
|
const user = req.user;
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
context: arcContext
|
|
770
|
-
});
|
|
771
|
-
} catch (err) {
|
|
772
|
-
return {
|
|
773
|
-
success: false,
|
|
774
|
-
error: "Hook execution failed",
|
|
775
|
-
details: {
|
|
776
|
-
code: "BEFORE_CREATE_HOOK_ERROR",
|
|
777
|
-
message: err.message
|
|
778
|
-
},
|
|
779
|
-
status: 400
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
const repoCreate = async () => this.repository.create(processedData, {
|
|
942
|
+
const item = await this.runHookedOpUntilResult(req, {
|
|
943
|
+
op: "create",
|
|
944
|
+
input: data
|
|
945
|
+
}, async (processed) => this.repository.create(processed, {
|
|
783
946
|
user,
|
|
784
947
|
context: arcContext,
|
|
785
948
|
...this.tenantRepoOptions(req)
|
|
786
|
-
});
|
|
787
|
-
|
|
788
|
-
if (hooks && this.resourceName) {
|
|
789
|
-
item = await hooks.executeAround(this.resourceName, "create", processedData, repoCreate, {
|
|
790
|
-
user,
|
|
791
|
-
context: arcContext
|
|
792
|
-
});
|
|
793
|
-
await hooks.executeAfter(this.resourceName, "create", item, {
|
|
794
|
-
user,
|
|
795
|
-
context: arcContext
|
|
796
|
-
});
|
|
797
|
-
} else item = await repoCreate();
|
|
949
|
+
}));
|
|
950
|
+
await this.runAfterHook(req, "create", item);
|
|
798
951
|
return {
|
|
799
|
-
success: true,
|
|
800
952
|
data: item,
|
|
801
953
|
status: 201,
|
|
802
954
|
meta: { message: "Created successfully" }
|
|
803
955
|
};
|
|
804
956
|
}
|
|
805
957
|
async update(req) {
|
|
806
|
-
const id = req.params.id;
|
|
807
|
-
if (!id) return {
|
|
808
|
-
success: false,
|
|
809
|
-
error: "ID parameter is required",
|
|
810
|
-
status: 400
|
|
811
|
-
};
|
|
812
958
|
const arcContext = this.meta(req);
|
|
813
959
|
const data = this.bodySanitizer.sanitize(req.body ?? {}, "update", req, arcContext);
|
|
814
960
|
const user = req.user;
|
|
815
961
|
const userId = getUserId(user);
|
|
816
962
|
if (userId) data.updatedBy = userId;
|
|
817
|
-
const {
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
error: "You do not have permission to modify this resource",
|
|
822
|
-
details: { code: "OWNERSHIP_DENIED" },
|
|
823
|
-
status: 403
|
|
963
|
+
const { id, existing, repoId } = await this.loadMutableTarget(req);
|
|
964
|
+
const hookMeta = {
|
|
965
|
+
id,
|
|
966
|
+
existing
|
|
824
967
|
};
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
user,
|
|
831
|
-
context: arcContext,
|
|
832
|
-
meta: {
|
|
833
|
-
id,
|
|
834
|
-
existing
|
|
835
|
-
}
|
|
836
|
-
});
|
|
837
|
-
} catch (err) {
|
|
838
|
-
return {
|
|
839
|
-
success: false,
|
|
840
|
-
error: "Hook execution failed",
|
|
841
|
-
details: {
|
|
842
|
-
code: "BEFORE_UPDATE_HOOK_ERROR",
|
|
843
|
-
message: err.message
|
|
844
|
-
},
|
|
845
|
-
status: 400
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
const repoUpdate = async () => this.repository.update(repoId, processedData, {
|
|
968
|
+
const item = await this.runHookedOpUntilResult(req, {
|
|
969
|
+
op: "update",
|
|
970
|
+
input: data,
|
|
971
|
+
meta: hookMeta
|
|
972
|
+
}, async (processed) => this.repository.update(repoId, processed, {
|
|
849
973
|
user,
|
|
850
974
|
context: arcContext,
|
|
851
975
|
...this.tenantRepoOptions(req)
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
item = await hooks.executeAround(this.resourceName, "update", processedData, repoUpdate, {
|
|
856
|
-
user,
|
|
857
|
-
context: arcContext,
|
|
858
|
-
meta: {
|
|
859
|
-
id,
|
|
860
|
-
existing
|
|
861
|
-
}
|
|
862
|
-
});
|
|
863
|
-
if (item) await hooks.executeAfter(this.resourceName, "update", item, {
|
|
864
|
-
user,
|
|
865
|
-
context: arcContext,
|
|
866
|
-
meta: {
|
|
867
|
-
id,
|
|
868
|
-
existing
|
|
869
|
-
}
|
|
870
|
-
});
|
|
871
|
-
} else item = await repoUpdate();
|
|
872
|
-
if (!item) return this.notFoundResponse("NOT_FOUND");
|
|
976
|
+
}));
|
|
977
|
+
if (!item) this.throwNotFound("NOT_FOUND");
|
|
978
|
+
await this.runAfterHook(req, "update", item, hookMeta);
|
|
873
979
|
return {
|
|
874
|
-
success: true,
|
|
875
980
|
data: item,
|
|
876
981
|
status: 200,
|
|
877
982
|
meta: { message: "Updated successfully" }
|
|
878
983
|
};
|
|
879
984
|
}
|
|
880
985
|
async delete(req) {
|
|
881
|
-
const id = req.params.id;
|
|
882
|
-
if (!id) return {
|
|
883
|
-
success: false,
|
|
884
|
-
error: "ID parameter is required",
|
|
885
|
-
status: 400
|
|
886
|
-
};
|
|
887
986
|
const arcContext = this.meta(req);
|
|
888
987
|
const user = req.user;
|
|
889
|
-
const {
|
|
890
|
-
|
|
891
|
-
if (!this.accessControl.checkOwnership(existing, req)) return {
|
|
892
|
-
success: false,
|
|
893
|
-
error: "You do not have permission to delete this resource",
|
|
894
|
-
details: { code: "OWNERSHIP_DENIED" },
|
|
895
|
-
status: 403
|
|
896
|
-
};
|
|
897
|
-
const repoId = this.resolveRepoId(id, existing);
|
|
898
|
-
const hooks = this.getHooks(req);
|
|
899
|
-
if (hooks && this.resourceName) try {
|
|
900
|
-
await hooks.executeBefore(this.resourceName, "delete", existing, {
|
|
901
|
-
user,
|
|
902
|
-
context: arcContext,
|
|
903
|
-
meta: { id }
|
|
904
|
-
});
|
|
905
|
-
} catch (err) {
|
|
906
|
-
return {
|
|
907
|
-
success: false,
|
|
908
|
-
error: "Hook execution failed",
|
|
909
|
-
details: {
|
|
910
|
-
code: "BEFORE_DELETE_HOOK_ERROR",
|
|
911
|
-
message: err.message
|
|
912
|
-
},
|
|
913
|
-
status: 400
|
|
914
|
-
};
|
|
915
|
-
}
|
|
988
|
+
const { id, existing, repoId } = await this.loadMutableTarget(req);
|
|
989
|
+
const hookMeta = { id };
|
|
916
990
|
const deleteMode = req.query?.hard === "true" || req.query?.hard === true || req.body?.mode === "hard" ? "hard" : void 0;
|
|
917
|
-
const
|
|
991
|
+
const result = await this.runHookedOpUntilResult(req, {
|
|
992
|
+
op: "delete",
|
|
993
|
+
input: existing,
|
|
994
|
+
meta: hookMeta,
|
|
995
|
+
pipeProcessedData: false
|
|
996
|
+
}, async () => this.repository.delete(repoId, {
|
|
918
997
|
user,
|
|
919
998
|
context: arcContext,
|
|
920
999
|
...this.tenantRepoOptions(req),
|
|
921
1000
|
...deleteMode ? { mode: deleteMode } : {}
|
|
922
|
-
});
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
context: arcContext,
|
|
927
|
-
meta: { id }
|
|
928
|
-
});
|
|
929
|
-
else result = await repoDelete();
|
|
930
|
-
if (!(() => {
|
|
931
|
-
if (typeof result !== "object" || result === null) return !!result;
|
|
932
|
-
const r = result;
|
|
933
|
-
if (typeof r.success === "boolean") return r.success;
|
|
934
|
-
if (typeof r.deletedCount === "number") return r.deletedCount > 0;
|
|
935
|
-
return true;
|
|
936
|
-
})()) return this.notFoundResponse("NOT_FOUND");
|
|
937
|
-
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "delete", existing, {
|
|
938
|
-
user,
|
|
939
|
-
context: arcContext,
|
|
940
|
-
meta: { id }
|
|
941
|
-
});
|
|
942
|
-
const deleteResult = typeof result === "object" && result !== null ? result : {};
|
|
1001
|
+
}));
|
|
1002
|
+
if (!result) this.throwNotFound("NOT_FOUND");
|
|
1003
|
+
await this.runAfterHook(req, "delete", existing, hookMeta);
|
|
1004
|
+
const deleteResult = result;
|
|
943
1005
|
return {
|
|
944
|
-
success: true,
|
|
945
1006
|
data: {
|
|
946
1007
|
message: deleteResult.message || "Deleted successfully",
|
|
947
1008
|
...id ? { id } : {},
|
|
@@ -974,17 +1035,9 @@ function BulkMixin(Base) {
|
|
|
974
1035
|
return class BulkController extends Base {
|
|
975
1036
|
async bulkCreate(req) {
|
|
976
1037
|
const repo = this.repository;
|
|
977
|
-
if (!repo.createMany)
|
|
978
|
-
success: false,
|
|
979
|
-
error: "Repository does not support createMany",
|
|
980
|
-
status: 501
|
|
981
|
-
};
|
|
1038
|
+
if (!repo.createMany) throw createError(501, "Repository does not support createMany");
|
|
982
1039
|
const rawItems = req.body?.items;
|
|
983
|
-
if (!Array.isArray(rawItems) || rawItems.length === 0)
|
|
984
|
-
success: false,
|
|
985
|
-
error: "Bulk create requires a non-empty items array",
|
|
986
|
-
status: 400
|
|
987
|
-
};
|
|
1040
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) throw createError(400, "Bulk create requires a non-empty items array");
|
|
988
1041
|
const items = rawItems;
|
|
989
1042
|
const arcContext = this.meta(req);
|
|
990
1043
|
const user = req.user;
|
|
@@ -993,20 +1046,10 @@ function BulkMixin(Base) {
|
|
|
993
1046
|
if (this.tenantField) {
|
|
994
1047
|
const scope = arcContext?._scope;
|
|
995
1048
|
if (scope) {
|
|
996
|
-
if (scope.kind === "public")
|
|
997
|
-
success: false,
|
|
998
|
-
error: "Organization context required to bulk-create resources",
|
|
999
|
-
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1000
|
-
status: 403
|
|
1001
|
-
};
|
|
1049
|
+
if (scope.kind === "public") throw createError(403, "Organization context required to bulk-create resources", { code: "ORG_CONTEXT_REQUIRED" });
|
|
1002
1050
|
if (!isElevated(scope)) {
|
|
1003
1051
|
const orgId = getOrgId(scope);
|
|
1004
|
-
if (!orgId)
|
|
1005
|
-
success: false,
|
|
1006
|
-
error: "Organization context required to bulk-create resources",
|
|
1007
|
-
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1008
|
-
status: 403
|
|
1009
|
-
};
|
|
1052
|
+
if (!orgId) throw createError(403, "Organization context required to bulk-create resources", { code: "ORG_CONTEXT_REQUIRED" });
|
|
1010
1053
|
const tenantField = this.tenantField;
|
|
1011
1054
|
scopedItems = sanitizedItems.map((item) => ({
|
|
1012
1055
|
...item,
|
|
@@ -1023,7 +1066,6 @@ function BulkMixin(Base) {
|
|
|
1023
1066
|
const inserted = created.length;
|
|
1024
1067
|
const skipped = requested - inserted;
|
|
1025
1068
|
return {
|
|
1026
|
-
success: true,
|
|
1027
1069
|
data: created,
|
|
1028
1070
|
status: skipped === 0 ? 201 : inserted === 0 ? 422 : 207,
|
|
1029
1071
|
meta: {
|
|
@@ -1116,49 +1158,21 @@ function BulkMixin(Base) {
|
|
|
1116
1158
|
}
|
|
1117
1159
|
async bulkUpdate(req) {
|
|
1118
1160
|
const repo = this.repository;
|
|
1119
|
-
if (!repo.updateMany)
|
|
1120
|
-
success: false,
|
|
1121
|
-
error: "Repository does not support updateMany",
|
|
1122
|
-
status: 501
|
|
1123
|
-
};
|
|
1161
|
+
if (!repo.updateMany) throw createError(501, "Repository does not support updateMany");
|
|
1124
1162
|
const body = req.body;
|
|
1125
|
-
if (!body.filter || Object.keys(body.filter).length === 0)
|
|
1126
|
-
|
|
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
|
-
};
|
|
1163
|
+
if (!body.filter || Object.keys(body.filter).length === 0) throw createError(400, "Bulk update requires a non-empty filter");
|
|
1164
|
+
if (!body.data || Object.keys(body.data).length === 0) throw createError(400, "Bulk update requires non-empty data");
|
|
1135
1165
|
const scopedFilter = this.buildBulkFilter(body.filter, req);
|
|
1136
|
-
if (scopedFilter === null)
|
|
1137
|
-
success: false,
|
|
1138
|
-
error: "Organization context required for bulk update",
|
|
1139
|
-
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1140
|
-
status: 403
|
|
1141
|
-
};
|
|
1166
|
+
if (scopedFilter === null) throw createError(403, "Organization context required for bulk update", { code: "ORG_CONTEXT_REQUIRED" });
|
|
1142
1167
|
const arcContext = this.meta(req);
|
|
1143
1168
|
const user = req.user;
|
|
1144
1169
|
const { sanitized, stripped, mixedShape } = this.sanitizeBulkUpdateData(body.data, req, arcContext);
|
|
1145
|
-
if (mixedShape)
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
-
};
|
|
1170
|
+
if (mixedShape) throw createError(400, "Bulk update payload cannot mix operator keys ($set, $inc, ...) with flat fields. Pick one shape.", { code: "MIXED_UPDATE_SHAPE" });
|
|
1171
|
+
if (Object.keys(sanitized).length === 0) throw createError(400, "Bulk update payload contained only protected fields", {
|
|
1172
|
+
code: "ALL_FIELDS_STRIPPED",
|
|
1173
|
+
stripped
|
|
1174
|
+
});
|
|
1160
1175
|
return {
|
|
1161
|
-
success: true,
|
|
1162
1176
|
data: await repo.updateMany(scopedFilter, sanitized, {
|
|
1163
1177
|
user,
|
|
1164
1178
|
context: arcContext
|
|
@@ -1183,33 +1197,16 @@ function BulkMixin(Base) {
|
|
|
1183
1197
|
*/
|
|
1184
1198
|
async bulkDelete(req) {
|
|
1185
1199
|
const repo = this.repository;
|
|
1186
|
-
if (!repo.deleteMany)
|
|
1187
|
-
success: false,
|
|
1188
|
-
error: "Repository does not support deleteMany",
|
|
1189
|
-
status: 501
|
|
1190
|
-
};
|
|
1200
|
+
if (!repo.deleteMany) throw createError(501, "Repository does not support deleteMany");
|
|
1191
1201
|
const body = req.body;
|
|
1192
1202
|
let userFilter;
|
|
1193
1203
|
if (body.ids && body.ids.length > 0) {
|
|
1194
|
-
if (body.filter && Object.keys(body.filter).length > 0)
|
|
1195
|
-
success: false,
|
|
1196
|
-
error: "Bulk delete accepts either `ids` or `filter`, not both",
|
|
1197
|
-
status: 400
|
|
1198
|
-
};
|
|
1204
|
+
if (body.filter && Object.keys(body.filter).length > 0) throw createError(400, "Bulk delete accepts either `ids` or `filter`, not both");
|
|
1199
1205
|
userFilter = { [this.idField]: { $in: body.ids } };
|
|
1200
1206
|
} else if (body.filter && Object.keys(body.filter).length > 0) userFilter = body.filter;
|
|
1201
|
-
else
|
|
1202
|
-
success: false,
|
|
1203
|
-
error: "Bulk delete requires a non-empty `filter` or `ids` array",
|
|
1204
|
-
status: 400
|
|
1205
|
-
};
|
|
1207
|
+
else throw createError(400, "Bulk delete requires a non-empty `filter` or `ids` array");
|
|
1206
1208
|
const scopedFilter = this.buildBulkFilter(userFilter, req);
|
|
1207
|
-
if (scopedFilter === null)
|
|
1208
|
-
success: false,
|
|
1209
|
-
error: "Organization context required for bulk delete",
|
|
1210
|
-
details: { code: "ORG_CONTEXT_REQUIRED" },
|
|
1211
|
-
status: 403
|
|
1212
|
-
};
|
|
1209
|
+
if (scopedFilter === null) throw createError(403, "Organization context required for bulk delete", { code: "ORG_CONTEXT_REQUIRED" });
|
|
1213
1210
|
const hardHint = req.query?.hard === "true" || req.query?.hard === true || body.mode === "hard";
|
|
1214
1211
|
const arcContext = this.meta(req);
|
|
1215
1212
|
const options = {
|
|
@@ -1218,7 +1215,6 @@ function BulkMixin(Base) {
|
|
|
1218
1215
|
};
|
|
1219
1216
|
if (hardHint) options.mode = "hard";
|
|
1220
1217
|
return {
|
|
1221
|
-
success: true,
|
|
1222
1218
|
data: await repo.deleteMany(scopedFilter, options),
|
|
1223
1219
|
status: 200
|
|
1224
1220
|
};
|
|
@@ -1245,14 +1241,17 @@ function SlugMixin(Base) {
|
|
|
1245
1241
|
...options?.filter ?? {}
|
|
1246
1242
|
};
|
|
1247
1243
|
item = await repo.getOne(filter, options);
|
|
1248
|
-
} else
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1244
|
+
} else throw createError(501, "Slug lookup not implemented — repository needs getBySlug() or getOne()");
|
|
1245
|
+
if (!this.accessControl.validateItemAccess(item, req)) {
|
|
1246
|
+
const code = item ? "POLICY_FILTERED" : "NOT_FOUND";
|
|
1247
|
+
const err = new NotFoundError(this.resourceName ?? "Resource");
|
|
1248
|
+
err.details = {
|
|
1249
|
+
...err.details ?? {},
|
|
1250
|
+
code
|
|
1251
|
+
};
|
|
1252
|
+
throw err;
|
|
1253
|
+
}
|
|
1254
1254
|
return {
|
|
1255
|
-
success: true,
|
|
1256
1255
|
data: item,
|
|
1257
1256
|
status: 200
|
|
1258
1257
|
};
|
|
@@ -1265,39 +1264,21 @@ function SoftDeleteMixin(Base) {
|
|
|
1265
1264
|
return class SoftDeleteController extends Base {
|
|
1266
1265
|
async getDeleted(req) {
|
|
1267
1266
|
const repo = this.repository;
|
|
1268
|
-
if (!repo.getDeleted)
|
|
1269
|
-
success: false,
|
|
1270
|
-
error: "Soft delete not implemented",
|
|
1271
|
-
status: 501
|
|
1272
|
-
};
|
|
1267
|
+
if (!repo.getDeleted) throw createError(501, "Soft delete not implemented");
|
|
1273
1268
|
const parsed = this.queryResolver.resolve(req, this.meta(req));
|
|
1274
1269
|
return {
|
|
1275
|
-
success: true,
|
|
1276
1270
|
data: await repo.getDeleted(parsed, parsed),
|
|
1277
1271
|
status: 200
|
|
1278
1272
|
};
|
|
1279
1273
|
}
|
|
1280
1274
|
async restore(req) {
|
|
1281
1275
|
const repo = this.repository;
|
|
1282
|
-
if (!repo.restore)
|
|
1283
|
-
success: false,
|
|
1284
|
-
error: "Restore not implemented",
|
|
1285
|
-
status: 501
|
|
1286
|
-
};
|
|
1276
|
+
if (!repo.restore) throw createError(501, "Restore not implemented");
|
|
1287
1277
|
const id = req.params.id;
|
|
1288
|
-
if (!id)
|
|
1289
|
-
success: false,
|
|
1290
|
-
error: "ID parameter is required",
|
|
1291
|
-
status: 400
|
|
1292
|
-
};
|
|
1278
|
+
if (!id) throw createError(400, "ID parameter is required");
|
|
1293
1279
|
const existing = await this.accessControl.fetchWithAccessControl(id, req, repo, { includeDeleted: true });
|
|
1294
|
-
if (!existing)
|
|
1295
|
-
if (!this.accessControl.checkOwnership(existing, req))
|
|
1296
|
-
success: false,
|
|
1297
|
-
error: "You do not have permission to restore this resource",
|
|
1298
|
-
details: { code: "OWNERSHIP_DENIED" },
|
|
1299
|
-
status: 403
|
|
1300
|
-
};
|
|
1280
|
+
if (!existing) throw new NotFoundError(this.resourceName ?? "Resource");
|
|
1281
|
+
if (!this.accessControl.checkOwnership(existing, req)) throw new ForbiddenError("You do not have permission to restore this resource");
|
|
1301
1282
|
const arcContext = this.meta(req);
|
|
1302
1283
|
const user = req.user;
|
|
1303
1284
|
const repoId = this.resolveRepoId(id, existing);
|
|
@@ -1309,15 +1290,10 @@ function SoftDeleteMixin(Base) {
|
|
|
1309
1290
|
meta: { id }
|
|
1310
1291
|
});
|
|
1311
1292
|
} catch (err) {
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
code: "BEFORE_RESTORE_HOOK_ERROR",
|
|
1317
|
-
message: err.message
|
|
1318
|
-
},
|
|
1319
|
-
status: 400
|
|
1320
|
-
};
|
|
1293
|
+
throw createError(400, "Hook execution failed", {
|
|
1294
|
+
code: "BEFORE_RESTORE_HOOK_ERROR",
|
|
1295
|
+
message: err.message
|
|
1296
|
+
});
|
|
1321
1297
|
}
|
|
1322
1298
|
const repoRestore = () => repo.restore(repoId);
|
|
1323
1299
|
let item;
|
|
@@ -1327,14 +1303,13 @@ function SoftDeleteMixin(Base) {
|
|
|
1327
1303
|
meta: { id }
|
|
1328
1304
|
});
|
|
1329
1305
|
else item = await repoRestore();
|
|
1330
|
-
if (!item)
|
|
1306
|
+
if (!item) throw new NotFoundError(this.resourceName ?? "Resource");
|
|
1331
1307
|
if (hooks && this.resourceName) await hooks.executeAfter(this.resourceName, "restore", item, {
|
|
1332
1308
|
user,
|
|
1333
1309
|
context: arcContext,
|
|
1334
1310
|
meta: { id }
|
|
1335
1311
|
});
|
|
1336
1312
|
return {
|
|
1337
|
-
success: true,
|
|
1338
1313
|
data: item,
|
|
1339
1314
|
status: 200,
|
|
1340
1315
|
meta: { message: "Restored successfully" }
|
|
@@ -1348,30 +1323,20 @@ function TreeMixin(Base) {
|
|
|
1348
1323
|
return class TreeController extends Base {
|
|
1349
1324
|
async getTree(req) {
|
|
1350
1325
|
const repo = this.repository;
|
|
1351
|
-
if (!repo.getTree)
|
|
1352
|
-
success: false,
|
|
1353
|
-
error: "Tree structure not implemented",
|
|
1354
|
-
status: 501
|
|
1355
|
-
};
|
|
1326
|
+
if (!repo.getTree) throw createError(501, "Tree structure not implemented");
|
|
1356
1327
|
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
1357
1328
|
return {
|
|
1358
|
-
success: true,
|
|
1359
1329
|
data: await repo.getTree(options),
|
|
1360
1330
|
status: 200
|
|
1361
1331
|
};
|
|
1362
1332
|
}
|
|
1363
1333
|
async getChildren(req) {
|
|
1364
1334
|
const repo = this.repository;
|
|
1365
|
-
if (!repo.getChildren)
|
|
1366
|
-
success: false,
|
|
1367
|
-
error: "Tree structure not implemented",
|
|
1368
|
-
status: 501
|
|
1369
|
-
};
|
|
1335
|
+
if (!repo.getChildren) throw createError(501, "Tree structure not implemented");
|
|
1370
1336
|
const parentField = this._presetFields.parentField ?? "parent";
|
|
1371
1337
|
const parentId = req.params[parentField] ?? req.params.parent ?? req.params.id;
|
|
1372
1338
|
const options = this.queryResolver.resolve(req, this.meta(req));
|
|
1373
1339
|
return {
|
|
1374
|
-
success: true,
|
|
1375
1340
|
data: await repo.getChildren(parentId, options),
|
|
1376
1341
|
status: 200
|
|
1377
1342
|
};
|