@nymphjs/tilmeld 1.0.0-beta.13 → 1.0.0-beta.131

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 (105) hide show
  1. package/CHANGELOG.md +604 -0
  2. package/README.md +21 -5
  3. package/dist/AbleObject.d.ts +15 -0
  4. package/dist/AbleObject.js +18 -6
  5. package/dist/AbleObject.js.map +1 -1
  6. package/dist/Group.d.ts +183 -19
  7. package/dist/Group.js +814 -440
  8. package/dist/Group.js.map +1 -1
  9. package/dist/Group.testArtifacts.d.ts +3 -0
  10. package/dist/Group.testArtifacts.js +120 -0
  11. package/dist/Group.testArtifacts.js.map +1 -0
  12. package/dist/Tilmeld.d.ts +241 -12
  13. package/dist/Tilmeld.js +725 -286
  14. package/dist/Tilmeld.js.map +1 -1
  15. package/dist/Tilmeld.types.d.ts +5 -5
  16. package/dist/Tilmeld.types.js +1 -2
  17. package/dist/User.d.ts +396 -26
  18. package/dist/User.js +1955 -1058
  19. package/dist/User.js.map +1 -1
  20. package/dist/User.testArtifacts.d.ts +3 -0
  21. package/dist/User.testArtifacts.js +175 -0
  22. package/dist/User.testArtifacts.js.map +1 -0
  23. package/dist/conf/defaults.d.ts +1 -1
  24. package/dist/conf/defaults.js +196 -141
  25. package/dist/conf/defaults.js.map +1 -1
  26. package/dist/conf/index.d.ts +355 -2
  27. package/dist/conf/index.js +2 -8
  28. package/dist/conf/index.js.map +1 -1
  29. package/dist/decorators/index.d.ts +2 -0
  30. package/dist/decorators/index.js +3 -0
  31. package/dist/decorators/index.js.map +1 -0
  32. package/dist/decorators/requireAbility.d.ts +10 -0
  33. package/dist/decorators/requireAbility.js +21 -0
  34. package/dist/decorators/requireAbility.js.map +1 -0
  35. package/dist/decorators/requireAuthentication.d.ts +8 -0
  36. package/dist/decorators/requireAuthentication.js +16 -0
  37. package/dist/decorators/requireAuthentication.js.map +1 -0
  38. package/dist/enforceTilmeld.d.ts +3 -0
  39. package/dist/enforceTilmeld.js +18 -0
  40. package/dist/enforceTilmeld.js.map +1 -0
  41. package/dist/errors/AccessControlError.d.ts +2 -1
  42. package/dist/errors/AccessControlError.js +3 -6
  43. package/dist/errors/AccessControlError.js.map +1 -1
  44. package/dist/errors/BadDataError.d.ts +2 -1
  45. package/dist/errors/BadDataError.js +3 -6
  46. package/dist/errors/BadDataError.js.map +1 -1
  47. package/dist/errors/BadEmailError.d.ts +2 -1
  48. package/dist/errors/BadEmailError.js +3 -6
  49. package/dist/errors/BadEmailError.js.map +1 -1
  50. package/dist/errors/BadUsernameError.d.ts +2 -1
  51. package/dist/errors/BadUsernameError.js +3 -6
  52. package/dist/errors/BadUsernameError.js.map +1 -1
  53. package/dist/errors/CouldNotChangeDefaultPrimaryGroupError.js +1 -5
  54. package/dist/errors/CouldNotChangeDefaultPrimaryGroupError.js.map +1 -1
  55. package/dist/errors/EmailChangeRateLimitExceededError.js +1 -5
  56. package/dist/errors/EmailChangeRateLimitExceededError.js.map +1 -1
  57. package/dist/errors/index.d.ts +6 -6
  58. package/dist/errors/index.js +6 -22
  59. package/dist/errors/index.js.map +1 -1
  60. package/dist/index.d.ts +12 -10
  61. package/dist/index.js +17 -35
  62. package/dist/index.js.map +1 -1
  63. package/dist/testArtifacts.d.ts +23 -0
  64. package/dist/testArtifacts.js +1317 -0
  65. package/dist/testArtifacts.js.map +1 -0
  66. package/dist/tilmeldJoiProps.d.ts +3 -3
  67. package/dist/tilmeldJoiProps.js +13 -18
  68. package/dist/tilmeldJoiProps.js.map +1 -1
  69. package/jest.config.js +31 -5
  70. package/package.json +49 -36
  71. package/src/AbleObject.ts +2 -2
  72. package/src/Group.testArtifacts.ts +159 -0
  73. package/src/Group.ts +647 -241
  74. package/src/Tilmeld.ts +550 -315
  75. package/src/Tilmeld.types.ts +5 -5
  76. package/src/User.testArtifacts.ts +226 -0
  77. package/src/User.ts +1657 -637
  78. package/src/conf/defaults.ts +172 -109
  79. package/src/conf/index.ts +370 -2
  80. package/src/decorators/index.ts +2 -0
  81. package/src/decorators/requireAbility.ts +26 -0
  82. package/src/decorators/requireAuthentication.ts +19 -0
  83. package/src/enforceTilmeld.ts +20 -0
  84. package/src/errors/AccessControlError.ts +4 -2
  85. package/src/errors/BadDataError.ts +4 -2
  86. package/src/errors/BadEmailError.ts +4 -2
  87. package/src/errors/BadUsernameError.ts +4 -2
  88. package/src/errors/index.ts +6 -6
  89. package/src/index.ts +13 -10
  90. package/src/testArtifacts.ts +2098 -0
  91. package/src/tilmeldJoiProps.ts +8 -23
  92. package/tsconfig.json +6 -3
  93. package/typedoc.json +4 -0
  94. package/dist/Group.test.d.ts +0 -1
  95. package/dist/Group.test.js +0 -48
  96. package/dist/Group.test.js.map +0 -1
  97. package/dist/User.test.d.ts +0 -1
  98. package/dist/User.test.js +0 -51
  99. package/dist/User.test.js.map +0 -1
  100. package/dist/conf/d.d.ts +0 -55
  101. package/dist/conf/d.js +0 -3
  102. package/dist/conf/d.js.map +0 -1
  103. package/src/Group.test.ts +0 -58
  104. package/src/User.test.ts +0 -61
  105. package/src/conf/d.ts +0 -303
package/src/Group.ts CHANGED
@@ -1,24 +1,75 @@
1
+ import crypto from 'node:crypto';
1
2
  import {
2
- EntityData,
3
- EntityInterface,
4
- EntityJson,
5
- EntityPatch,
6
- Options,
7
- Selector,
8
- SerializedEntityData,
3
+ type EntityData,
4
+ type EntityJson,
5
+ type EntityPatch,
6
+ type Options,
7
+ type Selector,
8
+ type SerializedEntityData,
9
+ transaction,
10
+ transactional,
9
11
  } from '@nymphjs/nymph';
10
- import md5 from 'crypto-js/md5';
11
- import { difference } from 'lodash';
12
+ import { nanoid } from '@nymphjs/guid';
13
+ import { difference, xor } from 'lodash-es';
14
+ import { splitn } from '@sciactive/splitn';
12
15
 
13
- import type Tilmeld from './Tilmeld';
14
- import AbleObject from './AbleObject';
16
+ import { enforceTilmeld } from './enforceTilmeld.js';
17
+ import AbleObject from './AbleObject.js';
15
18
  import {
19
+ AccessControlError,
16
20
  BadDataError,
17
21
  BadEmailError,
18
22
  BadUsernameError,
19
23
  CouldNotChangeDefaultPrimaryGroupError,
20
- } from './errors';
21
- import User, { UserData } from './User';
24
+ } from './errors/index.js';
25
+ import UserClass from './User.js';
26
+ import type { UserData } from './User.js';
27
+
28
+ export type GroupEventType =
29
+ | 'checkGroupname'
30
+ | 'beforeSave'
31
+ | 'afterSave'
32
+ | 'beforeDelete'
33
+ | 'afterDelete';
34
+ /**
35
+ * This is run when the user has entered an otherwise valid groupname into the
36
+ * signup form. It should return a result, which when false will stop the
37
+ * process and return the included message, disallowing the groupname.
38
+ */
39
+ export type TilmeldCheckGroupnameCallback = (
40
+ group: Group & GroupData,
41
+ data: { groupname: string },
42
+ ) => Promise<{ result: boolean; message?: string }>;
43
+ /**
44
+ * This is run just before the group is saved, so all checks have passed.
45
+ *
46
+ * The transaction is available in `group.$nymph`.
47
+ */
48
+ export type TilmeldBeforeGroupSaveCallback = (
49
+ group: Group & GroupData,
50
+ ) => Promise<void>;
51
+ /**
52
+ * This is run before the transaction is committed, and you can perform
53
+ * additional functions on the transaction, which is available in
54
+ * `group.$nymph`.
55
+ */
56
+ export type TilmeldAfterGroupSaveCallback = (
57
+ group: Group & GroupData,
58
+ ) => Promise<void>;
59
+ /**
60
+ * The transaction is available in `group.$nymph`.
61
+ */
62
+ export type TilmeldBeforeGroupDeleteCallback = (
63
+ group: Group & GroupData,
64
+ ) => Promise<void>;
65
+ /**
66
+ * This is run before the transaction is committed, and you can perform
67
+ * additional functions on the transaction, which is available in
68
+ * `group.$nymph`.
69
+ */
70
+ export type TilmeldAfterGroupDeleteCallback = (
71
+ group: Group & GroupData,
72
+ ) => Promise<void>;
22
73
 
23
74
  export type GroupData = {
24
75
  /**
@@ -52,7 +103,7 @@ export type GroupData = {
52
103
  /**
53
104
  * If generatePrimary is on, this will be the user who generated this group.
54
105
  */
55
- user?: (User & UserData) | null;
106
+ user?: (UserClass & UserData) | null;
56
107
 
57
108
  /**
58
109
  * Whether the group can be used.
@@ -77,17 +128,18 @@ export type GroupData = {
77
128
  *
78
129
  * Written by Hunter Perrin for SciActive.
79
130
  *
80
- * @author Hunter Perrin <hperrin@gmail.com>
131
+ * @author Hunter Perrin <hperrin@port87.com>
81
132
  * @copyright SciActive Inc
82
133
  * @see http://nymph.io/
83
134
  */
84
135
  export default class Group extends AbleObject<GroupData> {
85
- /**
86
- * The instance of Tilmeld to use for queries.
87
- */
88
- static tilmeld: Tilmeld;
89
136
  static ETYPE = 'tilmeld_group';
90
137
  static class = 'Group';
138
+ private static checkGroupnameCallbacks: TilmeldCheckGroupnameCallback[] = [];
139
+ private static beforeSaveCallbacks: TilmeldBeforeGroupSaveCallback[] = [];
140
+ private static afterSaveCallbacks: TilmeldAfterGroupSaveCallback[] = [];
141
+ private static beforeDeleteCallbacks: TilmeldBeforeGroupDeleteCallback[] = [];
142
+ private static afterDeleteCallbacks: TilmeldAfterGroupDeleteCallback[] = [];
91
143
 
92
144
  private static DEFAULT_PRIVATE_DATA = ['email', 'phone', 'abilities', 'user'];
93
145
  private static DEFAULT_ALLOWLIST_DATA: string[] = [];
@@ -112,16 +164,37 @@ export default class Group extends AbleObject<GroupData> {
112
164
  protected $allowlistTags?: string[] = [];
113
165
 
114
166
  /**
115
- * This is explicitly used only during the registration proccess.
167
+ * This should only be used by the backend.
116
168
  */
117
169
  private $skipAcWhenSaving = false;
170
+ /**
171
+ * This should only be used by the backend.
172
+ */
173
+ private $skipAcWhenDeleting = false;
174
+ /**
175
+ * Used to save the current groupname for domain admin permissions.
176
+ */
177
+ public $originalGroupname?: string;
118
178
 
119
- static async factory(guid?: string): Promise<Group & GroupData> {
120
- return (await super.factory(guid)) as Group & GroupData;
179
+ public static getFTSText(name: string, value: any) {
180
+ if (
181
+ [
182
+ 'abilities',
183
+ 'parent',
184
+ 'user',
185
+ 'enabled',
186
+ 'defaultPrimary',
187
+ 'defaultSecondary',
188
+ 'unverifiedSecondary',
189
+ ].indexOf(name) !== -1
190
+ ) {
191
+ return null;
192
+ }
193
+ return super.getFTSText(name, value);
121
194
  }
122
195
 
123
196
  static async factoryGroupname(
124
- groupname?: string
197
+ groupname?: string,
125
198
  ): Promise<Group & GroupData> {
126
199
  const entity = new this();
127
200
  if (groupname != null) {
@@ -132,7 +205,7 @@ export default class Group extends AbleObject<GroupData> {
132
205
  {
133
206
  type: '&',
134
207
  ilike: ['groupname', groupname.replace(/([\\%_])/g, (s) => `\\${s}`)],
135
- }
208
+ },
136
209
  );
137
210
  if (entity != null) {
138
211
  return entity;
@@ -141,21 +214,6 @@ export default class Group extends AbleObject<GroupData> {
141
214
  return entity;
142
215
  }
143
216
 
144
- static factorySync(guid?: string): Group & GroupData {
145
- return super.factorySync(guid) as Group & GroupData;
146
- }
147
-
148
- constructor(guid?: string) {
149
- super(guid);
150
-
151
- if (this.guid == null) {
152
- // Defaults.
153
- this.$data.enabled = true;
154
- this.$data.abilities = [];
155
- this.$updateDataProtection();
156
- }
157
- }
158
-
159
217
  /**
160
218
  * Get all the groups that can be assigned as primary groups.
161
219
  *
@@ -165,16 +223,17 @@ export default class Group extends AbleObject<GroupData> {
165
223
  */
166
224
  public static async getPrimaryGroups(
167
225
  options?: Options,
168
- selectors?: Selector[]
226
+ selectors?: Selector[],
169
227
  ) {
170
- if (!this.tilmeld.gatekeeper('tilmeld/admin')) {
228
+ const tilmeld = enforceTilmeld(this);
229
+ if (!tilmeld.gatekeeper('tilmeld/admin')) {
171
230
  throw new Error("You don't have permission to do that.");
172
231
  }
173
232
 
174
233
  return await this.getAssignableGroups(
175
- this.tilmeld.config.highestPrimary,
176
- { ...options, class: this.tilmeld.Group, return: 'entity' },
177
- [...(selectors ?? [])]
234
+ tilmeld.config.highestPrimary,
235
+ { ...options, class: tilmeld.Group, return: 'entity' },
236
+ [...(selectors ?? [])],
178
237
  );
179
238
  }
180
239
 
@@ -187,24 +246,26 @@ export default class Group extends AbleObject<GroupData> {
187
246
  */
188
247
  public static async getSecondaryGroups(
189
248
  options?: Options,
190
- selectors?: Selector[]
249
+ selectors?: Selector[],
191
250
  ) {
192
- if (!this.tilmeld.gatekeeper('tilmeld/admin')) {
251
+ const tilmeld = enforceTilmeld(this);
252
+ if (!tilmeld.gatekeeper('tilmeld/admin')) {
193
253
  throw new Error("You don't have permission to do that.");
194
254
  }
195
255
 
196
256
  return await this.getAssignableGroups(
197
- this.tilmeld.config.highestSecondary,
198
- { ...options, class: this.tilmeld.Group, return: 'entity' },
199
- [...(selectors ?? [])]
257
+ tilmeld.config.highestSecondary,
258
+ { ...options, class: tilmeld.Group, return: 'entity' },
259
+ [...(selectors ?? [])],
200
260
  );
201
261
  }
202
262
 
203
263
  private static async getAssignableGroups(
204
264
  highestParent: string | boolean,
205
265
  options: Options<typeof Group>,
206
- selectors: Selector[]
266
+ selectors: Selector[],
207
267
  ) {
268
+ const tilmeld = enforceTilmeld(this);
208
269
  let assignableGroups: (Group & GroupData)[] = [];
209
270
 
210
271
  if (highestParent === false) {
@@ -212,25 +273,57 @@ export default class Group extends AbleObject<GroupData> {
212
273
  }
213
274
 
214
275
  assignableGroups = await this.nymph.getEntities(
215
- { ...options, class: this.tilmeld.Group },
216
- ...selectors
276
+ { ...options, class: tilmeld.Group },
277
+ ...selectors,
217
278
  );
218
279
  if (highestParent !== true) {
219
- assignableGroups = assignableGroups.filter((group) => {
220
- let curGroup = group;
221
- while (curGroup.parent != null && curGroup.parent.cdate != null) {
222
- if (curGroup.parent.guid === highestParent) {
223
- return true;
224
- }
225
- curGroup = curGroup.parent;
226
- }
227
- return false;
228
- });
280
+ assignableGroups = (
281
+ await Promise.all(
282
+ assignableGroups.map(async (group) => {
283
+ let curGroup = group;
284
+ let parent = curGroup.parent;
285
+ await parent?.$wake();
286
+ while (parent != null && parent?.cdate != null) {
287
+ if (parent?.guid === highestParent) {
288
+ return group;
289
+ }
290
+ curGroup = parent;
291
+ parent = curGroup.parent;
292
+ await parent?.$wake();
293
+ }
294
+ return null;
295
+ }),
296
+ )
297
+ ).filter((group) => group != null) as (Group & GroupData)[];
229
298
  }
230
299
  return assignableGroups;
231
300
  }
232
301
 
233
- public $getAvatar() {
302
+ constructor() {
303
+ super();
304
+
305
+ // Defaults.
306
+ this.$data.enabled = true;
307
+ this.$data.abilities = [];
308
+ this.$updateDataProtection();
309
+ }
310
+
311
+ async $getUniques(): Promise<string[]> {
312
+ const tilmeld = enforceTilmeld(this);
313
+ const uniques = [`u:${this.$data.groupname}`];
314
+ if (
315
+ !tilmeld.config.emailUsernames &&
316
+ tilmeld.config.userFields.includes('email')
317
+ ) {
318
+ uniques.push(`e:${this.$data.email}`);
319
+ }
320
+ if (this.$data.defaultPrimary) {
321
+ uniques.push(`o:default-primary`);
322
+ }
323
+ return uniques;
324
+ }
325
+
326
+ public $getAvatar(): string {
234
327
  if (this.$data.avatar != null && this.$data.avatar !== '') {
235
328
  return this.$data.avatar;
236
329
  }
@@ -239,49 +332,111 @@ export default class Group extends AbleObject<GroupData> {
239
332
  }
240
333
  return (
241
334
  'https://secure.gravatar.com/avatar/' +
242
- md5(this.$data.email.trim().toLowerCase()).toString() +
335
+ crypto
336
+ .createHash('sha256')
337
+ .update(this.$data.email.trim().toLowerCase())
338
+ .digest('hex')
339
+ .toLowerCase() +
243
340
  '?d=identicon&s=40'
244
341
  );
245
342
  }
246
343
 
247
344
  public $jsonAcceptData(input: EntityJson, allowConflict = false) {
248
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
249
- this.$referenceWake();
345
+ this.$check();
250
346
 
251
- if (
252
- input.data.abilities?.indexOf('system/admin') !== -1 &&
253
- this.$data.abilities?.indexOf('system/admin') === -1 &&
254
- tilmeld.gatekeeper('tilmeld/admin') &&
255
- !tilmeld.gatekeeper('system/admin')
256
- ) {
257
- throw new BadDataError(
258
- "You don't have the authority to make this group a system admin."
259
- );
347
+ if ('abilities' in input.data) {
348
+ this.$checkIncomingAbilities(input.data.abilities);
260
349
  }
261
350
 
262
351
  super.$jsonAcceptData(input, allowConflict);
263
352
  }
264
353
 
265
354
  public $jsonAcceptPatch(patch: EntityPatch, allowConflict = false) {
266
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
267
- this.$referenceWake();
355
+ this.$check();
356
+
357
+ if ('abilities' in patch.set) {
358
+ this.$checkIncomingAbilities(patch.set.abilities);
359
+ }
360
+
361
+ super.$jsonAcceptPatch(patch, allowConflict);
362
+ }
363
+
364
+ private $checkIncomingAbilities(incoming: any) {
365
+ const tilmeld = enforceTilmeld(this);
268
366
 
269
367
  if (
270
- patch.set.abilities?.indexOf('system/admin') !== -1 &&
271
- this.$data.abilities?.indexOf('system/admin') === -1 &&
272
- tilmeld.gatekeeper('tilmeld/admin') &&
273
- !tilmeld.gatekeeper('system/admin')
368
+ !Array.isArray(incoming) ||
369
+ incoming.find((ability) => typeof ability !== 'string')
370
+ ) {
371
+ throw new BadDataError('Abilities must be an array of strings.');
372
+ }
373
+
374
+ if (
375
+ incoming.includes('system/admin') !==
376
+ this.$data.abilities?.includes('system/admin') &&
377
+ (!tilmeld.currentUser ||
378
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
274
379
  ) {
275
380
  throw new BadDataError(
276
- "You don't have the authority to make this group a system admin."
381
+ "You don't have the authority to grant or revoke system/admin.",
277
382
  );
278
383
  }
279
384
 
280
- super.$jsonAcceptPatch(patch, allowConflict);
385
+ if (
386
+ incoming.includes('tilmeld/admin') !==
387
+ this.$data.abilities?.includes('tilmeld/admin') &&
388
+ (!tilmeld.currentUser ||
389
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
390
+ ) {
391
+ throw new BadDataError(
392
+ "You don't have the authority to grant or revoke tilmeld/admin.",
393
+ );
394
+ }
395
+
396
+ if (
397
+ incoming.includes('tilmeld/switch') !==
398
+ this.$data.abilities?.includes('tilmeld/switch') &&
399
+ (!tilmeld.currentUser ||
400
+ !tilmeld.currentUser.abilities?.includes('system/admin'))
401
+ ) {
402
+ throw new BadDataError(
403
+ "You don't have the authority to grant or revoke tilmeld/switch.",
404
+ );
405
+ }
406
+
407
+ if (
408
+ xor(
409
+ incoming.filter((ability: string) => ability.startsWith('tilmeld/')),
410
+ this.$data.abilities?.filter((ability: string) =>
411
+ ability.startsWith('tilmeld/'),
412
+ ) ?? [],
413
+ ).length &&
414
+ (!tilmeld.currentUser ||
415
+ (!tilmeld.currentUser.abilities?.includes('system/admin') &&
416
+ !tilmeld.currentUser.abilities?.includes('tilmeld/admin')))
417
+ ) {
418
+ throw new BadDataError(
419
+ "You don't have the authority to grant or revoke Tilmeld abilities.",
420
+ );
421
+ }
281
422
  }
282
423
 
283
- public $putData(data: EntityData, sdata?: SerializedEntityData) {
284
- super.$putData(data, sdata);
424
+ public $putData(
425
+ data: EntityData,
426
+ sdata?: SerializedEntityData,
427
+ source?: 'server',
428
+ ) {
429
+ super.$putData(data, sdata, source);
430
+ // TODO: Fix these being added by the drivers.
431
+ // @ts-ignore
432
+ delete this.$data.acRead;
433
+ // @ts-ignore
434
+ delete this.$data.acWrite;
435
+ // @ts-ignore
436
+ delete this.$data.acFull;
437
+ if (source === 'server') {
438
+ this.$originalGroupname = this.$data.groupname;
439
+ }
285
440
  this.$updateDataProtection();
286
441
  }
287
442
 
@@ -290,9 +445,16 @@ export default class Group extends AbleObject<GroupData> {
290
445
  *
291
446
  * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
292
447
  */
293
- public $updateDataProtection(givenUser?: User & UserData) {
294
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
448
+ public $updateDataProtection(givenUser?: UserClass & UserData) {
449
+ const tilmeld = enforceTilmeld(this);
295
450
  let user = givenUser ?? tilmeld.User.current();
451
+ let [_groupname, domain] = tilmeld.config.domainSupport
452
+ ? splitn(this.$data.groupname ?? '', '@', 2)
453
+ : [this.$data.groupname, null];
454
+ // Lowercase the domain part.
455
+ if (domain) {
456
+ domain = domain.toLowerCase();
457
+ }
296
458
 
297
459
  this.$privateData = [...Group.DEFAULT_PRIVATE_DATA];
298
460
  this.$allowlistData = [...Group.DEFAULT_ALLOWLIST_DATA];
@@ -303,8 +465,8 @@ export default class Group extends AbleObject<GroupData> {
303
465
 
304
466
  if (
305
467
  user != null &&
306
- ((user.abilities?.indexOf('tilmeld/admin') ?? -1) !== -1 ||
307
- (user.abilities?.indexOf('system/admin') ?? -1) !== -1)
468
+ (user.abilities?.includes('tilmeld/admin') ||
469
+ user.abilities?.includes('system/admin'))
308
470
  ) {
309
471
  // Users who can edit groups can see their data.
310
472
  this.$privateData = [];
@@ -312,24 +474,34 @@ export default class Group extends AbleObject<GroupData> {
312
474
  } else if (
313
475
  this.$data.user != null &&
314
476
  user != null &&
315
- this.$data.user.$is(user)
477
+ this.$data.user.guid === user.guid
316
478
  ) {
317
479
  // Users can see their group's data.
318
480
  this.$privateData = [];
481
+ } else if (
482
+ user != null &&
483
+ tilmeld.config.domainSupport &&
484
+ domain &&
485
+ user.abilities?.includes(`tilmeld/domain/${domain}/admin`)
486
+ ) {
487
+ // Domain admins can see their groups' data.
488
+ this.$privateData = [];
319
489
  }
320
490
  }
321
491
 
322
492
  /**
323
- * Check whether the group is a descendant of a group.
493
+ * Check whether the group is a descendant of the given group.
324
494
  *
325
495
  * @param group The group, or the group's GUID.
326
496
  * @returns True or false.
327
497
  */
328
- public $isDescendant(givenGroup: (Group & GroupData) | string): boolean {
329
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
498
+ public async $isDescendant(
499
+ givenGroup: (Group & GroupData) | string,
500
+ ): Promise<boolean> {
501
+ const tilmeld = enforceTilmeld(this);
330
502
  let group: Group & GroupData;
331
503
  if (typeof givenGroup === 'string') {
332
- group = tilmeld.Group.factorySync(givenGroup);
504
+ group = await tilmeld.Group.factory(givenGroup);
333
505
  } else {
334
506
  group = givenGroup;
335
507
  }
@@ -337,13 +509,15 @@ export default class Group extends AbleObject<GroupData> {
337
509
  return false;
338
510
  }
339
511
  // Check to see if the group is a descendant of the given group.
340
- if (this.$data.parent == null) {
512
+ const parent = this.$data.parent;
513
+ await parent?.$wake();
514
+ if (parent == null) {
341
515
  return false;
342
516
  }
343
- if (this.$data.parent.$is(group)) {
517
+ if (parent.$is(group)) {
344
518
  return true;
345
519
  }
346
- if (this.$data.parent.$isDescendant(group)) {
520
+ if (await parent.$isDescendant(group)) {
347
521
  return true;
348
522
  }
349
523
  return false;
@@ -354,15 +528,15 @@ export default class Group extends AbleObject<GroupData> {
354
528
  *
355
529
  * @returns An array of groups.
356
530
  */
357
- public async $getChildren() {
358
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
531
+ public async $getChildren(): Promise<(Group & GroupData)[]> {
532
+ const tilmeld = enforceTilmeld(this);
359
533
  return await this.$nymph.getEntities(
360
534
  { class: tilmeld.Group },
361
535
  {
362
536
  type: '&',
363
537
  equal: ['enabled', true],
364
538
  ref: ['parent', this],
365
- }
539
+ },
366
540
  );
367
541
  }
368
542
 
@@ -370,20 +544,22 @@ export default class Group extends AbleObject<GroupData> {
370
544
  * Gets an array of the group's descendant groups.
371
545
  *
372
546
  * @param andSelf Include this group in the returned array.
547
+ * @param andDisabled Include disabled groups.
373
548
  * @returns An array of groups.
374
549
  */
375
550
  public async $getDescendants(
376
- andSelf = false
551
+ andSelf = false,
552
+ andDisabled = false,
377
553
  ): Promise<(Group & GroupData)[]> {
378
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
554
+ const tilmeld = enforceTilmeld(this);
379
555
  let groups: (Group & GroupData)[] = [];
380
556
  const entities = await this.$nymph.getEntities(
381
557
  { class: tilmeld.Group },
382
558
  {
383
559
  type: '&',
384
- equal: ['enabled', true],
560
+ ...(andDisabled ? {} : { equal: ['enabled', true] }),
385
561
  ref: ['parent', this],
386
- }
562
+ },
387
563
  );
388
564
  for (let entity of entities) {
389
565
  groups = (await entity.$getDescendants(true)).concat(groups);
@@ -394,37 +570,6 @@ export default class Group extends AbleObject<GroupData> {
394
570
  return groups;
395
571
  }
396
572
 
397
- /**
398
- * Gets an array of the group's descendant groups.
399
- *
400
- * @param andSelf Include this group in the returned array.
401
- * @returns An array of groups.
402
- */
403
- public $getDescendantsSync(andSelf = false): (Group & GroupData)[] {
404
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
405
- let groups: (Group & GroupData)[] = [];
406
- let entity: EntityInterface | null;
407
- let offset = 0;
408
- do {
409
- entity = this.$nymph.driver.getEntitySync(
410
- { class: tilmeld.Group, offset },
411
- {
412
- type: '&',
413
- equal: ['enabled', true],
414
- ref: ['parent', this],
415
- }
416
- );
417
- if (entity != null) {
418
- groups = entity.$getDescendantsSync(true).concat(groups);
419
- }
420
- offset++;
421
- } while (entity != null);
422
- if (andSelf) {
423
- groups.push(this);
424
- }
425
- return groups;
426
- }
427
-
428
573
  /**
429
574
  * Get the number of parents the group has.
430
575
  *
@@ -436,19 +581,23 @@ export default class Group extends AbleObject<GroupData> {
436
581
  *
437
582
  * @returns The level of the group.
438
583
  */
439
- public $getLevel() {
440
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
441
- let group = tilmeld.Group.factorySync(this.guid ?? undefined);
584
+ public async $getLevel(): Promise<number> {
585
+ const tilmeld = enforceTilmeld(this);
586
+ let group = await tilmeld.Group.factory(this.guid ?? undefined);
587
+ let parent = group.parent;
588
+ await parent?.$wake();
442
589
  let level = 0;
443
- while (group.parent != null && group.parent.cdate != null && level < 1024) {
590
+ while (parent != null && parent.cdate != null && level < 1024) {
444
591
  level++;
445
- group = group.parent;
592
+ group = parent;
593
+ parent = group.parent;
594
+ await parent?.$wake();
446
595
  }
447
596
  return level;
448
597
  }
449
598
 
450
599
  /**
451
- * Gets an array of users in the group.
600
+ * Gets an array of enabled users in the group.
452
601
  *
453
602
  * @param descendants Include users in all descendant groups too.
454
603
  * @param limit The limit for the query.
@@ -458,9 +607,9 @@ export default class Group extends AbleObject<GroupData> {
458
607
  public async $getUsers(
459
608
  descendants = false,
460
609
  limit?: number,
461
- offset?: number
462
- ): Promise<(User & UserData)[]> {
463
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
610
+ offset?: number,
611
+ ): Promise<(UserClass & UserData)[]> {
612
+ const tilmeld = enforceTilmeld(this);
464
613
  let groups: (Group & GroupData)[] = [];
465
614
  if (descendants) {
466
615
  groups = await this.$getDescendants();
@@ -471,10 +620,7 @@ export default class Group extends AbleObject<GroupData> {
471
620
  limit,
472
621
  offset,
473
622
  },
474
- {
475
- type: '&',
476
- equal: ['enabled', true],
477
- },
623
+ { type: '&', equal: ['enabled', true] },
478
624
  {
479
625
  type: '|',
480
626
  ref: [
@@ -489,7 +635,7 @@ export default class Group extends AbleObject<GroupData> {
489
635
  group,
490
636
  ]),
491
637
  ],
492
- }
638
+ },
493
639
  );
494
640
  }
495
641
 
@@ -502,15 +648,36 @@ export default class Group extends AbleObject<GroupData> {
502
648
  result: boolean;
503
649
  message: string;
504
650
  }> {
505
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
651
+ const tilmeld = enforceTilmeld(this);
506
652
  if (!tilmeld.config.emailUsernames) {
507
- if (this.$data.groupname == null || !this.$data.groupname.length) {
508
- return { result: false, message: 'Please specify a groupname.' };
653
+ let [groupname, domain] = tilmeld.config.domainSupport
654
+ ? splitn(this.$data.groupname ?? '', '@', 2)
655
+ : [this.$data.groupname, null];
656
+
657
+ // Lowercase the domain part.
658
+ if (domain) {
659
+ domain = domain.toLowerCase();
660
+ this.$data.groupname = `${groupname}@${domain}`;
509
661
  }
662
+
510
663
  if (
511
- tilmeld.config.maxUsernameLength > 0 &&
512
- this.$data.groupname.length > tilmeld.config.maxUsernameLength
664
+ this.$data.groupname == null ||
665
+ !this.$data.groupname.length ||
666
+ groupname == null ||
667
+ !groupname.length
513
668
  ) {
669
+ return { result: false, message: 'Please specify a groupname.' };
670
+ }
671
+ if (groupname.length < tilmeld.config.minUsernameLength) {
672
+ return {
673
+ result: false,
674
+ message:
675
+ 'Groupnames must be at least ' +
676
+ tilmeld.config.minUsernameLength +
677
+ ' characters.',
678
+ };
679
+ }
680
+ if (groupname.length > tilmeld.config.maxUsernameLength) {
514
681
  return {
515
682
  result: false,
516
683
  message:
@@ -520,22 +687,38 @@ export default class Group extends AbleObject<GroupData> {
520
687
  };
521
688
  }
522
689
  if (
523
- difference(
524
- this.$data.groupname.split(''),
525
- tilmeld.config.validChars.split('')
526
- ).length
690
+ difference(groupname.split(''), tilmeld.config.validChars.split(''))
691
+ .length
527
692
  ) {
528
693
  return {
529
694
  result: false,
530
695
  message: tilmeld.config.validCharsNotice,
531
696
  };
532
697
  }
533
- if (!tilmeld.config.validRegex.test(this.$data.groupname)) {
698
+ if (!tilmeld.config.validRegex.test(groupname)) {
534
699
  return {
535
700
  result: false,
536
701
  message: tilmeld.config.validRegexNotice,
537
702
  };
538
703
  }
704
+ if (domain != null) {
705
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
706
+ return {
707
+ result: false,
708
+ message: tilmeld.config.validDomainRegexNotice,
709
+ };
710
+ }
711
+ if (
712
+ !this.$is(tilmeld.currentUser?.group) &&
713
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
714
+ ) {
715
+ return {
716
+ result: false,
717
+ message:
718
+ "You don't have the authority to manage groups on this domain.",
719
+ };
720
+ }
721
+ }
539
722
 
540
723
  const selector: Selector = {
541
724
  type: '&',
@@ -549,12 +732,24 @@ export default class Group extends AbleObject<GroupData> {
549
732
  }
550
733
  const test = await this.$nymph.getEntity(
551
734
  { class: tilmeld.Group, skipAc: true },
552
- selector
735
+ selector,
553
736
  );
554
737
  if (test != null) {
555
738
  return { result: false, message: 'That groupname is taken.' };
556
739
  }
557
740
 
741
+ for (let callback of (this.constructor as typeof Group)
742
+ .checkGroupnameCallbacks) {
743
+ if (callback) {
744
+ const result = await callback(this, {
745
+ groupname: this.$data.groupname,
746
+ });
747
+ if (!result.result) {
748
+ return { message: 'Groupname is not available.', ...result };
749
+ }
750
+ }
751
+ }
752
+
558
753
  return {
559
754
  result: true,
560
755
  message:
@@ -565,10 +760,16 @@ export default class Group extends AbleObject<GroupData> {
565
760
  if (this.$data.groupname == null || !this.$data.groupname.length) {
566
761
  return { result: false, message: 'Please specify an email.' };
567
762
  }
568
- if (
569
- tilmeld.config.maxUsernameLength > 0 &&
570
- this.$data.groupname.length > tilmeld.config.maxUsernameLength
571
- ) {
763
+ if (this.$data.groupname.length < tilmeld.config.minUsernameLength) {
764
+ return {
765
+ result: false,
766
+ message:
767
+ 'Emails must be at least ' +
768
+ tilmeld.config.minUsernameLength +
769
+ ' characters.',
770
+ };
771
+ }
772
+ if (this.$data.groupname.length > tilmeld.config.maxUsernameLength) {
572
773
  return {
573
774
  result: false,
574
775
  message:
@@ -588,7 +789,7 @@ export default class Group extends AbleObject<GroupData> {
588
789
  * @returns An object with a boolean 'result' entry and a 'message' entry.
589
790
  */
590
791
  public async $checkEmail(): Promise<{ result: boolean; message: string }> {
591
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
792
+ const tilmeld = enforceTilmeld(this);
592
793
  if (this.$data.email === '') {
593
794
  return { result: true, message: '' };
594
795
  }
@@ -610,7 +811,7 @@ export default class Group extends AbleObject<GroupData> {
610
811
  }
611
812
  const test = await this.$nymph.getEntity(
612
813
  { class: tilmeld.Group, skipAc: true },
613
- selector
814
+ selector,
614
815
  );
615
816
  if (test != null) {
616
817
  return {
@@ -627,9 +828,62 @@ export default class Group extends AbleObject<GroupData> {
627
828
  }
628
829
 
629
830
  public async $save() {
630
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
831
+ let tilmeld = enforceTilmeld(this);
631
832
  if (this.$data.groupname == null || !this.$data.groupname.trim().length) {
632
- return false;
833
+ throw new BadUsernameError('Groupname must not be empty.');
834
+ }
835
+
836
+ // Lowercase the domain part.
837
+ if (tilmeld.config.domainSupport) {
838
+ const [groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
839
+ if (domain) {
840
+ this.$data.groupname = `${groupname}@${domain.toLowerCase()}`;
841
+ }
842
+ }
843
+
844
+ if (tilmeld.config.domainSupport) {
845
+ const [_groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
846
+ const [_originalGroupname, originalDomain] = splitn(
847
+ this.$originalGroupname ?? '',
848
+ '@',
849
+ 2,
850
+ );
851
+
852
+ if (
853
+ this.guid != null &&
854
+ ((originalDomain == null && domain != null) ||
855
+ (domain == null && originalDomain != null))
856
+ ) {
857
+ throw new AccessControlError(
858
+ "Groups can't switch to or from having a domain once created.",
859
+ );
860
+ }
861
+
862
+ if (
863
+ !this.$skipAcWhenSaving &&
864
+ !tilmeld.gatekeeper('tilmeld/admin') &&
865
+ !tilmeld.gatekeeper('system/admin')
866
+ ) {
867
+ if (
868
+ domain != null &&
869
+ !this.$is(tilmeld.currentUser) &&
870
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
871
+ ) {
872
+ throw new AccessControlError(
873
+ "You don't have the authority to modify groups on this domain.",
874
+ );
875
+ }
876
+ if (
877
+ originalDomain != null &&
878
+ originalDomain !== domain &&
879
+ (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`) ||
880
+ !tilmeld.gatekeeper(`tilmeld/domain/${originalDomain}/admin`))
881
+ ) {
882
+ throw new AccessControlError(
883
+ "You don't have the authority to modify groups on this domain.",
884
+ );
885
+ }
886
+ }
633
887
  }
634
888
 
635
889
  // Formatting.
@@ -643,16 +897,22 @@ export default class Group extends AbleObject<GroupData> {
643
897
  this.$data.enabled = !!this.$data.enabled;
644
898
 
645
899
  // Clear empty values.
646
- if (this.$data.name === '') {
900
+ if (this.$data.name === '' || !tilmeld.config.userFields.includes('name')) {
647
901
  delete this.$data.name;
648
902
  }
649
- if (this.$data.email === '') {
903
+ if (
904
+ this.$data.email === '' ||
905
+ !tilmeld.config.userFields.includes('email')
906
+ ) {
650
907
  delete this.$data.email;
651
908
  }
652
909
  if (this.$data.avatar === '') {
653
910
  delete this.$data.avatar;
654
911
  }
655
- if (this.$data.phone === '') {
912
+ if (
913
+ this.$data.phone === '' ||
914
+ !tilmeld.config.userFields.includes('phone')
915
+ ) {
656
916
  delete this.$data.phone;
657
917
  }
658
918
  if (this.$data.parent == null) {
@@ -671,20 +931,33 @@ export default class Group extends AbleObject<GroupData> {
671
931
  delete this.$data.unverifiedSecondary;
672
932
  }
673
933
 
674
- // Groups should never have 'system/admin' or 'tilmeld/admin' abilities.
675
- this.$data.abilities = difference(this.$data.abilities ?? [], [
676
- 'system/admin',
677
- 'tilmeld/admin',
678
- null,
679
- undefined,
680
- ]) as string[];
934
+ // Groups should never have 'system/admin', 'tilmeld/admin',
935
+ // 'tilmeld/switch', or domain admin abilities.
936
+ this.$data.abilities = (this.$data.abilities ?? []).filter((ability) => {
937
+ if (
938
+ ['system/admin', 'tilmeld/admin', 'tilmeld/switch'].indexOf(ability) !==
939
+ -1
940
+ ) {
941
+ return false;
942
+ }
943
+ if (ability == null) {
944
+ return false;
945
+ }
946
+ if (ability.startsWith('tilmeld/domain/') && ability.endsWith('/admin')) {
947
+ return false;
948
+ }
949
+ return true;
950
+ }) as string[];
681
951
 
682
952
  // Verification.
683
953
  const unCheck = await this.$checkGroupname();
684
954
  if (!unCheck.result) {
685
955
  throw new BadUsernameError(unCheck.message);
686
956
  }
687
- if (!tilmeld.config.emailUsernames) {
957
+ if (
958
+ !tilmeld.config.emailUsernames &&
959
+ tilmeld.config.userFields.includes('email')
960
+ ) {
688
961
  const emCheck = await this.$checkEmail();
689
962
  if (!emCheck.result) {
690
963
  throw new BadEmailError(emCheck.message);
@@ -692,19 +965,21 @@ export default class Group extends AbleObject<GroupData> {
692
965
  }
693
966
 
694
967
  // Validate group parent. Make sure it's not a descendant of this group.
968
+ const parent = this.$data.parent;
969
+ await parent?.$wake();
695
970
  if (
696
- this.$data.parent &&
697
- (this.$data.parent.cdate == null ||
698
- this.$is(this.$data.parent) ||
699
- this.$data.parent.$isDescendant(this))
971
+ parent &&
972
+ (parent.cdate == null ||
973
+ this.$is(parent) ||
974
+ (await parent.$isDescendant(this)))
700
975
  ) {
701
976
  throw new BadDataError(
702
- "Group parent can't be itself or descendant of itself."
977
+ "Group parent can't be itself or descendant of itself.",
703
978
  );
704
979
  }
705
980
 
706
981
  try {
707
- tilmeld.config.validatorGroup(this);
982
+ tilmeld.config.validatorGroup(tilmeld, this);
708
983
  } catch (e: any) {
709
984
  throw new BadDataError(e?.message);
710
985
  }
@@ -713,20 +988,62 @@ export default class Group extends AbleObject<GroupData> {
713
988
  if (this.$data.defaultPrimary) {
714
989
  const currentPrimary = await this.$nymph.getEntity(
715
990
  { class: tilmeld.Group },
716
- { type: '&', truthy: 'defaultPrimary' }
991
+ { type: '&', truthy: 'defaultPrimary' },
717
992
  );
718
993
  if (currentPrimary != null && !this.$is(currentPrimary)) {
719
994
  currentPrimary.defaultPrimary = false;
720
- if (!(await currentPrimary.$save())) {
995
+ try {
996
+ if (this.$skipAcWhenSaving) {
997
+ await currentPrimary.$saveSkipAC();
998
+ } else {
999
+ await currentPrimary.$save();
1000
+ }
1001
+ } catch (e: any) {
721
1002
  throw new CouldNotChangeDefaultPrimaryGroupError(
722
1003
  'Could not change new user primary group from ' +
723
- `${currentPrimary.groupname}.`
1004
+ `${currentPrimary.groupname}.`,
724
1005
  );
725
1006
  }
726
1007
  }
727
1008
  }
728
1009
 
729
- return await super.$save();
1010
+ let preGuid = this.guid;
1011
+ let preCdate = this.cdate;
1012
+ let preMdate = this.mdate;
1013
+ return await transaction(
1014
+ this.$nymph,
1015
+ 'tilmeld-save-group-' + (this.guid || nanoid()),
1016
+ async (nymph) => {
1017
+ this.$setNymph(nymph);
1018
+
1019
+ for (let callback of (this.constructor as typeof Group)
1020
+ .beforeSaveCallbacks) {
1021
+ if (callback) {
1022
+ await callback(this.$proxy());
1023
+ }
1024
+ }
1025
+
1026
+ await super.$save();
1027
+
1028
+ for (let callback of (this.constructor as typeof Group)
1029
+ .afterSaveCallbacks) {
1030
+ if (callback) {
1031
+ await callback(this.$proxy());
1032
+ }
1033
+ }
1034
+ },
1035
+ async (nymph, committed) => {
1036
+ this.$setNymph(nymph);
1037
+
1038
+ if (committed) {
1039
+ this.$originalGroupname = this.$data.groupname;
1040
+ } else {
1041
+ this.guid = preGuid;
1042
+ this.cdate = preCdate;
1043
+ this.mdate = preMdate;
1044
+ }
1045
+ },
1046
+ );
730
1047
  }
731
1048
 
732
1049
  /*
@@ -734,7 +1051,7 @@ export default class Group extends AbleObject<GroupData> {
734
1051
  */
735
1052
  public async $saveSkipAC() {
736
1053
  this.$skipAcWhenSaving = true;
737
- return await this.$save();
1054
+ await this.$save();
738
1055
  }
739
1056
 
740
1057
  public $tilmeldSaveSkipAC() {
@@ -745,77 +1062,166 @@ export default class Group extends AbleObject<GroupData> {
745
1062
  return false;
746
1063
  }
747
1064
 
1065
+ @transactional
748
1066
  public async $delete() {
749
- let tilmeld = this.$nymph.tilmeld as Tilmeld;
750
- if (!tilmeld.gatekeeper('tilmeld/admin')) {
751
- throw new BadDataError("You don't have the authority to delete groups.");
752
- }
753
-
754
- const transaction = 'tilmeld-delete-' + this.guid;
755
- const nymph = this.$nymph;
756
- const tnymph = await nymph.startTransaction(transaction);
757
- this.$nymph = tnymph;
758
- tilmeld = this.$nymph.tilmeld as Tilmeld;
759
-
760
- // Delete descendants.
761
- const descendants = await this.$getDescendants();
762
- if (descendants.length) {
763
- for (let curGroup of descendants) {
764
- if (!(await curGroup.$delete())) {
765
- await tnymph.rollback(transaction);
766
- this.$nymph = nymph;
767
- return false;
1067
+ let tilmeld = enforceTilmeld(this);
1068
+ const User = this.$nymph.getEntityClass(UserClass);
1069
+
1070
+ if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
1071
+ throw new AccessControlError(
1072
+ "You don't have the authority to delete groups.",
1073
+ );
1074
+ }
1075
+ if (this.$data.groupname !== this.$originalGroupname) {
1076
+ throw new AccessControlError(
1077
+ "You can't delete a group while switching its groupname.",
1078
+ );
1079
+ }
1080
+
1081
+ for (let callback of (this.constructor as typeof Group)
1082
+ .beforeDeleteCallbacks) {
1083
+ if (callback) {
1084
+ await callback(this.$proxy());
1085
+ }
1086
+ }
1087
+
1088
+ // Delete children.
1089
+ const children = await this.$nymph.getEntities(
1090
+ { class: tilmeld.Group, skipAc: true },
1091
+ { type: '&', ref: ['parent', this] },
1092
+ );
1093
+ if (children.length) {
1094
+ for (let curGroup of children) {
1095
+ if (this.$skipAcWhenDeleting) {
1096
+ await curGroup.$deleteSkipAC();
1097
+ } else {
1098
+ await curGroup.$delete();
768
1099
  }
769
1100
  }
770
1101
  }
771
1102
 
772
1103
  // Remove users from this primary group.
773
- const primaryUsers = await tnymph.getEntities(
774
- {
775
- class: tilmeld.User,
776
- skipAc: true,
777
- },
778
- {
779
- type: '&',
780
- ref: ['group', this],
781
- }
1104
+ const primaryUsers = await this.$nymph.getEntities(
1105
+ { class: User, skipAc: true },
1106
+ { type: '&', ref: ['group', this] },
782
1107
  );
783
1108
  for (let user of primaryUsers) {
784
1109
  delete user.group;
785
- if (!(await user.$save())) {
786
- await tnymph.rollback(transaction);
787
- this.$nymph = nymph;
788
- return false;
1110
+ if (this.$skipAcWhenDeleting) {
1111
+ await user.$saveSkipAC();
1112
+ } else {
1113
+ await user.$save();
789
1114
  }
790
1115
  }
791
1116
 
792
1117
  // Remove users from this secondary group.
793
- const secondaryUsers = await tnymph.getEntities(
794
- {
795
- class: tilmeld.User,
796
- skipAc: true,
797
- },
798
- {
799
- type: '&',
800
- ref: ['groups', this],
801
- }
1118
+ const secondaryUsers = await this.$nymph.getEntities(
1119
+ { class: User, skipAc: true },
1120
+ { type: '&', ref: ['groups', this] },
802
1121
  );
803
1122
  for (let user of secondaryUsers) {
804
1123
  user.$delGroup(this);
805
- if (!(await user.$save())) {
806
- await tnymph.rollback(transaction);
807
- return false;
1124
+ if (this.$skipAcWhenDeleting) {
1125
+ await user.$saveSkipAC();
1126
+ } else {
1127
+ await user.$save();
808
1128
  }
809
1129
  }
810
1130
 
811
1131
  // Delete the group.
812
- const success = await super.$delete();
813
- if (success) {
814
- await tnymph.commit(transaction);
815
- } else {
816
- await tnymph.rollback(transaction);
1132
+ await super.$delete();
1133
+
1134
+ for (let callback of (this.constructor as typeof Group)
1135
+ .afterDeleteCallbacks) {
1136
+ if (callback) {
1137
+ await callback(this.$proxy());
1138
+ }
1139
+ }
1140
+ }
1141
+
1142
+ /*
1143
+ * This should *never* be accessible on the client.
1144
+ */
1145
+ async $deleteSkipAC() {
1146
+ this.$skipAcWhenDeleting = true;
1147
+ await this.$delete();
1148
+ }
1149
+
1150
+ $tilmeldDeleteSkipAC() {
1151
+ if (this.$skipAcWhenDeleting) {
1152
+ this.$skipAcWhenDeleting = false;
1153
+ return true;
1154
+ }
1155
+ return false;
1156
+ }
1157
+
1158
+ public static on<T extends GroupEventType>(
1159
+ event: T,
1160
+ callback: T extends 'checkGroupname'
1161
+ ? TilmeldCheckGroupnameCallback
1162
+ : T extends 'beforeSave'
1163
+ ? TilmeldBeforeGroupSaveCallback
1164
+ : T extends 'afterSave'
1165
+ ? TilmeldAfterGroupSaveCallback
1166
+ : T extends 'beforeDelete'
1167
+ ? TilmeldBeforeGroupDeleteCallback
1168
+ : T extends 'afterDelete'
1169
+ ? TilmeldAfterGroupDeleteCallback
1170
+ : never,
1171
+ ) {
1172
+ const prop = (event + 'Callbacks') as T extends 'checkGroupname'
1173
+ ? 'checkGroupnameCallbacks'
1174
+ : T extends 'beforeSave'
1175
+ ? 'beforeSaveCallbacks'
1176
+ : T extends 'afterSave'
1177
+ ? 'afterSaveCallbacks'
1178
+ : T extends 'beforeDelete'
1179
+ ? 'beforeDeleteCallbacks'
1180
+ : T extends 'afterDelete'
1181
+ ? 'afterDeleteCallbacks'
1182
+ : never;
1183
+ if (!(prop in this)) {
1184
+ throw new Error('Invalid event type.');
1185
+ }
1186
+ // @ts-ignore: The callback should always be the right type here.
1187
+ this[prop].push(callback);
1188
+ return () => this.off(event, callback);
1189
+ }
1190
+
1191
+ public static off<T extends GroupEventType>(
1192
+ event: T,
1193
+ callback: T extends 'checkGroupname'
1194
+ ? TilmeldCheckGroupnameCallback
1195
+ : T extends 'beforeSave'
1196
+ ? TilmeldBeforeGroupSaveCallback
1197
+ : T extends 'afterSave'
1198
+ ? TilmeldAfterGroupSaveCallback
1199
+ : T extends 'beforeDelete'
1200
+ ? TilmeldBeforeGroupDeleteCallback
1201
+ : T extends 'afterDelete'
1202
+ ? TilmeldAfterGroupDeleteCallback
1203
+ : never,
1204
+ ) {
1205
+ const prop = (event + 'Callbacks') as T extends 'checkGroupname'
1206
+ ? 'checkGroupnameCallbacks'
1207
+ : T extends 'beforeSave'
1208
+ ? 'beforeSaveCallbacks'
1209
+ : T extends 'afterSave'
1210
+ ? 'afterSaveCallbacks'
1211
+ : T extends 'beforeDelete'
1212
+ ? 'beforeDeleteCallbacks'
1213
+ : T extends 'afterDelete'
1214
+ ? 'afterDeleteCallbacks'
1215
+ : never;
1216
+ if (!(prop in this)) {
1217
+ return false;
1218
+ }
1219
+ // @ts-ignore: The callback should always be the right type here.
1220
+ const i = this[prop].indexOf(callback);
1221
+ if (i > -1) {
1222
+ // @ts-ignore: The callback should always be the right type here.
1223
+ this[prop].splice(i, 1);
817
1224
  }
818
- this.$nymph = nymph;
819
- return success;
1225
+ return true;
820
1226
  }
821
1227
  }