@classytic/arc 2.10.8 → 2.11.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/dist/{BaseController-DVNKvoX4.mjs → BaseController-JNV08qOT.mjs} +480 -442
- package/dist/{queryCachePlugin-Dumka73q.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
- package/dist/adapters/index.d.mts +2 -2
- package/dist/adapters/index.mjs +1 -1
- package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
- package/dist/audit/index.d.mts +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.mjs +5 -5
- package/dist/{betterAuthOpenApi--rdY15Ld.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
- package/dist/cache/index.d.mts +3 -2
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +37 -27
- package/dist/cli/commands/init.mjs +46 -33
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -3
- package/dist/core-DXdSSFW-.mjs +1037 -0
- package/dist/createActionRouter-BwaSM0No.mjs +166 -0
- package/dist/{createApp-BwnEAO2h.mjs → createApp-P1d6rjPy.mjs} +75 -27
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-Dci0AYLT.mjs → elevation-DOFoxoDs.mjs} +1 -1
- package/dist/{errorHandler-CSxe7KIM.mjs → errorHandler-BQm8ZxTK.mjs} +1 -1
- package/dist/{eventPlugin-ByU4Cv0e.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
- package/dist/events/index.d.mts +3 -3
- package/dist/events/index.mjs +2 -2
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +2 -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 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-C_Noptz-.d.mts → index-BYCqHCVu.d.mts} +2 -2
- package/dist/{index-BGbpGVyM.d.mts → index-C_bgx9o4.d.mts} +712 -500
- package/dist/{index-BziRPS4H.d.mts → index-CvM1e09j.d.mts} +29 -10
- package/dist/{index-EqQN6p0W.d.mts → index-pUczGjO0.d.mts} +11 -8
- package/dist/index-smCAoA5W.d.mts +1179 -0
- package/dist/index.d.mts +6 -38
- package/dist/index.mjs +9 -9
- package/dist/integrations/event-gateway.d.mts +1 -1
- 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 +46 -5
- package/dist/integrations/streamline.mjs +50 -21
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +2 -154
- package/dist/integrations/websocket.mjs +292 -224
- package/dist/{keys-nWQGUTu1.mjs → keys-CARyUjiR.mjs} +2 -0
- package/dist/{loadResources-Bksk8ydA.mjs → loadResources-CPpkyKfM.mjs} +32 -8
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +1 -1
- package/dist/{openapi-DpNpqBmo.mjs → openapi-C0L9ar7m.mjs} +4 -4
- package/dist/org/index.d.mts +1 -1
- package/dist/permissions/index.d.mts +1 -1
- package/dist/permissions/index.mjs +2 -4
- package/dist/{permissions-wkqRwicB.mjs → permissions-B4vU9L0Q.mjs} +221 -3
- package/dist/{pipe-CGJxqDGx.mjs → pipe-DVoIheVC.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +4 -4
- package/dist/plugins/index.mjs +10 -10
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +42 -24
- package/dist/presets/filesUpload.d.mts +1 -1
- package/dist/presets/filesUpload.mjs +3 -3
- 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 +6 -0
- package/dist/presets/search.d.mts +1 -1
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-CrwOvuXI.mjs → presets-k604Lj99.mjs} +1 -1
- package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
- package/dist/{queryCachePlugin-ChLNZvFT.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
- package/dist/{redis-MXLp1oOf.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{resourceToTools-BhF3JV5p.mjs → resourceToTools--okX6QBr.mjs} +534 -420
- package/dist/routerShared-DeESFp4a.mjs +515 -0
- package/dist/schemaIR-BlG9bY7v.mjs +137 -0
- package/dist/scope/index.mjs +2 -2
- package/dist/testing/index.d.mts +367 -711
- package/dist/testing/index.mjs +637 -1434
- package/dist/{tracing-xqXzWeaf.d.mts → tracing-DokiEsuz.d.mts} +9 -4
- package/dist/types/index.d.mts +3 -3
- package/dist/types/index.mjs +1 -3
- package/dist/{types-CVdgPXBW.d.mts → types-BdA4uMBV.d.mts} +191 -28
- package/dist/{types-CVKBssX5.d.mts → types-Bh_gEJBi.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -968
- package/dist/utils/index.mjs +5 -6
- package/dist/utils-D3Yxnrwr.mjs +1639 -0
- package/dist/websocket-CyJ1VIFI.d.mts +186 -0
- package/package.json +7 -5
- package/skills/arc/SKILL.md +124 -39
- package/skills/arc/references/testing.md +212 -183
- package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
- package/dist/core-3MWJosCH.mjs +0 -1459
- package/dist/createActionRouter-C8UUB3Px.mjs +0 -249
- package/dist/errors-BI8kEKsO.d.mts +0 -140
- package/dist/fields-CTMWOUDt.mjs +0 -126
- package/dist/queryParser-NR__Qiju.mjs +0 -419
- package/dist/types-CDnTEpga.mjs +0 -27
- package/dist/utils-LMwVidKy.mjs +0 -947
- /package/dist/{HookSystem-BjFu7zf1.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
- /package/dist/{ResourceRegistry-CcN2LVrc.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
- /package/dist/{actionPermissions-TUVR3uiZ.mjs → actionPermissions-C8YYU92K.mjs} +0 -0
- /package/dist/{caching-3h93rkJM.mjs → caching-CheW3m-S.mjs} +0 -0
- /package/dist/{errorHandler-2ii4RIYr.d.mts → errorHandler-Co3lnVmJ.d.mts} +0 -0
- /package/dist/{errors-BqdUDja_.mjs → errors-D5c-5BJL.mjs} +0 -0
- /package/dist/{eventPlugin-D1ThQ1Pp.d.mts → eventPlugin-CUNjYYRY.d.mts} +0 -0
- /package/dist/{interface-B-pe8fhj.d.mts → interface-CkkWm5uR.d.mts} +0 -0
- /package/dist/{interface-yhyb_pLY.d.mts → interface-Da0r7Lna.d.mts} +0 -0
- /package/dist/{memory-DqI-449b.mjs → memory-DikHSvWa.mjs} +0 -0
- /package/dist/{metrics-TuOmguhi.mjs → metrics-Csh4nsvv.mjs} +0 -0
- /package/dist/{multipartBody-CUQGVlM_.mjs → multipartBody-CvTR1Un6.mjs} +0 -0
- /package/dist/{pluralize-CWP6MB39.mjs → pluralize-BneOJkpi.mjs} +0 -0
- /package/dist/{redis-stream-bkO88VHx.d.mts → redis-stream-CM8TXTix.d.mts} +0 -0
- /package/dist/{registry-B0Wl7uVV.mjs → registry-D63ee7fl.mjs} +0 -0
- /package/dist/{replyHelpers-BLojtuvR.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
- /package/dist/{requestContext-C38GskNt.mjs → requestContext-CfRkaxwf.mjs} +0 -0
- /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
- /package/dist/{sse-D8UeDwis.mjs → sse-V7aXc3bW.mjs} +0 -0
- /package/dist/{store-helpers-DYYUQbQN.mjs → store-helpers-BhrzxvyQ.mjs} +0 -0
- /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
- /package/dist/{types-D57iXYb8.mjs → types-DV9WDfeg.mjs} +0 -0
- /package/dist/{versioning-B6mimogM.mjs → versioning-CGPjkqAg.mjs} +0 -0
- /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 {
|
|
5
|
-
import { t as buildQueryKey } from "./keys-
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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/
|
|
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.
|
|
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
|
|
424
|
+
* Framework-agnostic CRUD controller implementing IController.
|
|
427
425
|
*
|
|
428
|
-
* Composes AccessControl, BodySanitizer, and QueryResolver
|
|
429
|
-
*
|
|
430
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
493
|
-
*
|
|
497
|
+
* Swap the controller's query parser. Rebuilds the internal `QueryResolver`
|
|
498
|
+
* with the new parser while preserving every other config.
|
|
494
499
|
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
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
|
-
*
|
|
505
|
-
*
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
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
|
-
*
|
|
527
|
-
*
|
|
528
|
-
* `_tenantFields`
|
|
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
|
|
563
|
+
* Resolve the repository primary key for mutation calls.
|
|
553
564
|
*
|
|
554
|
-
* When the resource declares a custom `idField` (
|
|
555
|
-
*
|
|
556
|
-
* because most Mongo repositories key
|
|
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
|
|
559
|
-
*
|
|
560
|
-
*
|
|
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
|
-
*
|
|
578
|
-
*
|
|
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
|
|
610
|
-
* Universal resources (tenantField: false) get shared cache keys
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
966
|
-
|
|
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: "
|
|
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
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
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
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
const sanitized =
|
|
1214
|
-
for (const
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
if (!
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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: "
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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 {
|
|
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 };
|