@openinc/parse-server-opendash 4.2.0 → 4.2.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 (24) hide show
  1. package/dist/features/openservice/serviceSchedules/acl/assignmentAcl.d.ts +30 -0
  2. package/dist/features/openservice/serviceSchedules/acl/assignmentAcl.js +85 -0
  3. package/dist/features/openservice/serviceSchedules/acl/index.d.ts +1 -0
  4. package/dist/features/openservice/serviceSchedules/acl/index.js +6 -0
  5. package/dist/features/openservice/serviceSchedules/calendarSync/functions/outlookRecurrence.d.ts +20 -0
  6. package/dist/features/openservice/serviceSchedules/calendarSync/functions/outlookRecurrence.js +50 -7
  7. package/dist/features/openservice/serviceSchedules/calendarSync/service/syncPlanToCalendar.js +8 -0
  8. package/dist/features/permissions/types/Permissions.d.ts +5 -6
  9. package/dist/features/permissions/types/Permissions.js +13 -8
  10. package/dist/hooks/Service_Schedule.js +43 -17
  11. package/dist/hooks/Service_Schedule_Template.js +28 -7
  12. package/dist/hooks/Service_Ticket.js +2 -2
  13. package/dist/types/Service_Schedule.d.ts +9 -3
  14. package/dist/types/Service_Schedule.js +9 -0
  15. package/dist/types/Service_Schedule_Execution.d.ts +6 -3
  16. package/dist/types/Service_Schedule_Execution.js +6 -0
  17. package/dist/types/Service_Schedule_Template.d.ts +7 -3
  18. package/dist/types/Service_Schedule_Template.js +6 -0
  19. package/package.json +5 -4
  20. package/schema/Service_Schedule.json +8 -0
  21. package/schema/Service_Schedule_Execution.json +5 -0
  22. package/schema/Service_Schedule_Template.json +4 -0
  23. package/dist/features/ruleset/applyRuleSet.d.ts +0 -41
  24. package/dist/features/ruleset/applyRuleSet.js +0 -81
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Marks a save this module made itself, so the `afterSave` that follows it does
3
+ * not start over. Without it every ACL write would trigger another pass — the
4
+ * second one is a no-op and would stop, but the round trip is pure waste.
5
+ */
6
+ export declare const ASSIGNMENT_ACL_CONTEXT_FLAG = "fromAssignmentAcl";
7
+ /** Which relations grant read access on this class. */
8
+ export interface AssignmentAclOptions {
9
+ /** Relation of `_Role`s that may read the record. */
10
+ roleRelations: string[];
11
+ /** Relation of `_User`s that may read the record. */
12
+ userRelations?: string[];
13
+ }
14
+ /**
15
+ * Who may see a maintenance plan (or a template): its creator, the roles and
16
+ * users assigned to it, and the administrators — nobody else.
17
+ *
18
+ * **Why this is rebuilt rather than amended.** Un-assigning somebody has to take
19
+ * their access away again, and an ACL carries no record of *why* a grant is
20
+ * there. Adding grants on every save would therefore be a one-way door: a role
21
+ * removed from the plan would keep reading it forever. The ACL is derived from
22
+ * the assignments instead, so it always says exactly what the assignments say.
23
+ *
24
+ * `od-admin` and `od-tenant-admin-<tenant>` are re-added by `defaultAclHandler`
25
+ * on the save that follows, so an assignment can never lock administrators out.
26
+ *
27
+ * Runs in `afterSave`, not `beforeSave`: relations are only queryable once the
28
+ * pending add/remove operations have been applied, which happens on save.
29
+ */
30
+ export declare function syncAssignmentAcl(object: Parse.Object, options: AssignmentAclOptions): Promise<void>;
@@ -0,0 +1,85 @@
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.ASSIGNMENT_ACL_CONTEXT_FLAG = void 0;
7
+ exports.syncAssignmentAcl = syncAssignmentAcl;
8
+ const node_1 = __importDefault(require("parse/node"));
9
+ /**
10
+ * Marks a save this module made itself, so the `afterSave` that follows it does
11
+ * not start over. Without it every ACL write would trigger another pass — the
12
+ * second one is a no-op and would stop, but the round trip is pure waste.
13
+ */
14
+ exports.ASSIGNMENT_ACL_CONTEXT_FLAG = "fromAssignmentAcl";
15
+ /**
16
+ * Who may see a maintenance plan (or a template): its creator, the roles and
17
+ * users assigned to it, and the administrators — nobody else.
18
+ *
19
+ * **Why this is rebuilt rather than amended.** Un-assigning somebody has to take
20
+ * their access away again, and an ACL carries no record of *why* a grant is
21
+ * there. Adding grants on every save would therefore be a one-way door: a role
22
+ * removed from the plan would keep reading it forever. The ACL is derived from
23
+ * the assignments instead, so it always says exactly what the assignments say.
24
+ *
25
+ * `od-admin` and `od-tenant-admin-<tenant>` are re-added by `defaultAclHandler`
26
+ * on the save that follows, so an assignment can never lock administrators out.
27
+ *
28
+ * Runs in `afterSave`, not `beforeSave`: relations are only queryable once the
29
+ * pending add/remove operations have been applied, which happens on save.
30
+ */
31
+ async function syncAssignmentAcl(object, options) {
32
+ const acl = new node_1.default.ACL();
33
+ // Administrators. Mirrors `defaultAclHandler` so the ACL is already complete
34
+ // when it is written, rather than only after the next save.
35
+ acl.setRoleReadAccess("od-admin", true);
36
+ acl.setRoleWriteAccess("od-admin", true);
37
+ const tenant = object.get("tenant");
38
+ if (tenant) {
39
+ acl.setRoleReadAccess(`od-tenant-admin-${tenant.id}`, true);
40
+ acl.setRoleWriteAccess(`od-tenant-admin-${tenant.id}`, true);
41
+ }
42
+ // The creator keeps write — the record is theirs.
43
+ const creator = object.get("user");
44
+ if (creator?.id) {
45
+ acl.setReadAccess(creator.id, true);
46
+ acl.setWriteAccess(creator.id, true);
47
+ }
48
+ for (const relation of options.roleRelations) {
49
+ for (const role of await readRelation(object, relation)) {
50
+ const name = role.get("name");
51
+ // Roles are addressed by name in an ACL, not by id.
52
+ if (name)
53
+ acl.setRoleReadAccess(name, true);
54
+ }
55
+ }
56
+ for (const relation of options.userRelations ?? []) {
57
+ for (const user of await readRelation(object, relation)) {
58
+ if (user.id)
59
+ acl.setReadAccess(user.id, true);
60
+ }
61
+ }
62
+ if (sameAcl(object.getACL(), acl))
63
+ return;
64
+ object.setACL(acl);
65
+ await object.save(null, {
66
+ useMasterKey: true,
67
+ context: { [exports.ASSIGNMENT_ACL_CONTEXT_FLAG]: true },
68
+ });
69
+ }
70
+ /** The members of a relation, or an empty list when it has none. */
71
+ async function readRelation(object, field) {
72
+ try {
73
+ return await object.relation(field).query().find({ useMasterKey: true });
74
+ }
75
+ catch {
76
+ // A relation that was never written has no backing table yet.
77
+ return [];
78
+ }
79
+ }
80
+ /** Whether two ACLs grant exactly the same thing. */
81
+ function sameAcl(current, next) {
82
+ if (!current)
83
+ return false;
84
+ return JSON.stringify(current.toJSON()) === JSON.stringify(next.toJSON());
85
+ }
@@ -0,0 +1 @@
1
+ export { ASSIGNMENT_ACL_CONTEXT_FLAG, syncAssignmentAcl, type AssignmentAclOptions, } from "./assignmentAcl.js";
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.syncAssignmentAcl = exports.ASSIGNMENT_ACL_CONTEXT_FLAG = void 0;
4
+ var assignmentAcl_js_1 = require("./assignmentAcl.js");
5
+ Object.defineProperty(exports, "ASSIGNMENT_ACL_CONTEXT_FLAG", { enumerable: true, get: function () { return assignmentAcl_js_1.ASSIGNMENT_ACL_CONTEXT_FLAG; } });
6
+ Object.defineProperty(exports, "syncAssignmentAcl", { enumerable: true, get: function () { return assignmentAcl_js_1.syncAssignmentAcl; } });
@@ -9,6 +9,16 @@ export type RRuleParts = {
9
9
  bymonth?: number[];
10
10
  count?: number;
11
11
  wkst?: string;
12
+ /**
13
+ * "The nth of the matching days" — `BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1` is
14
+ * "last workday of the month".
15
+ *
16
+ * Outlook has no equivalent: its `relativeMonthly` pattern takes *one*
17
+ * weekday and a position. With a single weekday the two agree (that is the
18
+ * ordinal `BYDAY` form); with several, the rule cannot be expressed — see
19
+ * {@link calendarUnsupportedReason}.
20
+ */
21
+ bysetpos?: number;
12
22
  };
13
23
  /**
14
24
  * Read an RRULE string into its parts.
@@ -29,6 +39,16 @@ export declare function buildRRule(parts: RRuleParts): string;
29
39
  * created by hand.
30
40
  */
31
41
  export declare function eventToCadence(start: Date, recurrence?: Recurrence): ServiceCadence;
42
+ /**
43
+ * Why this cadence cannot be written to a calendar — `undefined` when it can.
44
+ *
45
+ * Outlook's recurrence vocabulary is a **subset** of RFC 5545, and the gap is
46
+ * not cosmetic: a rule we cannot express would have to be approximated, and an
47
+ * approximated maintenance date in someone's calendar is worse than no entry
48
+ * at all. The sync therefore leaves such plans alone instead of guessing (see
49
+ * `syncPlanToCalendar`).
50
+ */
51
+ export declare function calendarUnsupportedReason(cadence: ServiceCadence | undefined): string | undefined;
32
52
  /**
33
53
  * A v2 cadence as an Outlook recurrence — `undefined` for a one-off, which
34
54
  * Graph expects as an event without a `recurrence` at all.
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseRRule = parseRRule;
4
4
  exports.buildRRule = buildRRule;
5
5
  exports.eventToCadence = eventToCadence;
6
+ exports.calendarUnsupportedReason = calendarUnsupportedReason;
6
7
  exports.cadenceToRecurrence = cadenceToRecurrence;
7
8
  /**
8
9
  * Outlook recurrence ↔ RRULE, both directions.
@@ -18,12 +19,12 @@ exports.cadenceToRecurrence = cadenceToRecurrence;
18
19
  * a maintenance date. `pnpm tsx scripts/…` round-trips it (see the module test
19
20
  * at the bottom of the file's docs in OPENSERVICE_V2_SCHEDULES.md).
20
21
  *
21
- * **Known asymmetry**: the plan editor's cadence UI understands
22
- * FREQ/INTERVAL/BYDAY/BYMONTHDAY/COUNT, but not `BYMONTH` or a positional
23
- * `BYDAY` (`+2TU`). Occurrence *maths* and the calendar are correct for those
24
- * (`@opendash/schedule` uses a full RRULE iterator) but a user who edits such
25
- * a plan's recurrence in the browser will simplify it. Yearly and "nth weekday"
26
- * series should therefore be maintained in Outlook.
22
+ * **Where the two vocabularies differ**: Outlook's patterns are a subset of the
23
+ * RRULE grammar. A positional weekday (`BYDAY=+2TU`) maps to `relativeMonthly`
24
+ * and back, but `BYSETPOS` over several weekdays ("last workday"), several
25
+ * days of the month, or several months do not exist there at all —
26
+ * {@link calendarUnsupportedReason} names them, and the sync skips such plans
27
+ * instead of approximating a maintenance date.
27
28
  */
28
29
  const WEEKDAY_TO_RRULE = {
29
30
  Sunday: "SU",
@@ -102,6 +103,12 @@ function parseRRule(rrule) {
102
103
  case "WKST":
103
104
  parts.wkst = value;
104
105
  break;
106
+ case "BYSETPOS": {
107
+ const parsed = Number(value);
108
+ if (Number.isFinite(parsed) && parsed !== 0)
109
+ parts.bysetpos = parsed;
110
+ break;
111
+ }
105
112
  }
106
113
  }
107
114
  return parts;
@@ -119,6 +126,8 @@ function buildRRule(parts) {
119
126
  }
120
127
  if (parts.bymonth?.length)
121
128
  chunks.push(`BYMONTH=${parts.bymonth.join(",")}`);
129
+ if (parts.bysetpos)
130
+ chunks.push(`BYSETPOS=${parts.bysetpos}`);
122
131
  if (parts.count)
123
132
  chunks.push(`COUNT=${parts.count}`);
124
133
  if (parts.wkst && parts.wkst !== "MO")
@@ -194,6 +203,34 @@ function eventToCadence(start, recurrence) {
194
203
  }
195
204
  return cadence;
196
205
  }
206
+ /**
207
+ * Why this cadence cannot be written to a calendar — `undefined` when it can.
208
+ *
209
+ * Outlook's recurrence vocabulary is a **subset** of RFC 5545, and the gap is
210
+ * not cosmetic: a rule we cannot express would have to be approximated, and an
211
+ * approximated maintenance date in someone's calendar is worse than no entry
212
+ * at all. The sync therefore leaves such plans alone instead of guessing (see
213
+ * `syncPlanToCalendar`).
214
+ */
215
+ function calendarUnsupportedReason(cadence) {
216
+ if (cadence?.kind !== "rrule" || !cadence.rrule)
217
+ return undefined;
218
+ const parts = parseRRule(cadence.rrule);
219
+ const days = parts.byday?.map(splitByDay) ?? [];
220
+ if (parts.bysetpos !== undefined && days.length > 1) {
221
+ return `BYSETPOS with ${days.length} weekdays has no Outlook equivalent`;
222
+ }
223
+ if (parts.byday && parts.byday.length > 1 && parts.freq !== "WEEKLY") {
224
+ return `several weekdays are only expressible for a weekly series (FREQ=${parts.freq})`;
225
+ }
226
+ if (parts.bymonthday && parts.bymonthday.length > 1) {
227
+ return "several days of the month have no Outlook equivalent";
228
+ }
229
+ if (parts.bymonth && parts.bymonth.length > 1) {
230
+ return "several months have no Outlook equivalent";
231
+ }
232
+ return undefined;
233
+ }
197
234
  /**
198
235
  * A v2 cadence as an Outlook recurrence — `undefined` for a one-off, which
199
236
  * Graph expects as an event without a `recurrence` at all.
@@ -215,7 +252,13 @@ function cadenceToRecurrence(cadence, start) {
215
252
  }
216
253
  function recurrencePattern(parts, start) {
217
254
  const interval = parts.interval && parts.interval > 0 ? parts.interval : 1;
218
- const positional = parts.byday?.map(splitByDay).find((day) => !!day.ordinal);
255
+ const days = parts.byday?.map(splitByDay) ?? [];
256
+ // A position comes either as an ordinal on the weekday (`+2TU`) or as
257
+ // BYSETPOS next to a single weekday — both are one Outlook `index`.
258
+ const positional = days.find((day) => !!day.ordinal) ??
259
+ (parts.bysetpos !== undefined && days.length === 1
260
+ ? { ordinal: parts.bysetpos, weekday: days[0].weekday }
261
+ : undefined);
219
262
  switch (parts.freq ?? "DAILY") {
220
263
  case "DAILY":
221
264
  return { type: "daily", interval };
@@ -8,6 +8,7 @@ exports.connectionForTenant = connectionForTenant;
8
8
  const node_1 = __importDefault(require("parse/node"));
9
9
  const CalendarManager_js_1 = require("../../../schedules/calendarSync/service/CalendarManager.js");
10
10
  const index_js_1 = require("../../../../../types/index.js");
11
+ const outlookRecurrence_js_1 = require("../functions/outlookRecurrence.js");
11
12
  const planToEvent_js_1 = require("../functions/planToEvent.js");
12
13
  const ServiceSyncTypes_js_1 = require("../types/ServiceSyncTypes.js");
13
14
  const serviceCalendarMeta_js_1 = require("./serviceCalendarMeta.js");
@@ -33,6 +34,13 @@ async function syncPlanToCalendar(plan) {
33
34
  const connection = await connectionForTenant(plan.get("tenant"));
34
35
  if (!connection)
35
36
  return;
37
+ // A recurrence Outlook cannot express is left untouched rather than
38
+ // approximated — a wrong maintenance date in a calendar is worse than none.
39
+ const unsupported = (0, outlookRecurrence_js_1.calendarUnsupportedReason)(plan.get("cadence"));
40
+ if (unsupported) {
41
+ console.warn(`[ServiceCalendarSync] Plan ${plan.id} not synced: ${unsupported}`);
42
+ return;
43
+ }
36
44
  const meta = await (0, serviceCalendarMeta_js_1.findMetaForPlan)(plan.id);
37
45
  const values = meta ? (0, serviceCalendarMeta_js_1.metaValues)(meta) : undefined;
38
46
  const eventId = values?.microsoftCalendarEventId;
@@ -24,12 +24,11 @@ export declare namespace Permissions {
24
24
  ticket_edit = "service:can-edit-ticket",
25
25
  ticket_delete = "service:can-delete-ticket",
26
26
  ticket_assign = "service:can-assign-ticket",
27
- schedule_read = "service:can-read-schedule",
28
- schedule_create = "service:can-create-schedule",
29
- schedule_edit = "service:can-edit-schedule",
30
- schedule_delete = "service:can-delete-schedule",
31
- execution_perform = "service:can-perform-execution",
32
- template_manage = "service:can-manage-template",
27
+ schedule_access = "openservice-schedule:can-access-schedule-plugin",
28
+ schedule_create = "openservice-schedule:can-create-schedule",
29
+ schedule_delete = "openservice-schedule:can-delete-schedule",
30
+ execution_perform = "openservice-schedule:can-perform-execution",
31
+ template_manage = "openservice-schedule:can-manage-template",
33
32
  masterdata_manage = "service:can-manage-masterdata",
34
33
  formconfig_manage = "service:can-manage-formconfig",
35
34
  tag_create = "service:can-create-tag",
@@ -36,15 +36,20 @@ var Permissions;
36
36
  SERVICE["ticket_edit"] = "service:can-edit-ticket";
37
37
  SERVICE["ticket_delete"] = "service:can-delete-ticket";
38
38
  SERVICE["ticket_assign"] = "service:can-assign-ticket";
39
- // Schedules (Wartungspläne)
40
- SERVICE["schedule_read"] = "service:can-read-schedule";
41
- SERVICE["schedule_create"] = "service:can-create-schedule";
42
- SERVICE["schedule_edit"] = "service:can-edit-schedule";
43
- SERVICE["schedule_delete"] = "service:can-delete-schedule";
44
- // Executions (Durchführungen)
45
- SERVICE["execution_perform"] = "service:can-perform-execution";
39
+ // Schedule-Plugin
40
+ //
41
+ // No `read` or `edit` here on purpose: both are decided per record by the
42
+ // ACL — creator, assigned roles and users — and a capability that says
43
+ // "may edit" while the ACL says "not this one" only adds a second answer to
44
+ // a question that already has one. `create` stays a capability because
45
+ // there is no record yet whose ACL could be asked, and a class-level
46
+ // permission cannot express it per tenant.
47
+ SERVICE["schedule_access"] = "openservice-schedule:can-access-schedule-plugin";
48
+ SERVICE["schedule_create"] = "openservice-schedule:can-create-schedule";
49
+ SERVICE["schedule_delete"] = "openservice-schedule:can-delete-schedule";
50
+ SERVICE["execution_perform"] = "openservice-schedule:can-perform-execution";
51
+ SERVICE["template_manage"] = "openservice-schedule:can-manage-template";
46
52
  // Catalog / master data / form config
47
- SERVICE["template_manage"] = "service:can-manage-template";
48
53
  SERVICE["masterdata_manage"] = "service:can-manage-masterdata";
49
54
  SERVICE["formconfig_manage"] = "service:can-manage-formconfig";
50
55
  // Narrow master-data capability: create a tag inline from a tag picker,
@@ -6,29 +6,50 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.init = init;
7
7
  const node_1 = __importDefault(require("parse/node"));
8
8
  const index_js_1 = require("../features/schema/index.js");
9
- const index_js_2 = require("../features/openservice/serviceSchedules/calendarSync/index.js");
10
- const index_js_3 = require("../types/index.js");
9
+ const Permissions_js_1 = require("../features/permissions/types/Permissions.js");
10
+ const index_js_2 = require("../features/permissions/index.js");
11
+ const index_js_3 = require("../features/openservice/serviceSchedules/acl/index.js");
12
+ const index_js_4 = require("../features/openservice/serviceSchedules/calendarSync/index.js");
13
+ const index_js_5 = require("../types/index.js");
11
14
  async function init() {
12
- (0, index_js_1.beforeSaveHook)(index_js_3.Service_Schedule, async (request) => {
15
+ (0, index_js_1.beforeSaveHook)(index_js_5.Service_Schedule, async (request) => {
13
16
  const { object, original, user } = request;
14
17
  await (0, index_js_1.defaultHandler)(request);
15
- // `allowCustomACL` keeps an ACL set by the client instead of resetting it on
16
- // every save. The frontend uses it for the per-record write lock ("Rechte
17
- // nur für Ersteller", `openservice-schedule/utils/acl.ts`): the creator gets
18
- // an explicit user grant, everyone else keeps read only. The handler still
19
- // re-adds `od-admin` / `od-tenant-admin-<tenant>` write and
20
- // `od-tenant-user-<tenant>` read afterwards, so a lock can never lock
21
- // administrators out.
22
- await (0, index_js_1.defaultAclHandler)(request, { allowCustomACL: true });
23
- // TODO
18
+ // The creator, recorded once and never rewritten: it is who the plan
19
+ // belongs to, and `defaultAclHandler` turns it into a read+write grant.
20
+ if (!original && user && !object.get("user")) {
21
+ object.set("user", user);
22
+ }
23
+ // `denyTenantUserRead` is what makes a plan private. Without it every user
24
+ // of the tenant reads every plan, which is the opposite of what assigning
25
+ // roles and users to a plan is supposed to mean.
26
+ //
27
+ // `allowCustomACL` keeps an ACL the client set instead of resetting it on
28
+ // every save — the assignment sync in `afterSave` depends on its ACL
29
+ // surviving the next save. The handler still re-adds `od-admin` /
30
+ // `od-tenant-admin-<tenant>`, so a plan can never lock administrators out.
31
+ await (0, index_js_1.defaultAclHandler)(request, {
32
+ allowCustomACL: true,
33
+ denyTenantUserRead: true,
34
+ });
24
35
  });
25
- (0, index_js_1.afterSaveHook)(index_js_3.Service_Schedule, async (request) => {
36
+ (0, index_js_1.afterSaveHook)(index_js_5.Service_Schedule, async (request) => {
26
37
  const { object, original, context } = request;
38
+ // Our own ACL write — the assignments already match, nothing to redo.
39
+ if (context?.[index_js_3.ASSIGNMENT_ACL_CONTEXT_FLAG])
40
+ return;
41
+ // Assignments are relations: only readable now that the save applied them.
42
+ await (0, index_js_3.syncAssignmentAcl)(object, {
43
+ roleRelations: ["assignedRoles", "escalationRoles"],
44
+ userRelations: ["assignedUsers"],
45
+ }).catch((error) => {
46
+ console.error(`[ServiceScheduleAcl] Plan ${object.id}: could not sync ACL from assignments:`, error);
47
+ });
27
48
  // A change that came *from* the calendar must not be written back to it:
28
49
  // that would create a new version there, which the delta feed reports as a
29
50
  // foreign change, which we would import again — an endless echo. The
30
51
  // poller marks its own writes with this flag.
31
- if (context?.[index_js_2.CALENDAR_SYNC_CONTEXT_FLAG])
52
+ if (context?.[index_js_4.CALENDAR_SYNC_CONTEXT_FLAG])
32
53
  return;
33
54
  // Seed data belongs to a demo tenant, not in anybody's calendar.
34
55
  if (object.get("seed"))
@@ -38,13 +59,18 @@ async function init() {
38
59
  // Graph round trip (token + write) would otherwise be part of every save
39
60
  // the user waits for. Failures are logged, and the next relevant save
40
61
  // retries — as does the delta poller from the other side.
41
- (0, index_js_2.syncPlanToCalendar)(object).catch((error) => {
62
+ (0, index_js_4.syncPlanToCalendar)(object).catch((error) => {
42
63
  console.error(`[ServiceCalendarSync] Plan ${object.id} could not be synced:`, error);
43
64
  });
44
65
  }
45
66
  });
46
- (0, index_js_1.beforeDeleteHook)(index_js_3.Service_Schedule, async (request) => {
67
+ (0, index_js_1.beforeDeleteHook)(index_js_5.Service_Schedule, async (request) => {
47
68
  const { object } = request;
69
+ // Parse has no delete right of its own — an ACL `write` grant covers update
70
+ // *and* delete, so anyone allowed to edit a plan could otherwise remove it.
71
+ // Hiding the button in the UI does not change that; this is what makes the
72
+ // capability real.
73
+ await (0, index_js_2.requirePermission)(request, Permissions_js_1.Permissions.SERVICE.schedule_delete, "delete maintenance schedule");
48
74
  // A protocol points at the plan it documents. Destroying the plan leaves
49
75
  // that pointer dangling and the protocol — the record that the work
50
76
  // happened — unreachable. Retiring a plan is done by setting `deletedAt`
@@ -53,7 +79,7 @@ async function init() {
53
79
  //
54
80
  // A plan without any execution carries no history, so removing it stays
55
81
  // allowed — that keeps mistakenly created plans cleanable.
56
- const executions = await new node_1.default.Query(index_js_3.Service_Schedule_Execution)
82
+ const executions = await new node_1.default.Query(index_js_5.Service_Schedule_Execution)
57
83
  .equalTo("schedule", object)
58
84
  .count({ useMasterKey: true });
59
85
  if (executions > 0) {
@@ -2,16 +2,37 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.init = init;
4
4
  const index_js_1 = require("../features/schema/index.js");
5
- const index_js_2 = require("../types/index.js");
5
+ const index_js_2 = require("../features/openservice/serviceSchedules/acl/index.js");
6
+ const index_js_3 = require("../types/index.js");
6
7
  async function init() {
7
- (0, index_js_1.beforeSaveHook)(index_js_2.Service_Schedule_Template, async (request) => {
8
+ (0, index_js_1.beforeSaveHook)(index_js_3.Service_Schedule_Template, async (request) => {
8
9
  const { object, original, user } = request;
9
10
  await (0, index_js_1.defaultHandler)(request);
10
- await (0, index_js_1.defaultAclHandler)(request);
11
- // TODO
11
+ // The creator, recorded once and never rewritten — a template stays visible
12
+ // to whoever built it, whatever happens to its assigned roles later.
13
+ if (!original && user && !object.get("user")) {
14
+ object.set("user", user);
15
+ }
16
+ // A template is private like a plan: its creator, the roles it is assigned
17
+ // to, and administrators. Without `denyTenantUserRead` the whole tenant
18
+ // reads every template and assigning roles would mean nothing.
19
+ await (0, index_js_1.defaultAclHandler)(request, {
20
+ allowCustomACL: true,
21
+ denyTenantUserRead: true,
22
+ });
12
23
  });
13
- (0, index_js_1.afterSaveHook)(index_js_2.Service_Schedule_Template, async (request) => {
14
- const { object, original, user } = request;
15
- // TODO
24
+ (0, index_js_1.afterSaveHook)(index_js_3.Service_Schedule_Template, async (request) => {
25
+ const { object, context } = request;
26
+ // Our own ACL write — the assignments already match.
27
+ if (context?.[index_js_2.ASSIGNMENT_ACL_CONTEXT_FLAG])
28
+ return;
29
+ // `assignedRoles` is a relation, readable only after the save applied it.
30
+ // Templates carry no `assignedUsers`: they are shared by role, and the
31
+ // creator keeps their own grant.
32
+ await (0, index_js_2.syncAssignmentAcl)(object, {
33
+ roleRelations: ["assignedRoles"],
34
+ }).catch((error) => {
35
+ console.error(`[ServiceScheduleAcl] Template ${object.id}: could not sync ACL from assignments:`, error);
36
+ });
16
37
  });
17
38
  }
@@ -43,7 +43,7 @@ const index_js_1 = require("../features/schema/index.js");
43
43
  const appLinks_js_1 = require("../helper/appLinks.js");
44
44
  const getUserLanguage_js_1 = require("../helper/getUserLanguage.js");
45
45
  const index_js_2 = require("../types/index.js");
46
- const applyRuleSet_js_1 = require("../features/ruleset/applyRuleSet.js");
46
+ const ruleset_core_1 = require("@opendash/ruleset-core");
47
47
  async function init() {
48
48
  // Soft delete: a ticket is referenced by chat messages, meta entries,
49
49
  // notifications and child tickets, so the frontend archives instead of
@@ -266,7 +266,7 @@ async function notifyWatchers(ticket, notified, assignerName) {
266
266
  for (const rule of rules) {
267
267
  if (rule.function !== "notify" || !rule.ruleset)
268
268
  continue;
269
- if (!(0, applyRuleSet_js_1.applyRuleSet)(rule.ruleset, getValue))
269
+ if (!(0, ruleset_core_1.applyRuleSet)(rule.ruleset, getValue))
270
270
  continue;
271
271
  const assignees = Array.isArray(rule.params?.assignees)
272
272
  ? rule.params.assignees
@@ -1,5 +1,6 @@
1
1
  import Parse from "parse/node";
2
2
  import type { _Role } from "./_Role";
3
+ import type { _User } from "./_User";
3
4
  import type { Service_Equipment } from "./Service_Equipment";
4
5
  import type { Tenant } from "./Tenant";
5
6
  export interface Service_ScheduleAttributes {
@@ -10,6 +11,7 @@ export interface Service_ScheduleAttributes {
10
11
  active: boolean;
11
12
  asset?: any | undefined;
12
13
  assignedRoles: Parse.Relation<Service_Schedule, _Role>;
14
+ assignedUsers: Parse.Relation<Service_Schedule, _User>;
13
15
  cadence: any;
14
16
  cadenceExceptions?: any[] | undefined;
15
17
  color?: string | undefined;
@@ -20,13 +22,14 @@ export interface Service_ScheduleAttributes {
20
22
  escalationRoles: Parse.Relation<Service_Schedule, _Role>;
21
23
  graceDays?: number | undefined;
22
24
  leadDays?: number | undefined;
23
- notifications?: any[] | undefined;
25
+ notifications: any[];
24
26
  pausedUntil?: Date | undefined;
25
27
  seed?: boolean | undefined;
26
28
  serviceProviders?: any[] | undefined;
27
29
  steps: any[];
28
30
  tenant: Tenant;
29
31
  title: string;
32
+ user?: _User | undefined;
30
33
  }
31
34
  export declare class Service_Schedule extends Parse.Object<Service_ScheduleAttributes> {
32
35
  static className: string;
@@ -36,6 +39,7 @@ export declare class Service_Schedule extends Parse.Object<Service_ScheduleAttri
36
39
  get asset(): any | undefined;
37
40
  set asset(value: any | undefined);
38
41
  get assignedRoles(): Parse.Relation<Service_Schedule, _Role>;
42
+ get assignedUsers(): Parse.Relation<Service_Schedule, _User>;
39
43
  get cadence(): any;
40
44
  set cadence(value: any);
41
45
  get cadenceExceptions(): any[] | undefined;
@@ -55,8 +59,8 @@ export declare class Service_Schedule extends Parse.Object<Service_ScheduleAttri
55
59
  set graceDays(value: number | undefined);
56
60
  get leadDays(): number | undefined;
57
61
  set leadDays(value: number | undefined);
58
- get notifications(): any[] | undefined;
59
- set notifications(value: any[] | undefined);
62
+ get notifications(): any[];
63
+ set notifications(value: any[]);
60
64
  get pausedUntil(): Date | undefined;
61
65
  set pausedUntil(value: Date | undefined);
62
66
  get seed(): boolean | undefined;
@@ -69,4 +73,6 @@ export declare class Service_Schedule extends Parse.Object<Service_ScheduleAttri
69
73
  set tenant(value: Tenant);
70
74
  get title(): string;
71
75
  set title(value: string);
76
+ get user(): _User | undefined;
77
+ set user(value: _User | undefined);
72
78
  }
@@ -25,6 +25,9 @@ class Service_Schedule extends node_1.default.Object {
25
25
  get assignedRoles() {
26
26
  return super.relation("assignedRoles");
27
27
  }
28
+ get assignedUsers() {
29
+ return super.relation("assignedUsers");
30
+ }
28
31
  get cadence() {
29
32
  return super.get("cadence");
30
33
  }
@@ -124,6 +127,12 @@ class Service_Schedule extends node_1.default.Object {
124
127
  set title(value) {
125
128
  super.set("title", value);
126
129
  }
130
+ get user() {
131
+ return super.get("user");
132
+ }
133
+ set user(value) {
134
+ super.set("user", value);
135
+ }
127
136
  }
128
137
  exports.Service_Schedule = Service_Schedule;
129
138
  node_1.default.Object.registerSubclass("OD3_Service_Schedule", Service_Schedule);
@@ -12,9 +12,10 @@ export interface Service_Schedule_ExecutionAttributes {
12
12
  coverageOrigin?: any | undefined;
13
13
  performedBy?: _User | undefined;
14
14
  schedule: Service_Schedule;
15
- signature?: any | undefined;
15
+ signature?: string | undefined;
16
16
  stepResults?: any[] | undefined;
17
17
  tenant: Tenant;
18
+ user?: _User | undefined;
18
19
  }
19
20
  export declare class Service_Schedule_Execution extends Parse.Object<Service_Schedule_ExecutionAttributes> {
20
21
  static className: string;
@@ -29,10 +30,12 @@ export declare class Service_Schedule_Execution extends Parse.Object<Service_Sch
29
30
  set performedBy(value: _User | undefined);
30
31
  get schedule(): Service_Schedule;
31
32
  set schedule(value: Service_Schedule);
32
- get signature(): any | undefined;
33
- set signature(value: any | undefined);
33
+ get signature(): string | undefined;
34
+ set signature(value: string | undefined);
34
35
  get stepResults(): any[] | undefined;
35
36
  set stepResults(value: any[] | undefined);
36
37
  get tenant(): Tenant;
37
38
  set tenant(value: Tenant);
39
+ get user(): _User | undefined;
40
+ set user(value: _User | undefined);
38
41
  }
@@ -58,6 +58,12 @@ class Service_Schedule_Execution extends node_1.default.Object {
58
58
  set tenant(value) {
59
59
  super.set("tenant", value);
60
60
  }
61
+ get user() {
62
+ return super.get("user");
63
+ }
64
+ set user(value) {
65
+ super.set("user", value);
66
+ }
61
67
  }
62
68
  exports.Service_Schedule_Execution = Service_Schedule_Execution;
63
69
  node_1.default.Object.registerSubclass("OD3_Service_Schedule_Execution", Service_Schedule_Execution);
@@ -1,6 +1,7 @@
1
1
  import Parse from "parse/node";
2
2
  import type { _Role } from "./_Role";
3
3
  import type { Tenant } from "./Tenant";
4
+ import type { _User } from "./_User";
4
5
  export interface Service_Schedule_TemplateAttributes {
5
6
  id: string;
6
7
  objectId: string;
@@ -10,11 +11,12 @@ export interface Service_Schedule_TemplateAttributes {
10
11
  cadence: any;
11
12
  deletedAt?: Date | undefined;
12
13
  description?: string | undefined;
13
- notifications?: any[] | undefined;
14
+ notifications: any[];
14
15
  serviceProviders: any[];
15
16
  steps: any[];
16
17
  tenant: Tenant;
17
18
  title: string;
19
+ user?: _User | undefined;
18
20
  }
19
21
  export declare class Service_Schedule_Template extends Parse.Object<Service_Schedule_TemplateAttributes> {
20
22
  static className: string;
@@ -26,8 +28,8 @@ export declare class Service_Schedule_Template extends Parse.Object<Service_Sche
26
28
  set deletedAt(value: Date | undefined);
27
29
  get description(): string | undefined;
28
30
  set description(value: string | undefined);
29
- get notifications(): any[] | undefined;
30
- set notifications(value: any[] | undefined);
31
+ get notifications(): any[];
32
+ set notifications(value: any[]);
31
33
  get serviceProviders(): any[];
32
34
  set serviceProviders(value: any[]);
33
35
  get steps(): any[];
@@ -36,4 +38,6 @@ export declare class Service_Schedule_Template extends Parse.Object<Service_Sche
36
38
  set tenant(value: Tenant);
37
39
  get title(): string;
38
40
  set title(value: string);
41
+ get user(): _User | undefined;
42
+ set user(value: _User | undefined);
39
43
  }
@@ -61,6 +61,12 @@ class Service_Schedule_Template extends node_1.default.Object {
61
61
  set title(value) {
62
62
  super.set("title", value);
63
63
  }
64
+ get user() {
65
+ return super.get("user");
66
+ }
67
+ set user(value) {
68
+ super.set("user", value);
69
+ }
64
70
  }
65
71
  exports.Service_Schedule_Template = Service_Schedule_Template;
66
72
  node_1.default.Object.registerSubclass("OD3_Service_Schedule_Template", Service_Schedule_Template);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openinc/parse-server-opendash",
3
- "version": "4.2.0",
3
+ "version": "4.2.2",
4
4
  "description": "Parse Server Cloud Code for open.INC Stack.",
5
5
  "packageManager": "pnpm@11.25.0",
6
6
  "keywords": [
@@ -69,14 +69,15 @@
69
69
  "seed:v1": "node dist/scripts/seed-v1-testdata.js"
70
70
  },
71
71
  "dependencies": {
72
+ "@opendash/ruleset-core": "0.1.1-nightly.1",
72
73
  "@openinc/parse-server-schema": "^4.1.1",
73
74
  "amqplib": "^0.10.9",
74
75
  "cron": "^4.4.0",
75
76
  "dayjs": "^1.11.23",
76
77
  "dotenv": "^17.4.2",
77
- "fast-equals": "^6.0.2",
78
- "i18next": "^26.4.1",
79
- "i18next-fs-backend": "^2.6.7",
78
+ "fast-equals": "^6.0.3",
79
+ "i18next": "^26.4.2",
80
+ "i18next-fs-backend": "^2.6.8",
80
81
  "jsonwebtoken": "^9.0.3",
81
82
  "jwks-rsa": "^4.1.0",
82
83
  "nodemailer": "^9.1.1",
@@ -13,6 +13,10 @@
13
13
  "targetClass": "_Role",
14
14
  "required": false
15
15
  },
16
+ "assignedUsers": {
17
+ "type": "Relation",
18
+ "targetClass": "_User"
19
+ },
16
20
  "cadence": {
17
21
  "type": "Object",
18
22
  "required": true
@@ -81,6 +85,10 @@
81
85
  "title": {
82
86
  "type": "String",
83
87
  "required": true
88
+ },
89
+ "user": {
90
+ "type": "Pointer",
91
+ "targetClass": "_User"
84
92
  }
85
93
  },
86
94
  "classLevelPermissions": {
@@ -29,6 +29,11 @@
29
29
  "type": "Pointer",
30
30
  "targetClass": "{{PREFIX}}Tenant",
31
31
  "required": true
32
+ },
33
+ "user": {
34
+ "type": "Pointer",
35
+ "targetClass": "_User",
36
+ "required": false
32
37
  }
33
38
  },
34
39
  "classLevelPermissions": {
@@ -41,6 +41,10 @@
41
41
  "title": {
42
42
  "type": "String",
43
43
  "required": true
44
+ },
45
+ "user": {
46
+ "type": "Pointer",
47
+ "targetClass": "_User"
44
48
  }
45
49
  },
46
50
  "classLevelPermissions": {
@@ -1,41 +0,0 @@
1
- /**
2
- * Backend port of the `@opendash/ui` ruleset evaluator
3
- * (`libs/ui/src/features/ruleset/functions/applyRuleSet.ts`).
4
- *
5
- * The frontend authors condition trees with the RuleSet editor and stores them
6
- * as JSON; notifications must be created server-side, so the same evaluation
7
- * logic has to run here. This is a faithful, dependency-free copy of the pure
8
- * evaluation core — keep the operator semantics in sync with the frontend.
9
- */
10
- export type RuleOperator = "equals" | "notEquals" | "contains" | "notContains" | "startsWith" | "endsWith" | "isEmpty" | "isNotEmpty" | "gt" | "gte" | "lt" | "lte" | "between" | "isTrue" | "isFalse" | "before" | "after";
11
- /** A single comparison against one field. */
12
- export interface Rule {
13
- id: string;
14
- kind: "rule";
15
- field: string;
16
- operator: RuleOperator;
17
- value?: string | number | boolean;
18
- /** Secondary value — only used for the "between" operator. */
19
- value2?: number;
20
- }
21
- /** A logical group that chains rules and nested groups via AND / OR. */
22
- export interface RuleGroup {
23
- id: string;
24
- kind: "group";
25
- conjunction: "AND" | "OR";
26
- rules: Array<Rule | RuleGroup>;
27
- }
28
- export type RuleSetValue = RuleGroup;
29
- /** A saved ruleset-function entry as produced by the RuleSet editor. */
30
- export interface RuleSetFunctionEntry {
31
- ruleset: RuleSetValue;
32
- function: string;
33
- params?: Record<string, unknown>;
34
- }
35
- /** Resolves the current value for a given field name. */
36
- export type GetValueFn = (fieldName: string) => unknown;
37
- /**
38
- * Evaluates a `RuleSetValue` against live data. An empty rule group always
39
- * returns `true` (matches the frontend semantics).
40
- */
41
- export declare function applyRuleSet(ruleSet: RuleSetValue, getValue: GetValueFn): boolean;
@@ -1,81 +0,0 @@
1
- "use strict";
2
- /**
3
- * Backend port of the `@opendash/ui` ruleset evaluator
4
- * (`libs/ui/src/features/ruleset/functions/applyRuleSet.ts`).
5
- *
6
- * The frontend authors condition trees with the RuleSet editor and stores them
7
- * as JSON; notifications must be created server-side, so the same evaluation
8
- * logic has to run here. This is a faithful, dependency-free copy of the pure
9
- * evaluation core — keep the operator semantics in sync with the frontend.
10
- */
11
- Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.applyRuleSet = applyRuleSet;
13
- function evaluateRule(rule, getValue) {
14
- const fieldValue = getValue(rule.field);
15
- switch (rule.operator) {
16
- case "equals":
17
- // eslint-disable-next-line eqeqeq
18
- return fieldValue == rule.value;
19
- case "notEquals":
20
- // eslint-disable-next-line eqeqeq
21
- return fieldValue != rule.value;
22
- case "contains":
23
- return (typeof fieldValue === "string" &&
24
- fieldValue.includes(String(rule.value ?? "")));
25
- case "notContains":
26
- return (typeof fieldValue === "string" &&
27
- !fieldValue.includes(String(rule.value ?? "")));
28
- case "startsWith":
29
- return (typeof fieldValue === "string" &&
30
- fieldValue.startsWith(String(rule.value ?? "")));
31
- case "endsWith":
32
- return (typeof fieldValue === "string" &&
33
- fieldValue.endsWith(String(rule.value ?? "")));
34
- case "isEmpty":
35
- return (fieldValue === null || fieldValue === undefined || fieldValue === "");
36
- case "isNotEmpty":
37
- return (fieldValue !== null && fieldValue !== undefined && fieldValue !== "");
38
- case "gt":
39
- return Number(fieldValue) > Number(rule.value);
40
- case "gte":
41
- return Number(fieldValue) >= Number(rule.value);
42
- case "lt":
43
- return Number(fieldValue) < Number(rule.value);
44
- case "lte":
45
- return Number(fieldValue) <= Number(rule.value);
46
- case "between":
47
- return (Number(fieldValue) >= Number(rule.value) &&
48
- Number(fieldValue) <= Number(rule.value2));
49
- case "isTrue":
50
- return fieldValue === true;
51
- case "isFalse":
52
- return fieldValue === false;
53
- case "before":
54
- return new Date(String(fieldValue)) < new Date(String(rule.value ?? ""));
55
- case "after":
56
- return new Date(String(fieldValue)) > new Date(String(rule.value ?? ""));
57
- default:
58
- return false;
59
- }
60
- }
61
- function evaluateGroup(group, getValue) {
62
- if (group.rules.length === 0)
63
- return true;
64
- if (group.conjunction === "AND") {
65
- return group.rules.every((item) => item.kind === "rule"
66
- ? evaluateRule(item, getValue)
67
- : evaluateGroup(item, getValue));
68
- }
69
- return group.rules.some((item) => item.kind === "rule"
70
- ? evaluateRule(item, getValue)
71
- : evaluateGroup(item, getValue));
72
- }
73
- /**
74
- * Evaluates a `RuleSetValue` against live data. An empty rule group always
75
- * returns `true` (matches the frontend semantics).
76
- */
77
- function applyRuleSet(ruleSet, getValue) {
78
- if (!ruleSet || ruleSet.kind !== "group")
79
- return false;
80
- return evaluateGroup(ruleSet, getValue);
81
- }