@openinc/parse-server-opendash 4.1.5 → 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.
- package/dist/features/schema/helper/hideSoftDeleted.d.ts +46 -0
- package/dist/features/schema/helper/hideSoftDeleted.js +74 -0
- package/dist/features/schema/index.d.ts +2 -0
- package/dist/features/schema/index.js +5 -1
- package/dist/features/schema/services/beforeFindHook.d.ts +17 -0
- package/dist/features/schema/services/beforeFindHook.js +39 -0
- package/dist/features/schema/types/hookTypes.d.ts +8 -0
- package/dist/helper/appLinks.d.ts +21 -0
- package/dist/helper/appLinks.js +47 -0
- package/dist/hooks/BDE_Article.js +22 -0
- package/dist/hooks/Chat_Message.js +20 -9
- package/dist/hooks/Notification.js +22 -7
- package/dist/hooks/Service_Equipment.js +4 -0
- package/dist/hooks/Service_Project.js +4 -0
- package/dist/hooks/Service_Status.js +4 -0
- package/dist/hooks/Service_Tag.js +4 -0
- package/dist/hooks/Service_Ticket.js +14 -29
- package/dist/i18n/de-DE/openservice.json +2 -2
- package/dist/i18n/en-US/openservice.json +2 -2
- package/package.json +1 -1
|
@@ -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>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { _User } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* App-relative deep link to a service ticket (`OD3_Service_Ticket`).
|
|
4
|
+
*
|
|
5
|
+
* Notifications store links relative to the app root rather than absolute:
|
|
6
|
+
* the same notification is rendered in the frontend (which navigates by route)
|
|
7
|
+
* and mailed out (which needs a host), and the host depends on the recipient's
|
|
8
|
+
* tenant. {@link getAppBaseUrl} resolves it at delivery time.
|
|
9
|
+
*/
|
|
10
|
+
export declare function ticketPath(ticketId: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the base URL of the open.DASH frontend for a given recipient, so
|
|
13
|
+
* links in notification emails point at the app that user actually works in.
|
|
14
|
+
*
|
|
15
|
+
* Precedence: the user's tenant `baseUrl` (multi-tenant installs serve each
|
|
16
|
+
* tenant from its own host), then the global `APP_URL`, then
|
|
17
|
+
* `PARSE_PUBLIC_SERVER_URL` with the trailing `/parse` stripped. Returns an
|
|
18
|
+
* empty string when nothing is configured, so callers emit the bare relative
|
|
19
|
+
* path instead of a link to a bogus host.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getAppBaseUrl(user: _User): Promise<string>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ticketPath = ticketPath;
|
|
4
|
+
exports.getAppBaseUrl = getAppBaseUrl;
|
|
5
|
+
const index_js_1 = require("../features/config/index.js");
|
|
6
|
+
/**
|
|
7
|
+
* App-relative deep link to a service ticket (`OD3_Service_Ticket`).
|
|
8
|
+
*
|
|
9
|
+
* Notifications store links relative to the app root rather than absolute:
|
|
10
|
+
* the same notification is rendered in the frontend (which navigates by route)
|
|
11
|
+
* and mailed out (which needs a host), and the host depends on the recipient's
|
|
12
|
+
* tenant. {@link getAppBaseUrl} resolves it at delivery time.
|
|
13
|
+
*/
|
|
14
|
+
function ticketPath(ticketId) {
|
|
15
|
+
return `/service/tickets/${ticketId}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Resolves the base URL of the open.DASH frontend for a given recipient, so
|
|
19
|
+
* links in notification emails point at the app that user actually works in.
|
|
20
|
+
*
|
|
21
|
+
* Precedence: the user's tenant `baseUrl` (multi-tenant installs serve each
|
|
22
|
+
* tenant from its own host), then the global `APP_URL`, then
|
|
23
|
+
* `PARSE_PUBLIC_SERVER_URL` with the trailing `/parse` stripped. Returns an
|
|
24
|
+
* empty string when nothing is configured, so callers emit the bare relative
|
|
25
|
+
* path instead of a link to a bogus host.
|
|
26
|
+
*/
|
|
27
|
+
async function getAppBaseUrl(user) {
|
|
28
|
+
let baseUrl = "";
|
|
29
|
+
try {
|
|
30
|
+
const tenant = (await user.fetchWithInclude("tenant", { useMasterKey: true })).get("tenant");
|
|
31
|
+
baseUrl = tenant?.get("baseUrl") || "";
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
// No tenant / not fetchable — fall through to the global configuration.
|
|
35
|
+
}
|
|
36
|
+
if (!baseUrl) {
|
|
37
|
+
try {
|
|
38
|
+
const config = index_js_1.ConfigInstance.getInstance();
|
|
39
|
+
const parseUrl = config.get("PARSE_PUBLIC_SERVER_URL") || "";
|
|
40
|
+
baseUrl = config.get("APP_URL") || parseUrl.replace(/\/parse\/?$/, "");
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.warn("[@openinc/parse-server-opendash] Could not resolve an app base URL for notification links");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return (baseUrl || "").replace(/\/+$/, "");
|
|
47
|
+
}
|
|
@@ -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.
|
|
@@ -7,6 +7,7 @@ exports.init = init;
|
|
|
7
7
|
const node_1 = __importDefault(require("parse/node"));
|
|
8
8
|
const i18next_1 = __importDefault(require("i18next"));
|
|
9
9
|
const index_js_1 = require("../features/schema/index.js");
|
|
10
|
+
const appLinks_js_1 = require("../helper/appLinks.js");
|
|
10
11
|
const getUserLanguage_js_1 = require("../helper/getUserLanguage.js");
|
|
11
12
|
const index_js_2 = require("../types/index.js");
|
|
12
13
|
async function init() {
|
|
@@ -49,7 +50,8 @@ async function notifyMentions(object, original) {
|
|
|
49
50
|
return;
|
|
50
51
|
const username = object.get("authorName") || "System";
|
|
51
52
|
const messageText = messageSnippet(object.get("text"));
|
|
52
|
-
|
|
53
|
+
// The only linkable chat context today is a service ticket's comment section.
|
|
54
|
+
const ticketId = getTicketId(object.get("context"));
|
|
53
55
|
// Users already handled — seeded with the author so they never self-notify.
|
|
54
56
|
const notified = new Set();
|
|
55
57
|
const authorId = object.get("author")?.id;
|
|
@@ -71,7 +73,7 @@ async function notifyMentions(object, original) {
|
|
|
71
73
|
await sendMentionNotification(user, "user", object, {
|
|
72
74
|
username,
|
|
73
75
|
messageText,
|
|
74
|
-
|
|
76
|
+
ticketId,
|
|
75
77
|
});
|
|
76
78
|
}
|
|
77
79
|
// 2. Mentioned roles → their members, skipping anyone already notified.
|
|
@@ -95,7 +97,7 @@ async function notifyMentions(object, original) {
|
|
|
95
97
|
await sendMentionNotification(member, "role", object, {
|
|
96
98
|
username,
|
|
97
99
|
messageText,
|
|
98
|
-
|
|
100
|
+
ticketId,
|
|
99
101
|
role: role.get("label"),
|
|
100
102
|
});
|
|
101
103
|
}
|
|
@@ -105,6 +107,12 @@ async function notifyMentions(object, original) {
|
|
|
105
107
|
* Builds and saves a single mention notification, translated into the
|
|
106
108
|
* recipient's preferred language. Delivery (email + web push) is handled
|
|
107
109
|
* automatically by the {@link Notification} afterSave hook.
|
|
110
|
+
*
|
|
111
|
+
* When the chat belongs to a service ticket the notification also carries that
|
|
112
|
+
* ticket: `data.url` gives the notification email a deep link (the host is
|
|
113
|
+
* resolved per recipient at delivery time) and `data.ticketId` gives the
|
|
114
|
+
* frontend its "open ticket" action — the same two keys assignment
|
|
115
|
+
* notifications use, so a mention behaves like any other ticket notification.
|
|
108
116
|
*/
|
|
109
117
|
async function sendMentionNotification(user, kind, message, vars) {
|
|
110
118
|
await i18next_1.default.changeLanguage(await (0, getUserLanguage_js_1.getUserLanguage)(user));
|
|
@@ -117,7 +125,9 @@ async function sendMentionNotification(user, kind, message, vars) {
|
|
|
117
125
|
role: vars.role,
|
|
118
126
|
}),
|
|
119
127
|
data: {
|
|
120
|
-
|
|
128
|
+
...(vars.ticketId
|
|
129
|
+
? { ticketId: vars.ticketId, url: (0, appLinks_js_1.ticketPath)(vars.ticketId) }
|
|
130
|
+
: {}),
|
|
121
131
|
messageId: message.id,
|
|
122
132
|
context: message.get("context"),
|
|
123
133
|
translation: {
|
|
@@ -143,11 +153,11 @@ function mentionKey(mention) {
|
|
|
143
153
|
return `${mention.className}:${mention.id}`;
|
|
144
154
|
}
|
|
145
155
|
/**
|
|
146
|
-
*
|
|
156
|
+
* Extracts the ticket a chat belongs to from its `context`. The context is the
|
|
147
157
|
* JSON-stringified address tuple (e.g. `["service","ticket","<id>"]`). Only the
|
|
148
|
-
* service-ticket context is linkable today; anything else yields no
|
|
158
|
+
* service-ticket context is linkable today; anything else yields no ticket.
|
|
149
159
|
*/
|
|
150
|
-
function
|
|
160
|
+
function getTicketId(context) {
|
|
151
161
|
if (typeof context !== "string")
|
|
152
162
|
return undefined;
|
|
153
163
|
try {
|
|
@@ -155,12 +165,13 @@ function getMessageLink(context) {
|
|
|
155
165
|
if (Array.isArray(parts) &&
|
|
156
166
|
parts[0] === "service" &&
|
|
157
167
|
parts[1] === "ticket" &&
|
|
168
|
+
typeof parts[2] === "string" &&
|
|
158
169
|
parts[2]) {
|
|
159
|
-
return
|
|
170
|
+
return parts[2];
|
|
160
171
|
}
|
|
161
172
|
}
|
|
162
173
|
catch {
|
|
163
|
-
// context is not a JSON tuple —
|
|
174
|
+
// context is not a JSON tuple — not ticket-bound
|
|
164
175
|
}
|
|
165
176
|
return undefined;
|
|
166
177
|
}
|
|
@@ -9,6 +9,7 @@ const web_push_1 = __importDefault(require("web-push"));
|
|
|
9
9
|
const index_js_1 = require("../features/schema/index.js");
|
|
10
10
|
const index_js_2 = require("../features/config/index.js");
|
|
11
11
|
const index_js_3 = require("../features/notifications/index.js");
|
|
12
|
+
const appLinks_js_1 = require("../helper/appLinks.js");
|
|
12
13
|
const index_js_4 = require("../types/index.js");
|
|
13
14
|
async function init() {
|
|
14
15
|
(0, index_js_1.beforeSaveHook)(index_js_4.Notification, async (request) => {
|
|
@@ -28,13 +29,7 @@ async function init() {
|
|
|
28
29
|
const email = user?.getEmail();
|
|
29
30
|
console.log("[@openinc/parse-server-opendash][Notification]", `notification="${object.id}"`, `type="email"`, `to="${email}"`);
|
|
30
31
|
if (email) {
|
|
31
|
-
|
|
32
|
-
if (object.get("data")?.url) {
|
|
33
|
-
body += "\n\n";
|
|
34
|
-
body += index_js_2.ConfigInstance.getInstance().get("APP_URL");
|
|
35
|
-
body += object.get("data")?.url;
|
|
36
|
-
}
|
|
37
|
-
await (0, index_js_3.sendSimpleEmail)(email, title, body);
|
|
32
|
+
await (0, index_js_3.sendSimpleEmail)(email, title, await buildEmailBody(description, object.get("data")?.url, user));
|
|
38
33
|
}
|
|
39
34
|
}
|
|
40
35
|
// Handle Push
|
|
@@ -55,6 +50,26 @@ async function init() {
|
|
|
55
50
|
}
|
|
56
51
|
});
|
|
57
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Plain-text body of a notification email: the (already translated)
|
|
55
|
+
* description, followed by a link to the thing the notification is about.
|
|
56
|
+
*
|
|
57
|
+
* Producers store that link in `data.url` as an app-relative path (e.g.
|
|
58
|
+
* `/service/tickets/<id>`), because the host depends on the recipient's tenant
|
|
59
|
+
* and is only known here, at delivery time — the frontend renders the same
|
|
60
|
+
* notification by route and never needs it. Absolute URLs are passed through
|
|
61
|
+
* unchanged for producers that resolved a host themselves.
|
|
62
|
+
*/
|
|
63
|
+
async function buildEmailBody(description, url, user) {
|
|
64
|
+
const body = description || "";
|
|
65
|
+
if (typeof url !== "string" || url === "") {
|
|
66
|
+
return body;
|
|
67
|
+
}
|
|
68
|
+
const link = /^https?:\/\//i.test(url)
|
|
69
|
+
? url
|
|
70
|
+
: `${await (0, appLinks_js_1.getAppBaseUrl)(user)}${url}`;
|
|
71
|
+
return `${body}\n\n${link}`;
|
|
72
|
+
}
|
|
58
73
|
async function sendWebPush(subscription, id, title, description, icon, data) {
|
|
59
74
|
try {
|
|
60
75
|
console.log("[@openinc/parse-server-opendash][Notification]", `notification="${id}"`, `type="web"`, `subscription="${subscription?.id}"`);
|
|
@@ -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);
|
|
@@ -40,11 +40,17 @@ exports.init = init;
|
|
|
40
40
|
const node_1 = __importStar(require("parse/node"));
|
|
41
41
|
const i18next_1 = __importDefault(require("i18next"));
|
|
42
42
|
const index_js_1 = require("../features/schema/index.js");
|
|
43
|
+
const appLinks_js_1 = require("../helper/appLinks.js");
|
|
43
44
|
const getUserLanguage_js_1 = require("../helper/getUserLanguage.js");
|
|
44
45
|
const index_js_2 = require("../types/index.js");
|
|
45
|
-
const index_js_3 = require("../features/config/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();
|
|
@@ -338,10 +344,15 @@ async function notifyRecipients(userIds, roleIds, notified, ticket, options) {
|
|
|
338
344
|
* Builds and saves a single assignment notification, translated into the
|
|
339
345
|
* recipient's preferred language. Delivery (email + web push) is handled
|
|
340
346
|
* automatically by the {@link Notification} afterSave hook.
|
|
347
|
+
*
|
|
348
|
+
* The deep link lives in `data.url` rather than inside the description text:
|
|
349
|
+
* the frontend opens the ticket from `data.ticketId` (see
|
|
350
|
+
* `TicketNotificationActionAdapter`) and would only render a raw URL as noise,
|
|
351
|
+
* while the notification email appends `data.url` — resolved against the
|
|
352
|
+
* recipient's tenant host — to the body.
|
|
341
353
|
*/
|
|
342
354
|
async function sendAssignmentNotification(user, ticket, options) {
|
|
343
355
|
await i18next_1.default.changeLanguage(await (0, getUserLanguage_js_1.getUserLanguage)(user));
|
|
344
|
-
const ticketLink = await getTicketLink(ticket, user);
|
|
345
356
|
const base = `openservice:ticket.assignment.${options.variant}`;
|
|
346
357
|
const ticketName = ticket.get("title");
|
|
347
358
|
await new index_js_2.Notification({
|
|
@@ -349,11 +360,11 @@ async function sendAssignmentNotification(user, ticket, options) {
|
|
|
349
360
|
description: i18next_1.default.t(`${base}.description`, {
|
|
350
361
|
username: options.assignerName,
|
|
351
362
|
ticketName,
|
|
352
|
-
ticketLink,
|
|
353
363
|
}),
|
|
354
364
|
type: options.frontpage ? "frontpage" : "mailbox",
|
|
355
365
|
data: {
|
|
356
366
|
ticketId: ticket.id,
|
|
367
|
+
url: (0, appLinks_js_1.ticketPath)(ticket.id),
|
|
357
368
|
translation: {
|
|
358
369
|
username: options.assignerName,
|
|
359
370
|
ticketName,
|
|
@@ -390,32 +401,6 @@ function pointerIds(value) {
|
|
|
390
401
|
})
|
|
391
402
|
.filter((id) => typeof id === "string");
|
|
392
403
|
}
|
|
393
|
-
/**
|
|
394
|
-
* Returns the link to a ticket.
|
|
395
|
-
* @param ticket
|
|
396
|
-
* @returns
|
|
397
|
-
*/
|
|
398
|
-
async function getTicketLink(ticket, user) {
|
|
399
|
-
//Get hostname from tenant or environment variable or default to localhost
|
|
400
|
-
let hostname = "localhost";
|
|
401
|
-
const tenant = (await user.fetchWithInclude("tenant", { useMasterKey: true })).get("tenant");
|
|
402
|
-
try {
|
|
403
|
-
const baseurl = tenant?.get("baseUrl");
|
|
404
|
-
const publicurl = index_js_3.ConfigInstance.getInstance()
|
|
405
|
-
.get("PARSE_PUBLIC_SERVER_URL")
|
|
406
|
-
.replace("/parse", "");
|
|
407
|
-
if (baseurl !== undefined && baseurl !== "") {
|
|
408
|
-
hostname = baseurl;
|
|
409
|
-
}
|
|
410
|
-
else if (publicurl) {
|
|
411
|
-
hostname = publicurl;
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
catch (error) {
|
|
415
|
-
console.warn("[@openinc/parse-server-opendash] Could not get a hostname, defaulting to localhost");
|
|
416
|
-
}
|
|
417
|
-
return `${hostname}/service/tickets/${ticket.id}`;
|
|
418
|
-
}
|
|
419
404
|
function getUsername(user) {
|
|
420
405
|
if (!user)
|
|
421
406
|
return "System";
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
"assignment": {
|
|
4
4
|
"assigned": {
|
|
5
5
|
"title": "Ihnen wurde ein Ticket zugewiesen",
|
|
6
|
-
"description": "{{username}} hat Ihnen das Ticket {{ticketName}} zugewiesen
|
|
6
|
+
"description": "{{username}} hat Ihnen das Ticket {{ticketName}} zugewiesen."
|
|
7
7
|
},
|
|
8
8
|
"watcher": {
|
|
9
9
|
"title": "Ein Ticket wurde zugewiesen",
|
|
10
|
-
"description": "{{username}} hat das Ticket {{ticketName}} zugewiesen
|
|
10
|
+
"description": "{{username}} hat das Ticket {{ticketName}} zugewiesen."
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
}
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
"assignment": {
|
|
4
4
|
"assigned": {
|
|
5
5
|
"title": "You were assigned to a ticket",
|
|
6
|
-
"description": "{{username}} assigned you to ticket {{ticketName}}
|
|
6
|
+
"description": "{{username}} assigned you to ticket {{ticketName}}."
|
|
7
7
|
},
|
|
8
8
|
"watcher": {
|
|
9
9
|
"title": "A ticket was assigned",
|
|
10
|
-
"description": "{{username}} assigned ticket {{ticketName}}
|
|
10
|
+
"description": "{{username}} assigned ticket {{ticketName}}."
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
13
|
}
|