@openinc/parse-server-opendash 4.1.1 → 4.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/dist/featuremap.json +27 -12
  2. package/dist/features/config/helper/customOptions.js +9 -0
  3. package/dist/features/config/helper/isFeatureEnabled.d.ts +1 -1
  4. package/dist/features/config/helper/isFeatureEnabled.js +2 -0
  5. package/dist/features/meta/index.d.ts +1 -0
  6. package/dist/features/meta/index.js +5 -0
  7. package/dist/features/meta/services/registerMetaContext.d.ts +29 -0
  8. package/dist/features/meta/services/registerMetaContext.js +208 -0
  9. package/dist/features/permissions/types/Permissions.d.ts +24 -1
  10. package/dist/features/permissions/types/Permissions.js +34 -0
  11. package/dist/features/ruleset/applyRuleSet.d.ts +41 -0
  12. package/dist/features/ruleset/applyRuleSet.js +81 -0
  13. package/dist/features/schema/helper/defaultHandler.js +13 -0
  14. package/dist/features/schema/helper/migrationMode.d.ts +26 -0
  15. package/dist/features/schema/helper/migrationMode.js +48 -0
  16. package/dist/features/schema/index.d.ts +1 -0
  17. package/dist/features/schema/index.js +3 -1
  18. package/dist/features/schema/services/beforeSaveHook.js +21 -0
  19. package/dist/features/translations/services/initTranslation.js +1 -1
  20. package/dist/hooks/Chat_Message.d.ts +1 -0
  21. package/dist/hooks/Chat_Message.js +179 -0
  22. package/dist/hooks/Meta_Config.js +19 -10
  23. package/dist/hooks/Meta_Context.d.ts +6 -0
  24. package/dist/hooks/Meta_Context.js +16 -0
  25. package/dist/hooks/Meta_Entry.js +20 -10
  26. package/dist/hooks/Service_Equipment.d.ts +1 -0
  27. package/dist/hooks/Service_Equipment.js +38 -0
  28. package/dist/hooks/Service_FormConfig.d.ts +1 -0
  29. package/dist/hooks/Service_FormConfig.js +17 -0
  30. package/dist/hooks/Service_Project.d.ts +1 -0
  31. package/dist/hooks/Service_Project.js +17 -0
  32. package/dist/hooks/Service_Schedule.d.ts +1 -0
  33. package/dist/hooks/Service_Schedule.js +24 -0
  34. package/dist/hooks/Service_Schedule_Execution.d.ts +1 -0
  35. package/dist/hooks/Service_Schedule_Execution.js +17 -0
  36. package/dist/hooks/Service_Schedule_Template.d.ts +1 -0
  37. package/dist/hooks/Service_Schedule_Template.js +17 -0
  38. package/dist/hooks/Service_Status.d.ts +1 -0
  39. package/dist/hooks/Service_Status.js +32 -0
  40. package/dist/hooks/Service_Tag.d.ts +1 -0
  41. package/dist/hooks/Service_Tag.js +168 -0
  42. package/dist/hooks/Service_Ticket.d.ts +1 -0
  43. package/dist/hooks/Service_Ticket.js +433 -0
  44. package/dist/hooks/Service_TriggerState.d.ts +1 -0
  45. package/dist/hooks/Service_TriggerState.js +17 -0
  46. package/dist/hooks/_ChangeLog.js +27 -6
  47. package/dist/i18n/de-DE/chat.json +18 -0
  48. package/dist/i18n/de-DE/openservice.json +14 -0
  49. package/dist/i18n/en-US/chat.json +18 -0
  50. package/dist/i18n/en-US/openservice.json +14 -0
  51. package/dist/scripts/backfill-meta-contexts.d.ts +1 -0
  52. package/dist/scripts/backfill-meta-contexts.js +100 -0
  53. package/dist/scripts/migration/lib/acl.d.ts +52 -0
  54. package/dist/scripts/migration/lib/acl.js +79 -0
  55. package/dist/scripts/migration/lib/articleMeta.d.ts +47 -0
  56. package/dist/scripts/migration/lib/articleMeta.js +132 -0
  57. package/dist/scripts/migration/lib/env.d.ts +56 -0
  58. package/dist/scripts/migration/lib/env.js +106 -0
  59. package/dist/scripts/migration/lib/latest.d.ts +47 -0
  60. package/dist/scripts/migration/lib/latest.js +115 -0
  61. package/dist/scripts/migration/lib/map.d.ts +53 -0
  62. package/dist/scripts/migration/lib/map.js +113 -0
  63. package/dist/scripts/migration/lib/material.d.ts +47 -0
  64. package/dist/scripts/migration/lib/material.js +51 -0
  65. package/dist/scripts/migration/lib/message.d.ts +78 -0
  66. package/dist/scripts/migration/lib/message.js +115 -0
  67. package/dist/scripts/migration/lib/pass.d.ts +67 -0
  68. package/dist/scripts/migration/lib/pass.js +89 -0
  69. package/dist/scripts/migration/lib/ptr.d.ts +32 -0
  70. package/dist/scripts/migration/lib/ptr.js +38 -0
  71. package/dist/scripts/migration/lib/report.d.ts +41 -0
  72. package/dist/scripts/migration/lib/report.js +78 -0
  73. package/dist/scripts/migration/lib/rest.d.ts +47 -0
  74. package/dist/scripts/migration/lib/rest.js +68 -0
  75. package/dist/scripts/migration/lib/ticket.d.ts +72 -0
  76. package/dist/scripts/migration/lib/ticket.js +192 -0
  77. package/dist/scripts/migration/passes/01-statuses.d.ts +12 -0
  78. package/dist/scripts/migration/passes/01-statuses.js +122 -0
  79. package/dist/scripts/migration/passes/02-tags.d.ts +13 -0
  80. package/dist/scripts/migration/passes/02-tags.js +150 -0
  81. package/dist/scripts/migration/passes/03-equipment.d.ts +14 -0
  82. package/dist/scripts/migration/passes/03-equipment.js +176 -0
  83. package/dist/scripts/migration/passes/04-projects.d.ts +27 -0
  84. package/dist/scripts/migration/passes/04-projects.js +241 -0
  85. package/dist/scripts/migration/passes/05-articles.d.ts +25 -0
  86. package/dist/scripts/migration/passes/05-articles.js +203 -0
  87. package/dist/scripts/migration/passes/06-formconfigs.d.ts +17 -0
  88. package/dist/scripts/migration/passes/06-formconfigs.js +166 -0
  89. package/dist/scripts/migration/passes/07-meta-configs.d.ts +46 -0
  90. package/dist/scripts/migration/passes/07-meta-configs.js +185 -0
  91. package/dist/scripts/migration/passes/08-tickets.d.ts +27 -0
  92. package/dist/scripts/migration/passes/08-tickets.js +353 -0
  93. package/dist/scripts/migration/passes/09-ticket-meta.d.ts +12 -0
  94. package/dist/scripts/migration/passes/09-ticket-meta.js +136 -0
  95. package/dist/scripts/migration/passes/10-chat.d.ts +17 -0
  96. package/dist/scripts/migration/passes/10-chat.js +230 -0
  97. package/dist/scripts/migration/passes/11-assets.d.ts +17 -0
  98. package/dist/scripts/migration/passes/11-assets.js +93 -0
  99. package/dist/scripts/migration/passes/12-history.d.ts +24 -0
  100. package/dist/scripts/migration/passes/12-history.js +332 -0
  101. package/dist/scripts/migration/run.d.ts +1 -0
  102. package/dist/scripts/migration/run.js +148 -0
  103. package/dist/scripts/migration/verify.d.ts +1 -0
  104. package/dist/scripts/migration/verify.js +620 -0
  105. package/dist/scripts/seed-v1-testdata.d.ts +41 -0
  106. package/dist/scripts/seed-v1-testdata.js +543 -0
  107. package/dist/types/BDE_Article.d.ts +3 -0
  108. package/dist/types/BDE_Article.js +6 -0
  109. package/dist/types/Chat_Message.d.ts +49 -0
  110. package/dist/types/Chat_Message.js +87 -0
  111. package/dist/types/MES_RessourceOverview.d.ts +38 -0
  112. package/dist/types/MES_RessourceOverview.js +63 -0
  113. package/dist/types/Meta_Context.d.ts +43 -0
  114. package/dist/types/Meta_Context.js +75 -0
  115. package/dist/types/Service_Equipment.d.ts +33 -0
  116. package/dist/types/Service_Equipment.js +57 -0
  117. package/dist/types/Service_FormConfig.d.ts +33 -0
  118. package/dist/types/Service_FormConfig.js +57 -0
  119. package/dist/types/Service_MeterReading.d.ts +35 -0
  120. package/dist/types/Service_MeterReading.js +57 -0
  121. package/dist/types/Service_Project.d.ts +53 -0
  122. package/dist/types/Service_Project.js +93 -0
  123. package/dist/types/Service_Schedule.d.ts +66 -0
  124. package/dist/types/Service_Schedule.js +117 -0
  125. package/dist/types/Service_Schedule_Execution.d.ts +38 -0
  126. package/dist/types/Service_Schedule_Execution.js +63 -0
  127. package/dist/types/Service_Schedule_Template.d.ts +36 -0
  128. package/dist/types/Service_Schedule_Template.js +60 -0
  129. package/dist/types/Service_Status.d.ts +30 -0
  130. package/dist/types/Service_Status.js +51 -0
  131. package/dist/types/Service_Tag.d.ts +30 -0
  132. package/dist/types/Service_Tag.js +51 -0
  133. package/dist/types/Service_Ticket.d.ts +92 -0
  134. package/dist/types/Service_Ticket.js +165 -0
  135. package/dist/types/Service_TicketCounter.d.ts +18 -0
  136. package/dist/types/Service_TicketCounter.js +27 -0
  137. package/dist/types/Service_TriggerState.d.ts +31 -0
  138. package/dist/types/Service_TriggerState.js +51 -0
  139. package/dist/types/_Session.d.ts +0 -1
  140. package/dist/types/_User.d.ts +2 -6
  141. package/dist/types/custom/MetaField.d.ts +9 -0
  142. package/dist/types/custom/TicketMaterial.d.ts +32 -0
  143. package/dist/types/custom/TicketMaterial.js +2 -0
  144. package/dist/types/index.d.ts +30 -0
  145. package/dist/types/index.js +33 -3
  146. package/package.json +6 -3
  147. package/schema/BDE_Article.json +3 -0
  148. package/schema/Changelog.json +3 -6
  149. package/schema/Chat_Message.json +88 -0
  150. package/schema/Meta_Context.json +78 -0
  151. package/schema/Service_Equipment.json +55 -0
  152. package/schema/Service_FormConfig.json +66 -0
  153. package/schema/Service_MeterReading.json +54 -0
  154. package/schema/Service_MigrationMap.json +55 -0
  155. package/schema/Service_Project.json +77 -0
  156. package/schema/Service_Schedule.json +101 -0
  157. package/schema/Service_Schedule_Execution.json +58 -0
  158. package/schema/Service_Schedule_Template.json +73 -0
  159. package/schema/Service_Status.json +50 -0
  160. package/schema/Service_Tag.json +50 -0
  161. package/schema/Service_Ticket.json +114 -0
  162. package/schema/Service_TicketCounter.json +38 -0
  163. package/schema/Service_TriggerState.json +50 -0
@@ -0,0 +1,168 @@
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.init = init;
7
+ const node_1 = __importDefault(require("parse/node"));
8
+ const index_js_1 = require("../features/schema/index.js");
9
+ const index_js_2 = require("../types/index.js");
10
+ async function init() {
11
+ (0, index_js_1.beforeSaveHook)(index_js_2.Service_Tag, async (request) => {
12
+ const { object, original, user } = request;
13
+ await (0, index_js_1.defaultHandler)(request);
14
+ await (0, index_js_1.defaultAclHandler)(request);
15
+ // TODO
16
+ });
17
+ (0, index_js_1.afterSaveHook)(index_js_2.Service_Tag, async ({ object, original }) => {
18
+ // Entities store the *ancestor closure* of the tags picked on them (see the
19
+ // tags README). Re-parenting a tag changes those ancestors, so every
20
+ // equipment/ticket whose closure includes this tag must be rebuilt.
21
+ // A brand-new tag (no `original`) has nothing referencing it yet.
22
+ if (!original)
23
+ return;
24
+ // A migration re-run can re-parent an already-imported tag; rebuilding
25
+ // closures mid-import would race the pass that is still writing them. The
26
+ // migration rebuilds once at the end instead. See helper/migrationMode.ts.
27
+ if ((0, index_js_1.isMigrationMode)())
28
+ return;
29
+ const oldParentId = getParentId(original);
30
+ const newParentId = getParentId(object);
31
+ if (oldParentId === newParentId)
32
+ return; // parent unchanged → closures fine
33
+ await reconcileClosures(object, oldParentId, newParentId);
34
+ });
35
+ (0, index_js_1.afterDeleteHook)(index_js_2.Service_Tag, async (request) => {
36
+ const { object } = request;
37
+ await cleanupTagArrays(object);
38
+ });
39
+ }
40
+ async function cleanupTagArrays(object) {
41
+ if (!object.id)
42
+ return;
43
+ await Promise.all([
44
+ removeTagFromClass(index_js_2.Service_Ticket, object),
45
+ removeTagFromClass(index_js_2.Service_Equipment, object),
46
+ ]);
47
+ }
48
+ async function removeTagFromClass(ObjectClass, tag) {
49
+ const query = new node_1.default.Query(ObjectClass);
50
+ query.equalTo("tags", tag.id);
51
+ // A tag can only be referenced by objects of the same tenant, so we narrow
52
+ // the query to avoid scanning (and touching) other tenants' data.
53
+ if (tag.tenant) {
54
+ query.equalTo("tenant", tag.tenant);
55
+ }
56
+ const affected = await query.findAll({ useMasterKey: true });
57
+ for (const object of affected) {
58
+ object.remove("tags", tag.id);
59
+ }
60
+ if (affected.length > 0) {
61
+ await node_1.default.Object.saveAll(affected, { useMasterKey: true });
62
+ }
63
+ }
64
+ function getParentId(tag) {
65
+ return tag.get("parent")?.id ?? null;
66
+ }
67
+ /** Ancestor ids, nearest first; stops on an unknown parent, breaks cycles. */
68
+ function ancestorsOf(id, parents) {
69
+ const out = [];
70
+ const seen = new Set([id]);
71
+ let cur = parents.get(id) ?? null;
72
+ while (cur && !seen.has(cur)) {
73
+ seen.add(cur);
74
+ out.push(cur);
75
+ cur = parents.get(cur) ?? null;
76
+ }
77
+ return out;
78
+ }
79
+ /** Every id plus all of its ancestors, de-duplicated, order preserved. */
80
+ function closureOf(ids, parents) {
81
+ const out = [];
82
+ const seen = new Set();
83
+ const add = (id) => {
84
+ if (seen.has(id))
85
+ return;
86
+ seen.add(id);
87
+ out.push(id);
88
+ };
89
+ for (const id of ids) {
90
+ add(id);
91
+ for (const anc of ancestorsOf(id, parents))
92
+ add(anc);
93
+ }
94
+ return out;
95
+ }
96
+ /** Collapse a closure back to only its deepest members. Unknown ids are kept. */
97
+ function deepestOf(ids, parents) {
98
+ const set = new Set(ids);
99
+ const impliedByChild = new Set();
100
+ for (const id of ids) {
101
+ for (const anc of ancestorsOf(id, parents)) {
102
+ if (set.has(anc))
103
+ impliedByChild.add(anc);
104
+ }
105
+ }
106
+ const seen = new Set();
107
+ return ids.filter((id) => {
108
+ if (impliedByChild.has(id) || seen.has(id))
109
+ return false;
110
+ seen.add(id);
111
+ return true;
112
+ });
113
+ }
114
+ function sameMembers(a, b) {
115
+ if (a.length !== b.length)
116
+ return false;
117
+ const set = new Set(a);
118
+ return b.every((x) => set.has(x));
119
+ }
120
+ /**
121
+ * Rebuild the ancestor closures stored on equipment/tickets after `tag` was
122
+ * re-parented. Only this tag moved, so the "old tree" is the current tree with
123
+ * this tag's parent reverted — recovering the user's real (deepest) picks under
124
+ * the old tree lets us drop now-invalid ancestors when re-expanding under the
125
+ * new tree.
126
+ */
127
+ async function reconcileClosures(tag, oldParentId, newParentId) {
128
+ const tagId = tag.id;
129
+ if (!tagId)
130
+ return;
131
+ const tenant = tag.tenant;
132
+ const query = new node_1.default.Query(index_js_2.Service_Tag);
133
+ if (tenant)
134
+ query.equalTo("tenant", tenant);
135
+ const allTags = await query.findAll({ useMasterKey: true });
136
+ const newParents = new Map();
137
+ for (const t of allTags) {
138
+ if (t.id)
139
+ newParents.set(t.id, getParentId(t));
140
+ }
141
+ newParents.set(tagId, newParentId);
142
+ const oldParents = new Map(newParents);
143
+ oldParents.set(tagId, oldParentId);
144
+ await Promise.all([
145
+ reconcileClass(index_js_2.Service_Ticket, tagId, tenant, oldParents, newParents),
146
+ reconcileClass(index_js_2.Service_Equipment, tagId, tenant, oldParents, newParents),
147
+ ]);
148
+ }
149
+ async function reconcileClass(ObjectClass, tagId, tenant, oldParents, newParents) {
150
+ const query = new node_1.default.Query(ObjectClass);
151
+ query.equalTo("tags", tagId);
152
+ // A tag is only referenced by objects of the same tenant.
153
+ if (tenant)
154
+ query.equalTo("tenant", tenant);
155
+ const affected = await query.findAll({ useMasterKey: true });
156
+ const toSave = [];
157
+ for (const object of affected) {
158
+ const current = object.get("tags") ?? [];
159
+ const rebuilt = closureOf(deepestOf(current, oldParents), newParents);
160
+ if (!sameMembers(current, rebuilt)) {
161
+ object.set("tags", rebuilt);
162
+ toSave.push(object);
163
+ }
164
+ }
165
+ if (toSave.length > 0) {
166
+ await node_1.default.Object.saveAll(toSave, { useMasterKey: true });
167
+ }
168
+ }
@@ -0,0 +1 @@
1
+ export declare function init(): Promise<void>;
@@ -0,0 +1,433 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.init = init;
40
+ const node_1 = __importStar(require("parse/node"));
41
+ const i18next_1 = __importDefault(require("i18next"));
42
+ const index_js_1 = require("../features/schema/index.js");
43
+ const getUserLanguage_js_1 = require("../helper/getUserLanguage.js");
44
+ const index_js_2 = require("../types/index.js");
45
+ const index_js_3 = require("../features/config/index.js");
46
+ const applyRuleSet_js_1 = require("../features/ruleset/applyRuleSet.js");
47
+ async function init() {
48
+ (0, index_js_1.beforeSaveHook)("OD3_Service_Ticket", async (request) => {
49
+ const { object, original, user } = request;
50
+ const isNew = object.isNew();
51
+ if (isNew && !object.get("createdBy") && user)
52
+ object.set("createdBy", user);
53
+ await (0, index_js_1.defaultHandler)(request);
54
+ await (0, index_js_1.defaultAclHandler)(request, {
55
+ allowCustomACL: true,
56
+ allowTenantUserWrite: true,
57
+ });
58
+ await setTicketACL(object);
59
+ // Assign a sequential ticket number on creation. Done here (backend) rather
60
+ // than in the frontend because every write path — the create form, the
61
+ // cross-plugin service, and the generic entity admin — funnels through this
62
+ // beforeSave, and the counter increment is atomic (no client race).
63
+ //
64
+ // Also re-number when an existing ticket's `project` changes: numbers are a
65
+ // per-project sequence, so a ticket keeping its old-project number after a
66
+ // move could collide with an existing number in the new project. Drawing a
67
+ // fresh number from the destination keeps each project's sequence unique
68
+ // (the source sequence simply gains a gap, which is fine).
69
+ const projectChanged = !isNew && object.dirty("project");
70
+ if ((isNew && object.get("number") == null) || projectChanged) {
71
+ await assignTicketNumber(object);
72
+ }
73
+ });
74
+ (0, index_js_1.afterDeleteHook)(index_js_2.Service_Ticket, async ({ object }) => {
75
+ if (object.id)
76
+ await cleanupTicketRelations(object.id);
77
+ });
78
+ (0, index_js_1.afterSaveHook)(index_js_2.Service_Ticket, async ({ object, original, user }) => {
79
+ // A bulk import would otherwise notify every assignee once per migrated
80
+ // ticket (~10k per tenant). See helper/migrationMode.ts.
81
+ if ((0, index_js_1.isMigrationMode)())
82
+ return;
83
+ try {
84
+ // Shared across the assignee + watcher passes so a user who is both an
85
+ // assignee and a matched watcher is notified only once (assignee first).
86
+ const notified = new Set();
87
+ if (user?.id)
88
+ notified.add(user.id); // never notify the acting user
89
+ const assignerName = getUsername(user);
90
+ await notifyNewAssignees(object, original, notified, assignerName);
91
+ // Conditional "watcher" rules are evaluated on ticket creation only for
92
+ // now — re-evaluating them on every edit (and tracking who was already
93
+ // notified across edits) is deferred.
94
+ if (!original) {
95
+ await notifyWatchers(object, notified, assignerName);
96
+ }
97
+ }
98
+ catch (error) {
99
+ console.error("[@openinc/parse-server-opendash][Service_Ticket] Failed to notify on assignment", error);
100
+ }
101
+ });
102
+ }
103
+ /** Upper bound on related records deleted per ticket — far above any realistic
104
+ * meta/chat/notification count for a single ticket. */
105
+ const RELATED_DELETE_LIMIT = 10000;
106
+ /**
107
+ * Removes the data attached to a ticket after it is deleted. Parse cannot
108
+ * cascade these on its own: meta head-data ({@link Meta_Entry}) and chat
109
+ * messages ({@link Chat_Message}) link to the ticket by a plain string key
110
+ * rather than a pointer, and notifications reference it inside their JSON `data`
111
+ * blob. Without this hook all three would be orphaned.
112
+ *
113
+ * Child tickets (the `parent` self-pointer) are intentionally left untouched.
114
+ *
115
+ * This is the single cleanup path for both single deletes and the dev
116
+ * "delete all" command, so the frontend no longer needs to delete related data
117
+ * itself.
118
+ */
119
+ async function cleanupTicketRelations(ticketId) {
120
+ const [metaEntries, chatMessages, notifications] = await Promise.all([
121
+ new node_1.default.Query(index_js_2.Meta_Entry)
122
+ .equalTo("entryClassName", index_js_2.Service_Ticket.className)
123
+ .equalTo("entryObjectId", ticketId)
124
+ .limit(RELATED_DELETE_LIMIT)
125
+ .find({ useMasterKey: true }),
126
+ // Chat context is stored as `contextToString(["service","ticket",id])`,
127
+ // i.e. a JSON.stringify of the tuple — match it exactly.
128
+ new node_1.default.Query(index_js_2.Chat_Message)
129
+ .equalTo("context", JSON.stringify(["service", "ticket", ticketId]))
130
+ .limit(RELATED_DELETE_LIMIT)
131
+ .find({ useMasterKey: true }),
132
+ // `data` is a JSON Object column; Parse supports dot-notation into it. Query
133
+ // by class name (untyped) so the `data.ticketId` path key is accepted.
134
+ new node_1.default.Query(index_js_2.Notification.className)
135
+ .equalTo("data.ticketId", ticketId)
136
+ .limit(RELATED_DELETE_LIMIT)
137
+ .find({ useMasterKey: true }),
138
+ ]);
139
+ await node_1.default.Object.destroyAll([...metaEntries, ...chatMessages, ...notifications], { useMasterKey: true });
140
+ }
141
+ /**
142
+ * Assigns a sequential `number` to a new ticket.
143
+ *
144
+ * - With a project: atomically increments that project's `ticketCounter` and
145
+ * uses the result, so numbers are per-project sequences.
146
+ * - Without a project: falls back to a per-tenant counter
147
+ * (`OD3_Service_TicketCounter`, one row per tenant), so every ticket still
148
+ * gets a number.
149
+ *
150
+ * The increments use Parse's atomic `$inc`, so concurrent creates never collide.
151
+ */
152
+ async function assignTicketNumber(ticket) {
153
+ const project = ticket.get("project");
154
+ if (project) {
155
+ await project.fetch({ useMasterKey: true });
156
+ project.increment("ticketCounter", 1);
157
+ await project.save(null, { useMasterKey: true });
158
+ ticket.set("number", project.get("ticketCounter"));
159
+ return;
160
+ }
161
+ const tenant = ticket.get("tenant");
162
+ if (!tenant)
163
+ return; // no scope to count within — leave unnumbered
164
+ ticket.set("number", await nextTenantTicketNumber(tenant));
165
+ }
166
+ /** Find-or-create the tenant's ticket counter and return the next value. */
167
+ async function nextTenantTicketNumber(tenant) {
168
+ const existing = await new node_1.default.Query("OD3_Service_TicketCounter")
169
+ .equalTo("tenant", tenant)
170
+ .first({ useMasterKey: true });
171
+ if (existing) {
172
+ existing.increment("value", 1);
173
+ await existing.save(null, { useMasterKey: true });
174
+ return existing.get("value");
175
+ }
176
+ const Counter = node_1.default.Object.extend("OD3_Service_TicketCounter");
177
+ const counter = new Counter();
178
+ counter.set("tenant", tenant);
179
+ counter.set("value", 1);
180
+ await counter.save(null, { useMasterKey: true });
181
+ return 1;
182
+ }
183
+ /**
184
+ * Sets the ticket ACL from `createdBy`, `assignedUsers` and `assignedRoles` —
185
+ * each gets read/write. `assignedUsers`/`assignedRoles` are arrays of Parse
186
+ * pointers, so we iterate the entries (by object id) rather than the array
187
+ * indices. Parse ACL role grants require the role *name*, not its id, so the
188
+ * assigned role pointers are resolved to names first.
189
+ */
190
+ async function setTicketACL(object) {
191
+ const acl = object.getACL() ?? new node_1.default.ACL();
192
+ // deny global read / write
193
+ acl.setPublicReadAccess(false);
194
+ acl.setPublicWriteAccess(false);
195
+ // creator read/write
196
+ const creatorId = object.get("createdBy")?.id;
197
+ if (creatorId) {
198
+ acl.setReadAccess(creatorId, true);
199
+ acl.setWriteAccess(creatorId, true);
200
+ }
201
+ // assigned users read/write
202
+ for (const userId of pointerIds(object.get("assignedUsers"))) {
203
+ acl.setReadAccess(userId, true);
204
+ acl.setWriteAccess(userId, true);
205
+ }
206
+ // assigned roles read/write (resolve pointer ids → role names)
207
+ const roleIds = pointerIds(object.get("assignedRoles"));
208
+ if (roleIds.length > 0) {
209
+ const roles = await new node_1.default.Query(node_1.default.Role)
210
+ .containedIn("objectId", roleIds)
211
+ .find({ useMasterKey: true });
212
+ for (const role of roles) {
213
+ const name = role.getName();
214
+ if (name) {
215
+ acl.setRoleReadAccess(name, true);
216
+ acl.setRoleWriteAccess(name, true);
217
+ }
218
+ }
219
+ }
220
+ object.setACL(acl);
221
+ }
222
+ /**
223
+ * Notifies the users and roles that were newly assigned to a ticket.
224
+ *
225
+ * Runs on every save and diffs the current `assignedUsers`/`assignedRoles`
226
+ * (arrays of pointers) against the previous version (`original`). On creation
227
+ * `original` is undefined, so everyone assigned is new; on an edit only the
228
+ * added assignees are notified, so re-saving a ticket never re-notifies people
229
+ * who were already assigned.
230
+ *
231
+ * Dedup + precedence mirror the chat-mention feature: one notification per user,
232
+ * directly assigned users win over role membership (`users` > `roles`), and the
233
+ * acting user (whoever performed the save) is never notified.
234
+ */
235
+ async function notifyNewAssignees(ticket, original, notified, assignerName) {
236
+ const newUserIds = addedIds(ticket.get("assignedUsers"), original?.get("assignedUsers"));
237
+ const newRoleIds = addedIds(ticket.get("assignedRoles"), original?.get("assignedRoles"));
238
+ if (newUserIds.length === 0 && newRoleIds.length === 0)
239
+ return;
240
+ await notifyRecipients(newUserIds, newRoleIds, notified, ticket, {
241
+ variant: "assigned",
242
+ assignerName,
243
+ });
244
+ }
245
+ /** OD3_Config key holding the per-tenant assignment-notification rules. */
246
+ const ASSIGNMENT_NOTIFICATION_CONFIG_KEY = "OPENSERVICE_ASSIGNMENT_NOTIFICATIONS";
247
+ /**
248
+ * Notifies the configured "watchers": users/roles that a conditional ruleset
249
+ * says should hear about a ticket, independent of assignment. The rules live in
250
+ * a per-tenant {@link Config} row (`OPENSERVICE_ASSIGNMENT_NOTIFICATIONS`) as a
251
+ * JSON array of RuleSet function entries authored in the frontend admin. Each
252
+ * matching rule's assignees (`user:<id>` / `role:<id>`) are notified, deduped
253
+ * against the shared `notified` set (so assignees already handled win).
254
+ */
255
+ async function notifyWatchers(ticket, notified, assignerName) {
256
+ const rules = await loadWatcherRules(ticket.get("tenant"));
257
+ if (rules.length === 0)
258
+ return;
259
+ const getValue = (field) => ticket.get(field);
260
+ for (const rule of rules) {
261
+ if (rule.function !== "notify" || !rule.ruleset)
262
+ continue;
263
+ if (!(0, applyRuleSet_js_1.applyRuleSet)(rule.ruleset, getValue))
264
+ continue;
265
+ const assignees = Array.isArray(rule.params?.assignees)
266
+ ? rule.params.assignees
267
+ : [];
268
+ const userIds = assignees
269
+ .filter((a) => a.startsWith("user:"))
270
+ .map((a) => a.slice(5));
271
+ const roleIds = assignees
272
+ .filter((a) => a.startsWith("role:"))
273
+ .map((a) => a.slice(5));
274
+ await notifyRecipients(userIds, roleIds, notified, ticket, {
275
+ variant: "watcher",
276
+ assignerName,
277
+ frontpage: rule.params?.frontpage === true,
278
+ });
279
+ }
280
+ }
281
+ /** Loads and parses the tenant's assignment-notification rules. */
282
+ async function loadWatcherRules(tenant) {
283
+ if (!tenant)
284
+ return [];
285
+ const config = await new node_1.default.Query(index_js_2.Config)
286
+ .equalTo("key", ASSIGNMENT_NOTIFICATION_CONFIG_KEY)
287
+ .equalTo("tenant", tenant)
288
+ .first({ useMasterKey: true });
289
+ const raw = config?.get("value");
290
+ if (typeof raw !== "string")
291
+ return [];
292
+ try {
293
+ const parsed = JSON.parse(raw);
294
+ return Array.isArray(parsed) ? parsed : [];
295
+ }
296
+ catch {
297
+ return [];
298
+ }
299
+ }
300
+ /**
301
+ * Resolves a set of user ids + role ids to concrete recipients and sends one
302
+ * notification each, skipping anyone already in `notified`. Directly listed
303
+ * users take precedence over users reached via a role. `notified` is mutated so
304
+ * callers can chain multiple passes (assignees, then watchers) with shared
305
+ * de-duplication.
306
+ */
307
+ async function notifyRecipients(userIds, roleIds, notified, ticket, options) {
308
+ const users = userIds.length
309
+ ? await new node_1.default.Query(node_1.default.User)
310
+ .containedIn("objectId", userIds)
311
+ .findAll({ useMasterKey: true })
312
+ : [];
313
+ for (const user of users) {
314
+ if (!user.id || notified.has(user.id))
315
+ continue;
316
+ notified.add(user.id);
317
+ await sendAssignmentNotification(user, ticket, options);
318
+ }
319
+ const roles = roleIds.length
320
+ ? await new node_1.default.Query(node_1.Role)
321
+ .containedIn("objectId", roleIds)
322
+ .findAll({ useMasterKey: true })
323
+ : [];
324
+ for (const role of roles) {
325
+ const members = await role
326
+ .relation("users")
327
+ .query()
328
+ .findAll({ useMasterKey: true });
329
+ for (const member of members) {
330
+ if (!member.id || notified.has(member.id))
331
+ continue;
332
+ notified.add(member.id);
333
+ await sendAssignmentNotification(member, ticket, options);
334
+ }
335
+ }
336
+ }
337
+ /**
338
+ * Builds and saves a single assignment notification, translated into the
339
+ * recipient's preferred language. Delivery (email + web push) is handled
340
+ * automatically by the {@link Notification} afterSave hook.
341
+ */
342
+ async function sendAssignmentNotification(user, ticket, options) {
343
+ await i18next_1.default.changeLanguage(await (0, getUserLanguage_js_1.getUserLanguage)(user));
344
+ const ticketLink = await getTicketLink(ticket, user);
345
+ const base = `openservice:ticket.assignment.${options.variant}`;
346
+ const ticketName = ticket.get("title");
347
+ await new index_js_2.Notification({
348
+ title: i18next_1.default.t(`${base}.title`),
349
+ description: i18next_1.default.t(`${base}.description`, {
350
+ username: options.assignerName,
351
+ ticketName,
352
+ ticketLink,
353
+ }),
354
+ type: options.frontpage ? "frontpage" : "mailbox",
355
+ data: {
356
+ ticketId: ticket.id,
357
+ translation: {
358
+ username: options.assignerName,
359
+ ticketName,
360
+ ticketId: ticket.id,
361
+ },
362
+ },
363
+ user: user,
364
+ isSent: false,
365
+ }).save(null, { useMasterKey: true });
366
+ }
367
+ /** Ids present in `current` (array of pointers) but not in `previous`. */
368
+ function addedIds(current, previous) {
369
+ const previousIds = new Set(pointerIds(previous));
370
+ return pointerIds(current).filter((id) => !previousIds.has(id));
371
+ }
372
+ /** Extracts the object ids from an array of Parse pointers, tolerating
373
+ * undefined / malformed entries. */
374
+ function pointerIds(value) {
375
+ if (!Array.isArray(value))
376
+ return [];
377
+ return value
378
+ .map((entry) => {
379
+ // The entity adapter stores array-of-ref fields as plain id
380
+ // STRINGS, not Parse.Objects. Reading `.id` off them returned
381
+ // undefined, so setTicketACL granted assignees nothing and
382
+ // notifyNewAssignees found nobody — silently, for every ticket.
383
+ if (typeof entry === "string")
384
+ return entry;
385
+ if (entry && typeof entry === "object") {
386
+ const o = entry;
387
+ return o.id ?? o.objectId;
388
+ }
389
+ return undefined;
390
+ })
391
+ .filter((id) => typeof id === "string");
392
+ }
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
+ function getUsername(user) {
420
+ if (!user)
421
+ return "System";
422
+ let username = "";
423
+ if (user?.get("firstname") && user?.get("lastname")) {
424
+ username = user?.get("firstname") + " " + user?.get("lastname");
425
+ }
426
+ else if (user?.get("name")) {
427
+ username = user.get("name");
428
+ }
429
+ else if (user?.getUsername()) {
430
+ username = user?.getUsername();
431
+ }
432
+ return username ?? "System";
433
+ }
@@ -0,0 +1 @@
1
+ export declare function init(): Promise<void>;
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.init = init;
4
+ const index_js_1 = require("../features/schema/index.js");
5
+ const index_js_2 = require("../types/index.js");
6
+ async function init() {
7
+ (0, index_js_1.beforeSaveHook)(index_js_2.Service_TriggerState, async (request) => {
8
+ const { object, original, user } = request;
9
+ await (0, index_js_1.defaultHandler)(request);
10
+ await (0, index_js_1.defaultAclHandler)(request);
11
+ // TODO
12
+ });
13
+ (0, index_js_1.afterSaveHook)(index_js_2.Service_TriggerState, async (request) => {
14
+ const { object, original, user } = request;
15
+ // TODO
16
+ });
17
+ }
@@ -23,10 +23,24 @@ async function init() {
23
23
  ];
24
24
  console.log(`ChangeLog blacklisted classes: ${blacklistedClasses.join(", ") || "None"}`);
25
25
  (0, index_js_1.beforeSaveHook)(index_js_3.Changelog.className, async (request) => {
26
- const { object, original, user } = request;
26
+ const { object, user } = request;
27
27
  await (0, index_js_1.defaultHandler)(request);
28
- await (0, index_js_1.defaultAclHandler)(request);
29
- if (user && user.id && object && object.getACL()) {
28
+ // Mirror the ACL of the changed object instead of locking the changelog to
29
+ // the od-admin role: whoever can see the underlying object can see its
30
+ // history. The object snapshot lives in `value` for create/update and in
31
+ // `original` for delete (both are `Parse.Object.toJSON()` output, which
32
+ // carries the object's ACL under the `ACL` key).
33
+ const snapshot = object.get("value") ?? object.get("original");
34
+ const sourceAcl = snapshot?.ACL;
35
+ if (sourceAcl) {
36
+ object.setACL(new node_1.default.ACL(sourceAcl));
37
+ }
38
+ else {
39
+ // No ACL on the source (e.g. public or masterkey-only object): fall back
40
+ // to the restrictive default rather than leaving the changelog public.
41
+ await (0, index_js_1.defaultAclHandler)(request);
42
+ }
43
+ if (user && user.id && object.getACL()) {
30
44
  object.getACL().setReadAccess(user.id, true);
31
45
  }
32
46
  });
@@ -77,9 +91,16 @@ async function init() {
77
91
  (0, index_js_1.beforeDeleteHook)(classSchema.className, async (request) => {
78
92
  console.log(`Creating ChangeLog entry for DELETE on class ${classSchema.className} and object ${request.object.id}`);
79
93
  try {
80
- const { object, user, master } = request;
81
- if (blacklistedClasses.includes(classSchema.className)) {
82
- console.log(`Skipping ChangeLog entry for class ${classSchema.className} and object ${object.id} due to blacklisted class.`);
94
+ const { object, user, master, context } = request;
95
+ // `context.skipChangelog` is honoured here as well as on afterSave. The
96
+ // two were asymmetric: a caller could suppress the create/update entry but
97
+ // not the delete entry, so a bulk cleanup (a migration rollback, a dev
98
+ // purge) wrote one delete entry per row into the very table it was trying
99
+ // to clear. Parse does pass `context` to beforeDelete, and the JS SDK
100
+ // forwards it on `destroy`/`destroyAll`, so nothing else was missing.
101
+ if (context?.skipChangelog ||
102
+ blacklistedClasses.includes(classSchema.className)) {
103
+ console.log(`Skipping ChangeLog entry for class ${classSchema.className} and object ${object.id} due to ${context?.skipChangelog ? "context flag" : "blacklisted class"}.`);
83
104
  return;
84
105
  }
85
106
  const changelog = new index_js_3.Changelog();