@nymphjs/tilmeld 1.0.0-beta.11 → 1.0.0-beta.110

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