@openinc/parse-server-opendash 4.1.6 → 4.1.7

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.
@@ -0,0 +1,46 @@
1
+ export interface HideSoftDeletedOptions {
2
+ /** Column carrying the soft-delete marker, e.g. `"deleted"` / `"deletedAt"`. */
3
+ field: string;
4
+ /**
5
+ * How the marker is stored:
6
+ * - `"boolean"` (default): archived when the column is `true`. Rows where the
7
+ * column is missing count as *not* archived, so legacy rows written before
8
+ * the column existed stay visible.
9
+ * - `"date"`: archived when the column is set at all.
10
+ */
11
+ type?: "boolean" | "date";
12
+ }
13
+ /**
14
+ * Hides soft-deleted rows of a class from every client query — the server-side
15
+ * counterpart of the frontend's soft delete (the admin action stamps the marker
16
+ * column instead of destroying the row, so nothing pointing at the record ends
17
+ * up dangling).
18
+ *
19
+ * Doing it here rather than in each frontend query means a class is filtered
20
+ * *once*, for every consumer: dashboards, reference dropdowns, KPI counts
21
+ * (`count` runs through beforeFind too) and any plugin that queries the class
22
+ * later on. A caller that forgets the filter no longer leaks archived rows.
23
+ *
24
+ * Three escape hatches, in the order they are checked:
25
+ *
26
+ * 1. **Master key** — backend hooks, cloud functions and migration scripts see
27
+ * everything. They are the ones that have to clean archived rows up.
28
+ * 2. **The query already constrains `field`** — an explicit constraint always
29
+ * wins, which is how an "archive" view asks for the hidden rows
30
+ * (`deleted = true`) and how a query asks for *all* rows regardless of the
31
+ * marker (`deleted $nin []` matches every row, including those where the
32
+ * column is missing).
33
+ * 3. **The query names object ids** (`objectId` / `objectId $in [...]`) — such a
34
+ * query resolves a *known reference* rather than listing: a `get` by id, the
35
+ * server resolving an `include`d pointer, or a batch label lookup. Parse runs
36
+ * beforeFind for include resolution as well, so without this a ticket
37
+ * pointing at an archived status would come back with an unresolved pointer
38
+ * and the UI would lose the label. Hiding is about listing, not about
39
+ * breaking references that already exist.
40
+ *
41
+ * @param target - The Parse class (or its name) to filter.
42
+ * @param options - Marker column and how it is stored, see {@link HideSoftDeletedOptions}.
43
+ */
44
+ export declare function hideSoftDeleted<T extends Parse.Object<Parse.Attributes>>(target: string | {
45
+ new (): T;
46
+ }, options: HideSoftDeletedOptions): void;
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hideSoftDeleted = hideSoftDeleted;
4
+ const beforeFindHook_js_1 = require("../services/beforeFindHook.js");
5
+ /**
6
+ * Hides soft-deleted rows of a class from every client query — the server-side
7
+ * counterpart of the frontend's soft delete (the admin action stamps the marker
8
+ * column instead of destroying the row, so nothing pointing at the record ends
9
+ * up dangling).
10
+ *
11
+ * Doing it here rather than in each frontend query means a class is filtered
12
+ * *once*, for every consumer: dashboards, reference dropdowns, KPI counts
13
+ * (`count` runs through beforeFind too) and any plugin that queries the class
14
+ * later on. A caller that forgets the filter no longer leaks archived rows.
15
+ *
16
+ * Three escape hatches, in the order they are checked:
17
+ *
18
+ * 1. **Master key** — backend hooks, cloud functions and migration scripts see
19
+ * everything. They are the ones that have to clean archived rows up.
20
+ * 2. **The query already constrains `field`** — an explicit constraint always
21
+ * wins, which is how an "archive" view asks for the hidden rows
22
+ * (`deleted = true`) and how a query asks for *all* rows regardless of the
23
+ * marker (`deleted $nin []` matches every row, including those where the
24
+ * column is missing).
25
+ * 3. **The query names object ids** (`objectId` / `objectId $in [...]`) — such a
26
+ * query resolves a *known reference* rather than listing: a `get` by id, the
27
+ * server resolving an `include`d pointer, or a batch label lookup. Parse runs
28
+ * beforeFind for include resolution as well, so without this a ticket
29
+ * pointing at an archived status would come back with an unresolved pointer
30
+ * and the UI would lose the label. Hiding is about listing, not about
31
+ * breaking references that already exist.
32
+ *
33
+ * @param target - The Parse class (or its name) to filter.
34
+ * @param options - Marker column and how it is stored, see {@link HideSoftDeletedOptions}.
35
+ */
36
+ function hideSoftDeleted(target, options) {
37
+ const { field, type = "boolean" } = options;
38
+ (0, beforeFindHook_js_1.beforeFindHook)(target, async (request) => {
39
+ if (request.master)
40
+ return;
41
+ // The marker column is configured by name, so the attribute types of T
42
+ // cannot describe it — constrain the query untyped.
43
+ const query = request.query;
44
+ const where = query.toJSON().where;
45
+ if (constrainsKey(where, field))
46
+ return;
47
+ if (constrainsKey(where, "objectId"))
48
+ return;
49
+ if (type === "date")
50
+ query.doesNotExist(field);
51
+ // `notEqualTo(true)` rather than `equalTo(false)`: it also keeps rows where
52
+ // the column was never written.
53
+ else
54
+ query.notEqualTo(field, true);
55
+ });
56
+ }
57
+ /**
58
+ * Whether a rest `where` clause constrains `key` — including inside the
59
+ * branches of a compound operator, so an `$or` of two archive filters counts.
60
+ */
61
+ function constrainsKey(where, key) {
62
+ if (!where)
63
+ return false;
64
+ if (Object.prototype.hasOwnProperty.call(where, key))
65
+ return true;
66
+ for (const operator of ["$or", "$and", "$nor"]) {
67
+ const branches = where[operator];
68
+ if (Array.isArray(branches) &&
69
+ branches.some((branch) => constrainsKey(branch, key))) {
70
+ return true;
71
+ }
72
+ }
73
+ return false;
74
+ }
@@ -1,9 +1,11 @@
1
1
  export { afterDeleteHook } from "./services/afterDeleteHook.js";
2
2
  export { afterSaveHook } from "./services/afterSaveHook.js";
3
3
  export { beforeDeleteHook } from "./services/beforeDeleteHook.js";
4
+ export { beforeFindHook } from "./services/beforeFindHook.js";
4
5
  export { beforeSaveHook } from "./services/beforeSaveHook.js";
5
6
  export * from "./types/hookTypes.js";
6
7
  export { defaultAclHandler, defaultHandler } from "./helper/defaultHandler.js";
8
+ export { hideSoftDeleted, type HideSoftDeletedOptions, } from "./helper/hideSoftDeleted.js";
7
9
  export { immutableField } from "./helper/immutableField.js";
8
10
  export { isMigrationMode } from "./helper/migrationMode.js";
9
11
  export { ensureRole } from "./helper/ensureRole.js";
@@ -14,19 +14,23 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.initDefaultRoles = exports.initDefaultData = exports.initSchema = exports.ensureUserRole = exports.ensureRole = exports.isMigrationMode = exports.immutableField = exports.defaultHandler = exports.defaultAclHandler = exports.beforeSaveHook = exports.beforeDeleteHook = exports.afterSaveHook = exports.afterDeleteHook = void 0;
17
+ exports.initDefaultRoles = exports.initDefaultData = exports.initSchema = exports.ensureUserRole = exports.ensureRole = exports.isMigrationMode = exports.immutableField = exports.hideSoftDeleted = exports.defaultHandler = exports.defaultAclHandler = exports.beforeSaveHook = exports.beforeFindHook = exports.beforeDeleteHook = exports.afterSaveHook = exports.afterDeleteHook = void 0;
18
18
  var afterDeleteHook_js_1 = require("./services/afterDeleteHook.js");
19
19
  Object.defineProperty(exports, "afterDeleteHook", { enumerable: true, get: function () { return afterDeleteHook_js_1.afterDeleteHook; } });
20
20
  var afterSaveHook_js_1 = require("./services/afterSaveHook.js");
21
21
  Object.defineProperty(exports, "afterSaveHook", { enumerable: true, get: function () { return afterSaveHook_js_1.afterSaveHook; } });
22
22
  var beforeDeleteHook_js_1 = require("./services/beforeDeleteHook.js");
23
23
  Object.defineProperty(exports, "beforeDeleteHook", { enumerable: true, get: function () { return beforeDeleteHook_js_1.beforeDeleteHook; } });
24
+ var beforeFindHook_js_1 = require("./services/beforeFindHook.js");
25
+ Object.defineProperty(exports, "beforeFindHook", { enumerable: true, get: function () { return beforeFindHook_js_1.beforeFindHook; } });
24
26
  var beforeSaveHook_js_1 = require("./services/beforeSaveHook.js");
25
27
  Object.defineProperty(exports, "beforeSaveHook", { enumerable: true, get: function () { return beforeSaveHook_js_1.beforeSaveHook; } });
26
28
  __exportStar(require("./types/hookTypes.js"), exports);
27
29
  var defaultHandler_js_1 = require("./helper/defaultHandler.js");
28
30
  Object.defineProperty(exports, "defaultAclHandler", { enumerable: true, get: function () { return defaultHandler_js_1.defaultAclHandler; } });
29
31
  Object.defineProperty(exports, "defaultHandler", { enumerable: true, get: function () { return defaultHandler_js_1.defaultHandler; } });
32
+ var hideSoftDeleted_js_1 = require("./helper/hideSoftDeleted.js");
33
+ Object.defineProperty(exports, "hideSoftDeleted", { enumerable: true, get: function () { return hideSoftDeleted_js_1.hideSoftDeleted; } });
30
34
  var immutableField_js_1 = require("./helper/immutableField.js");
31
35
  Object.defineProperty(exports, "immutableField", { enumerable: true, get: function () { return immutableField_js_1.immutableField; } });
32
36
  var migrationMode_js_1 = require("./helper/migrationMode.js");
@@ -0,0 +1,17 @@
1
+ import { beforeFindHookType } from "../index.js";
2
+ /**
3
+ * Registers a beforeFind hook for a Parse class. In comparison to
4
+ * Parse.Cloud.beforeFind(), this function allows multiple beforeFind hooks to be
5
+ * registered for the same class.
6
+ *
7
+ * Each callback either mutates `request.query` in place or returns a
8
+ * replacement query. The result is threaded into the next callback, so several
9
+ * hooks compose into one set of constraints.
10
+ *
11
+ * @template T - The type of the Parse.Object.
12
+ * @param {string | { new (): T }} target - The name of the Parse class or the class itself.
13
+ * @param {beforeFindHookType<T>} callback - The callback function to be executed before querying the class.
14
+ */
15
+ export declare function beforeFindHook<T extends Parse.Object<Parse.Attributes>>(target: string | {
16
+ new (): T;
17
+ }, callback: beforeFindHookType<T>): void;
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.beforeFindHook = beforeFindHook;
7
+ const node_1 = __importDefault(require("parse/node"));
8
+ const beforeFindHooks = {};
9
+ /**
10
+ * Registers a beforeFind hook for a Parse class. In comparison to
11
+ * Parse.Cloud.beforeFind(), this function allows multiple beforeFind hooks to be
12
+ * registered for the same class.
13
+ *
14
+ * Each callback either mutates `request.query` in place or returns a
15
+ * replacement query. The result is threaded into the next callback, so several
16
+ * hooks compose into one set of constraints.
17
+ *
18
+ * @template T - The type of the Parse.Object.
19
+ * @param {string | { new (): T }} target - The name of the Parse class or the class itself.
20
+ * @param {beforeFindHookType<T>} callback - The callback function to be executed before querying the class.
21
+ */
22
+ function beforeFindHook(target, callback) {
23
+ // @ts-ignore
24
+ const className = typeof target === "string" ? target : target.className;
25
+ if (!beforeFindHooks[className]) {
26
+ beforeFindHooks[className] = [];
27
+ node_1.default.Cloud.beforeFind(className, async function beforeFindHookFunction(request) {
28
+ const sessionToken = request.user?.getSessionToken();
29
+ let query = request.query;
30
+ for (const fn of beforeFindHooks[className]) {
31
+ const result = await fn({ ...request, query, sessionToken });
32
+ if (result)
33
+ query = result;
34
+ }
35
+ return query;
36
+ });
37
+ }
38
+ beforeFindHooks[className].push(callback);
39
+ }
@@ -11,3 +11,11 @@ export type beforeDeleteHookType<T extends Parse.Object<Parse.Attributes>> = (re
11
11
  export type afterDeleteHookType<T extends Parse.Object<Parse.Attributes>> = (request: Parse.Cloud.AfterDeleteRequest<T> & {
12
12
  sessionToken: SessionTokenType;
13
13
  }) => Promise<void>;
14
+ /**
15
+ * A beforeFind hook. May mutate `request.query` in place or return a
16
+ * replacement query — the registry threads the returned query through the
17
+ * remaining hooks of the class.
18
+ */
19
+ export type beforeFindHookType<T extends Parse.Object<Parse.Attributes>> = (request: Parse.Cloud.BeforeFindRequest<T> & {
20
+ sessionToken: SessionTokenType;
21
+ }) => Promise<Parse.Query<T> | void>;
@@ -4,8 +4,30 @@ exports.init = init;
4
4
  const index_js_1 = require("../features/schema/index.js");
5
5
  const index_js_2 = require("../types/index.js");
6
6
  async function init() {
7
+ // Soft delete: articles are referenced from a ticket's `material` list and
8
+ // from BDE work orders, so both admin UIs archive instead of destroying.
9
+ // Archived articles are hidden from every client query — including BDE's, the
10
+ // catalog being shared. The escape hatches keep both usable: a query naming
11
+ // object ids still resolves an archived article (so an existing work order or
12
+ // material line keeps its label), and a query constraining `isDeleted` sees
13
+ // the archive — which is what BDE's `isDeleted` column filter sends, so
14
+ // un-archiving from the parse admin table still works.
15
+ (0, index_js_1.hideSoftDeleted)(index_js_2.BDE_Article, { field: "isDeleted" });
7
16
  (0, index_js_1.beforeSaveHook)(index_js_2.BDE_Article, async (request) => {
8
17
  const { object, original, user } = request;
18
+ // Normalize the soft-delete marker: an absent column means "not archived",
19
+ // so write that out explicitly. Not every write path fills it in — BDE's own
20
+ // admin form does not list the field, and neither does the ABAS sync — and
21
+ // consumers match on `isDeleted = false` rather than `!= true` (e.g. the
22
+ // ticket material picker), which an unset column fails.
23
+ //
24
+ // Deliberately not limited to `isNew()`: that leaves every already-synced
25
+ // article invisible to those consumers until someone backfills. Filling it
26
+ // on any save heals the existing rows instead, as the ABAS sync re-saves the
27
+ // whole catalog on its first run after a restart.
28
+ if (object.get("isDeleted") == null) {
29
+ object.set("isDeleted", false);
30
+ }
9
31
  await (0, index_js_1.defaultHandler)(request);
10
32
  // Ported from the former MES_Article hook: tenant users may manage the
11
33
  // shared article catalog, and custom ACLs are honoured.
@@ -8,6 +8,10 @@ const node_1 = __importDefault(require("parse/node"));
8
8
  const index_js_1 = require("../features/schema/index.js");
9
9
  const index_js_2 = require("../types/index.js");
10
10
  async function init() {
11
+ // Soft delete: tickets and meter readings point at equipment, so it is
12
+ // archived rather than destroyed. Archived rows are hidden from every client
13
+ // query (see hideSoftDeleted()).
14
+ (0, index_js_1.hideSoftDeleted)(index_js_2.Service_Equipment, { field: "deleted" });
11
15
  (0, index_js_1.beforeSaveHook)(index_js_2.Service_Equipment, async (request) => {
12
16
  const { object, original, user } = request;
13
17
  await (0, index_js_1.defaultHandler)(request);
@@ -4,6 +4,10 @@ exports.init = init;
4
4
  const index_js_1 = require("../features/schema/index.js");
5
5
  const index_js_2 = require("../types/index.js");
6
6
  async function init() {
7
+ // Soft delete: tickets point at their project and draw their number from its
8
+ // counter, so a project is archived rather than destroyed. Archived rows are
9
+ // hidden from every client query (see hideSoftDeleted()).
10
+ (0, index_js_1.hideSoftDeleted)(index_js_2.Service_Project, { field: "deleted" });
7
11
  (0, index_js_1.beforeSaveHook)(index_js_2.Service_Project, async (request) => {
8
12
  const { object, original, user } = request;
9
13
  await (0, index_js_1.defaultHandler)(request);
@@ -8,6 +8,10 @@ const node_1 = __importDefault(require("parse/node"));
8
8
  const index_js_1 = require("../features/schema/index.js");
9
9
  const index_js_2 = require("../types/index.js");
10
10
  async function init() {
11
+ // Soft delete: every ticket carries a required `status` pointer, so a status
12
+ // is archived rather than destroyed. Archived rows are hidden from every
13
+ // client query (see hideSoftDeleted() for the escape hatches).
14
+ (0, index_js_1.hideSoftDeleted)(index_js_2.Service_Status, { field: "deleted" });
11
15
  (0, index_js_1.beforeSaveHook)(index_js_2.Service_Status, async (request) => {
12
16
  const { object, original, user } = request;
13
17
  await (0, index_js_1.defaultHandler)(request);
@@ -8,6 +8,10 @@ const node_1 = __importDefault(require("parse/node"));
8
8
  const index_js_1 = require("../features/schema/index.js");
9
9
  const index_js_2 = require("../types/index.js");
10
10
  async function init() {
11
+ // Soft delete: tags are stored in the ancestor closures on tickets and
12
+ // equipment, so they are archived rather than destroyed. Archived rows are
13
+ // hidden from every client query (see hideSoftDeleted()).
14
+ (0, index_js_1.hideSoftDeleted)(index_js_2.Service_Tag, { field: "deleted" });
11
15
  (0, index_js_1.beforeSaveHook)(index_js_2.Service_Tag, async (request) => {
12
16
  const { object, original, user } = request;
13
17
  await (0, index_js_1.defaultHandler)(request);
@@ -45,6 +45,12 @@ const getUserLanguage_js_1 = require("../helper/getUserLanguage.js");
45
45
  const index_js_2 = require("../types/index.js");
46
46
  const applyRuleSet_js_1 = require("../features/ruleset/applyRuleSet.js");
47
47
  async function init() {
48
+ // Soft delete: a ticket is referenced by chat messages, meta entries,
49
+ // notifications and child tickets, so the frontend archives instead of
50
+ // destroying. Archived rows are hidden from every client query here — see
51
+ // hideSoftDeleted() for the escape hatches (master key, an explicit
52
+ // constraint on `deleted`, and queries naming object ids).
53
+ (0, index_js_1.hideSoftDeleted)(index_js_2.Service_Ticket, { field: "deleted" });
48
54
  (0, index_js_1.beforeSaveHook)("OD3_Service_Ticket", async (request) => {
49
55
  const { object, original, user } = request;
50
56
  const isNew = object.isNew();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openinc/parse-server-opendash",
3
- "version": "4.1.6",
3
+ "version": "4.1.7",
4
4
  "description": "Parse Server Cloud Code for open.INC Stack.",
5
5
  "packageManager": "pnpm@11.25.0",
6
6
  "keywords": [