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

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