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

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 +504 -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 +571 -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 +1386 -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 +683 -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 +1628 -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,111 @@ 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
+ // TODO: Fix these being added by the drivers.
445
+ // @ts-ignore
446
+ delete this.$data.acRead;
447
+ // @ts-ignore
448
+ delete this.$data.acWrite;
449
+ // @ts-ignore
450
+ delete this.$data.acFull;
451
+ if (source === 'server') {
452
+ this.$originalGroupname = this.$data.groupname;
453
+ }
285
454
  this.$updateDataProtection();
286
455
  }
287
456
 
@@ -290,9 +459,16 @@ export default class Group extends AbleObject<GroupData> {
290
459
  *
291
460
  * @param givenUser User to update protection for. If undefined, will use the currently logged in user.
292
461
  */
293
- public $updateDataProtection(givenUser?: User & UserData) {
294
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
462
+ public $updateDataProtection(givenUser?: UserClass & UserData) {
463
+ const tilmeld = enforceTilmeld(this);
295
464
  let user = givenUser ?? tilmeld.User.current();
465
+ let [_groupname, domain] = tilmeld.config.domainSupport
466
+ ? splitn(this.$data.groupname ?? '', '@', 2)
467
+ : [this.$data.groupname, null];
468
+ // Lowercase the domain part.
469
+ if (domain) {
470
+ domain = domain.toLowerCase();
471
+ }
296
472
 
297
473
  this.$privateData = [...Group.DEFAULT_PRIVATE_DATA];
298
474
  this.$allowlistData = [...Group.DEFAULT_ALLOWLIST_DATA];
@@ -303,8 +479,8 @@ export default class Group extends AbleObject<GroupData> {
303
479
 
304
480
  if (
305
481
  user != null &&
306
- ((user.abilities?.indexOf('tilmeld/admin') ?? -1) !== -1 ||
307
- (user.abilities?.indexOf('system/admin') ?? -1) !== -1)
482
+ (user.abilities?.includes('tilmeld/admin') ||
483
+ user.abilities?.includes('system/admin'))
308
484
  ) {
309
485
  // Users who can edit groups can see their data.
310
486
  this.$privateData = [];
@@ -312,10 +488,18 @@ export default class Group extends AbleObject<GroupData> {
312
488
  } else if (
313
489
  this.$data.user != null &&
314
490
  user != null &&
315
- this.$data.user.$is(user)
491
+ this.$data.user.guid === user.guid
316
492
  ) {
317
493
  // Users can see their group's data.
318
494
  this.$privateData = [];
495
+ } else if (
496
+ user != null &&
497
+ tilmeld.config.domainSupport &&
498
+ domain &&
499
+ user.abilities?.includes(`tilmeld/domain/${domain}/admin`)
500
+ ) {
501
+ // Domain admins can see their groups' data.
502
+ this.$privateData = [];
319
503
  }
320
504
  }
321
505
 
@@ -325,11 +509,13 @@ export default class Group extends AbleObject<GroupData> {
325
509
  * @param group The group, or the group's GUID.
326
510
  * @returns True or false.
327
511
  */
328
- public $isDescendant(givenGroup: (Group & GroupData) | string): boolean {
329
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
512
+ public async $isDescendant(
513
+ givenGroup: (Group & GroupData) | string,
514
+ ): Promise<boolean> {
515
+ const tilmeld = enforceTilmeld(this);
330
516
  let group: Group & GroupData;
331
517
  if (typeof givenGroup === 'string') {
332
- group = tilmeld.Group.factorySync(givenGroup);
518
+ group = await tilmeld.Group.factory(givenGroup);
333
519
  } else {
334
520
  group = givenGroup;
335
521
  }
@@ -337,13 +523,15 @@ export default class Group extends AbleObject<GroupData> {
337
523
  return false;
338
524
  }
339
525
  // Check to see if the group is a descendant of the given group.
340
- if (this.$data.parent == null) {
526
+ const parent = this.$data.parent;
527
+ await parent?.$wake();
528
+ if (parent == null) {
341
529
  return false;
342
530
  }
343
- if (this.$data.parent.$is(group)) {
531
+ if (parent.$is(group)) {
344
532
  return true;
345
533
  }
346
- if (this.$data.parent.$isDescendant(group)) {
534
+ if (await parent.$isDescendant(group)) {
347
535
  return true;
348
536
  }
349
537
  return false;
@@ -355,14 +543,14 @@ export default class Group extends AbleObject<GroupData> {
355
543
  * @returns An array of groups.
356
544
  */
357
545
  public async $getChildren() {
358
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
546
+ const tilmeld = enforceTilmeld(this);
359
547
  return await this.$nymph.getEntities(
360
548
  { class: tilmeld.Group },
361
549
  {
362
550
  type: '&',
363
551
  equal: ['enabled', true],
364
552
  ref: ['parent', this],
365
- }
553
+ },
366
554
  );
367
555
  }
368
556
 
@@ -373,9 +561,9 @@ export default class Group extends AbleObject<GroupData> {
373
561
  * @returns An array of groups.
374
562
  */
375
563
  public async $getDescendants(
376
- andSelf = false
564
+ andSelf = false,
377
565
  ): Promise<(Group & GroupData)[]> {
378
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
566
+ const tilmeld = enforceTilmeld(this);
379
567
  let groups: (Group & GroupData)[] = [];
380
568
  const entities = await this.$nymph.getEntities(
381
569
  { class: tilmeld.Group },
@@ -383,7 +571,7 @@ export default class Group extends AbleObject<GroupData> {
383
571
  type: '&',
384
572
  equal: ['enabled', true],
385
573
  ref: ['parent', this],
386
- }
574
+ },
387
575
  );
388
576
  for (let entity of entities) {
389
577
  groups = (await entity.$getDescendants(true)).concat(groups);
@@ -394,37 +582,6 @@ export default class Group extends AbleObject<GroupData> {
394
582
  return groups;
395
583
  }
396
584
 
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
585
  /**
429
586
  * Get the number of parents the group has.
430
587
  *
@@ -436,13 +593,17 @@ export default class Group extends AbleObject<GroupData> {
436
593
  *
437
594
  * @returns The level of the group.
438
595
  */
439
- public $getLevel() {
440
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
441
- let group = tilmeld.Group.factorySync(this.guid ?? undefined);
596
+ public async $getLevel() {
597
+ const tilmeld = enforceTilmeld(this);
598
+ let group = await tilmeld.Group.factory(this.guid ?? undefined);
599
+ let parent = group.parent;
600
+ await parent?.$wake();
442
601
  let level = 0;
443
- while (group.parent != null && group.parent.cdate != null && level < 1024) {
602
+ while (parent != null && parent.cdate != null && level < 1024) {
444
603
  level++;
445
- group = group.parent;
604
+ group = parent;
605
+ parent = group.parent;
606
+ await parent?.$wake();
446
607
  }
447
608
  return level;
448
609
  }
@@ -458,9 +619,9 @@ export default class Group extends AbleObject<GroupData> {
458
619
  public async $getUsers(
459
620
  descendants = false,
460
621
  limit?: number,
461
- offset?: number
462
- ): Promise<(User & UserData)[]> {
463
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
622
+ offset?: number,
623
+ ): Promise<(UserClass & UserData)[]> {
624
+ const tilmeld = enforceTilmeld(this);
464
625
  let groups: (Group & GroupData)[] = [];
465
626
  if (descendants) {
466
627
  groups = await this.$getDescendants();
@@ -489,7 +650,7 @@ export default class Group extends AbleObject<GroupData> {
489
650
  group,
490
651
  ]),
491
652
  ],
492
- }
653
+ },
493
654
  );
494
655
  }
495
656
 
@@ -502,15 +663,36 @@ export default class Group extends AbleObject<GroupData> {
502
663
  result: boolean;
503
664
  message: string;
504
665
  }> {
505
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
666
+ const tilmeld = enforceTilmeld(this);
506
667
  if (!tilmeld.config.emailUsernames) {
507
- if (this.$data.groupname == null || !this.$data.groupname.length) {
508
- return { result: false, message: 'Please specify a groupname.' };
668
+ let [groupname, domain] = tilmeld.config.domainSupport
669
+ ? splitn(this.$data.groupname ?? '', '@', 2)
670
+ : [this.$data.groupname, null];
671
+
672
+ // Lowercase the domain part.
673
+ if (domain) {
674
+ domain = domain.toLowerCase();
675
+ this.$data.groupname = `${groupname}@${domain}`;
509
676
  }
677
+
510
678
  if (
511
- tilmeld.config.maxUsernameLength > 0 &&
512
- this.$data.groupname.length > tilmeld.config.maxUsernameLength
679
+ this.$data.groupname == null ||
680
+ !this.$data.groupname.length ||
681
+ groupname == null ||
682
+ !groupname.length
513
683
  ) {
684
+ return { result: false, message: 'Please specify a groupname.' };
685
+ }
686
+ if (groupname.length < tilmeld.config.minUsernameLength) {
687
+ return {
688
+ result: false,
689
+ message:
690
+ 'Groupnames must be at least ' +
691
+ tilmeld.config.minUsernameLength +
692
+ ' characters.',
693
+ };
694
+ }
695
+ if (groupname.length > tilmeld.config.maxUsernameLength) {
514
696
  return {
515
697
  result: false,
516
698
  message:
@@ -520,22 +702,38 @@ export default class Group extends AbleObject<GroupData> {
520
702
  };
521
703
  }
522
704
  if (
523
- difference(
524
- this.$data.groupname.split(''),
525
- tilmeld.config.validChars.split('')
526
- ).length
705
+ difference(groupname.split(''), tilmeld.config.validChars.split(''))
706
+ .length
527
707
  ) {
528
708
  return {
529
709
  result: false,
530
710
  message: tilmeld.config.validCharsNotice,
531
711
  };
532
712
  }
533
- if (!tilmeld.config.validRegex.test(this.$data.groupname)) {
713
+ if (!tilmeld.config.validRegex.test(groupname)) {
534
714
  return {
535
715
  result: false,
536
716
  message: tilmeld.config.validRegexNotice,
537
717
  };
538
718
  }
719
+ if (domain != null) {
720
+ if (!tilmeld.config.validDomainRegex.test(domain)) {
721
+ return {
722
+ result: false,
723
+ message: tilmeld.config.validDomainRegexNotice,
724
+ };
725
+ }
726
+ if (
727
+ !this.$is(tilmeld.currentUser?.group) &&
728
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
729
+ ) {
730
+ return {
731
+ result: false,
732
+ message:
733
+ "You don't have the authority to manage groups on this domain.",
734
+ };
735
+ }
736
+ }
539
737
 
540
738
  const selector: Selector = {
541
739
  type: '&',
@@ -549,12 +747,24 @@ export default class Group extends AbleObject<GroupData> {
549
747
  }
550
748
  const test = await this.$nymph.getEntity(
551
749
  { class: tilmeld.Group, skipAc: true },
552
- selector
750
+ selector,
553
751
  );
554
752
  if (test != null) {
555
753
  return { result: false, message: 'That groupname is taken.' };
556
754
  }
557
755
 
756
+ for (let callback of (this.constructor as typeof Group)
757
+ .checkGroupnameCallbacks) {
758
+ if (callback) {
759
+ const result = await callback(this, {
760
+ groupname: this.$data.groupname,
761
+ });
762
+ if (!result.result) {
763
+ return { message: 'Groupname is not available.', ...result };
764
+ }
765
+ }
766
+ }
767
+
558
768
  return {
559
769
  result: true,
560
770
  message:
@@ -565,10 +775,16 @@ export default class Group extends AbleObject<GroupData> {
565
775
  if (this.$data.groupname == null || !this.$data.groupname.length) {
566
776
  return { result: false, message: 'Please specify an email.' };
567
777
  }
568
- if (
569
- tilmeld.config.maxUsernameLength > 0 &&
570
- this.$data.groupname.length > tilmeld.config.maxUsernameLength
571
- ) {
778
+ if (this.$data.groupname.length < tilmeld.config.minUsernameLength) {
779
+ return {
780
+ result: false,
781
+ message:
782
+ 'Emails must be at least ' +
783
+ tilmeld.config.minUsernameLength +
784
+ ' characters.',
785
+ };
786
+ }
787
+ if (this.$data.groupname.length > tilmeld.config.maxUsernameLength) {
572
788
  return {
573
789
  result: false,
574
790
  message:
@@ -588,7 +804,7 @@ export default class Group extends AbleObject<GroupData> {
588
804
  * @returns An object with a boolean 'result' entry and a 'message' entry.
589
805
  */
590
806
  public async $checkEmail(): Promise<{ result: boolean; message: string }> {
591
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
807
+ const tilmeld = enforceTilmeld(this);
592
808
  if (this.$data.email === '') {
593
809
  return { result: true, message: '' };
594
810
  }
@@ -610,7 +826,7 @@ export default class Group extends AbleObject<GroupData> {
610
826
  }
611
827
  const test = await this.$nymph.getEntity(
612
828
  { class: tilmeld.Group, skipAc: true },
613
- selector
829
+ selector,
614
830
  );
615
831
  if (test != null) {
616
832
  return {
@@ -627,11 +843,64 @@ export default class Group extends AbleObject<GroupData> {
627
843
  }
628
844
 
629
845
  public async $save() {
630
- const tilmeld = this.$nymph.tilmeld as Tilmeld;
846
+ let tilmeld = enforceTilmeld(this);
631
847
  if (this.$data.groupname == null || !this.$data.groupname.trim().length) {
632
848
  return false;
633
849
  }
634
850
 
851
+ // Lowercase the domain part.
852
+ if (tilmeld.config.domainSupport) {
853
+ const [groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
854
+ if (domain) {
855
+ this.$data.groupname = `${groupname}@${domain.toLowerCase()}`;
856
+ }
857
+ }
858
+
859
+ if (tilmeld.config.domainSupport) {
860
+ const [_groupname, domain] = splitn(this.$data.groupname ?? '', '@', 2);
861
+ const [_originalGroupname, originalDomain] = splitn(
862
+ this.$originalGroupname ?? '',
863
+ '@',
864
+ 2,
865
+ );
866
+
867
+ if (
868
+ this.guid != null &&
869
+ ((originalDomain == null && domain != null) ||
870
+ (domain == null && originalDomain != null))
871
+ ) {
872
+ throw new AccessControlError(
873
+ "Groups can't switch to or from having a domain once created.",
874
+ );
875
+ }
876
+
877
+ if (
878
+ !this.$skipAcWhenSaving &&
879
+ !tilmeld.gatekeeper('tilmeld/admin') &&
880
+ !tilmeld.gatekeeper('system/admin')
881
+ ) {
882
+ if (
883
+ domain != null &&
884
+ !this.$is(tilmeld.currentUser) &&
885
+ !tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`)
886
+ ) {
887
+ throw new AccessControlError(
888
+ "You don't have the authority to modify groups on this domain.",
889
+ );
890
+ }
891
+ if (
892
+ originalDomain != null &&
893
+ originalDomain !== domain &&
894
+ (!tilmeld.gatekeeper(`tilmeld/domain/${domain}/admin`) ||
895
+ !tilmeld.gatekeeper(`tilmeld/domain/${originalDomain}/admin`))
896
+ ) {
897
+ throw new AccessControlError(
898
+ "You don't have the authority to modify groups on this domain.",
899
+ );
900
+ }
901
+ }
902
+ }
903
+
635
904
  // Formatting.
636
905
  this.$data.groupname = this.$data.groupname.trim();
637
906
  if (tilmeld.config.emailUsernames) {
@@ -643,16 +912,22 @@ export default class Group extends AbleObject<GroupData> {
643
912
  this.$data.enabled = !!this.$data.enabled;
644
913
 
645
914
  // Clear empty values.
646
- if (this.$data.name === '') {
915
+ if (this.$data.name === '' || !tilmeld.config.userFields.includes('name')) {
647
916
  delete this.$data.name;
648
917
  }
649
- if (this.$data.email === '') {
918
+ if (
919
+ this.$data.email === '' ||
920
+ !tilmeld.config.userFields.includes('email')
921
+ ) {
650
922
  delete this.$data.email;
651
923
  }
652
924
  if (this.$data.avatar === '') {
653
925
  delete this.$data.avatar;
654
926
  }
655
- if (this.$data.phone === '') {
927
+ if (
928
+ this.$data.phone === '' ||
929
+ !tilmeld.config.userFields.includes('phone')
930
+ ) {
656
931
  delete this.$data.phone;
657
932
  }
658
933
  if (this.$data.parent == null) {
@@ -671,20 +946,33 @@ export default class Group extends AbleObject<GroupData> {
671
946
  delete this.$data.unverifiedSecondary;
672
947
  }
673
948
 
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[];
949
+ // Groups should never have 'system/admin', 'tilmeld/admin',
950
+ // 'tilmeld/switch', or domain admin abilities.
951
+ this.$data.abilities = (this.$data.abilities ?? []).filter((ability) => {
952
+ if (
953
+ ['system/admin', 'tilmeld/admin', 'tilmeld/switch'].indexOf(ability) !==
954
+ -1
955
+ ) {
956
+ return false;
957
+ }
958
+ if (ability == null) {
959
+ return false;
960
+ }
961
+ if (ability.startsWith('tilmeld/domain/') && ability.endsWith('/admin')) {
962
+ return false;
963
+ }
964
+ return true;
965
+ }) as string[];
681
966
 
682
967
  // Verification.
683
968
  const unCheck = await this.$checkGroupname();
684
969
  if (!unCheck.result) {
685
970
  throw new BadUsernameError(unCheck.message);
686
971
  }
687
- if (!tilmeld.config.emailUsernames) {
972
+ if (
973
+ !tilmeld.config.emailUsernames &&
974
+ tilmeld.config.userFields.includes('email')
975
+ ) {
688
976
  const emCheck = await this.$checkEmail();
689
977
  if (!emCheck.result) {
690
978
  throw new BadEmailError(emCheck.message);
@@ -692,19 +980,21 @@ export default class Group extends AbleObject<GroupData> {
692
980
  }
693
981
 
694
982
  // Validate group parent. Make sure it's not a descendant of this group.
983
+ const parent = this.$data.parent;
984
+ await parent?.$wake();
695
985
  if (
696
- this.$data.parent &&
697
- (this.$data.parent.cdate == null ||
698
- this.$is(this.$data.parent) ||
699
- this.$data.parent.$isDescendant(this))
986
+ parent &&
987
+ (parent.cdate == null ||
988
+ this.$is(parent) ||
989
+ (await parent.$isDescendant(this)))
700
990
  ) {
701
991
  throw new BadDataError(
702
- "Group parent can't be itself or descendant of itself."
992
+ "Group parent can't be itself or descendant of itself.",
703
993
  );
704
994
  }
705
995
 
706
996
  try {
707
- tilmeld.config.validatorGroup(this);
997
+ tilmeld.config.validatorGroup(tilmeld, this);
708
998
  } catch (e: any) {
709
999
  throw new BadDataError(e?.message);
710
1000
  }
@@ -713,20 +1003,62 @@ export default class Group extends AbleObject<GroupData> {
713
1003
  if (this.$data.defaultPrimary) {
714
1004
  const currentPrimary = await this.$nymph.getEntity(
715
1005
  { class: tilmeld.Group },
716
- { type: '&', truthy: 'defaultPrimary' }
1006
+ { type: '&', truthy: 'defaultPrimary' },
717
1007
  );
718
1008
  if (currentPrimary != null && !this.$is(currentPrimary)) {
719
1009
  currentPrimary.defaultPrimary = false;
720
- if (!(await currentPrimary.$save())) {
1010
+ if (
1011
+ this.$skipAcWhenSaving
1012
+ ? !(await currentPrimary.$saveSkipAC())
1013
+ : !(await currentPrimary.$save())
1014
+ ) {
721
1015
  throw new CouldNotChangeDefaultPrimaryGroupError(
722
1016
  'Could not change new user primary group from ' +
723
- `${currentPrimary.groupname}.`
1017
+ `${currentPrimary.groupname}.`,
724
1018
  );
725
1019
  }
726
1020
  }
727
1021
  }
728
1022
 
729
- return await super.$save();
1023
+ const transaction = 'tilmeld-save-group-' + (this.guid || nanoid());
1024
+ const nymph = this.$nymph;
1025
+ const tnymph = await nymph.startTransaction(transaction);
1026
+ this.$setNymph(tnymph);
1027
+ tilmeld = enforceTilmeld(this);
1028
+
1029
+ let preGuid = this.guid;
1030
+ let preCdate = this.cdate;
1031
+ let preMdate = this.mdate;
1032
+
1033
+ try {
1034
+ for (let callback of (this.constructor as typeof Group)
1035
+ .beforeSaveCallbacks) {
1036
+ if (callback) {
1037
+ await callback(this);
1038
+ }
1039
+ }
1040
+
1041
+ let ret = await super.$save();
1042
+ if (ret) {
1043
+ this.$originalGroupname = this.$data.groupname;
1044
+ }
1045
+ for (let callback of (this.constructor as typeof Group)
1046
+ .afterSaveCallbacks) {
1047
+ if (callback) {
1048
+ await callback(this);
1049
+ }
1050
+ }
1051
+ ret = await tnymph.commit(transaction);
1052
+ this.$setNymph(nymph);
1053
+ return ret;
1054
+ } catch (e: any) {
1055
+ await tnymph.rollback(transaction);
1056
+ this.guid = preGuid;
1057
+ this.cdate = preCdate;
1058
+ this.mdate = preMdate;
1059
+ this.$setNymph(nymph);
1060
+ throw e;
1061
+ }
730
1062
  }
731
1063
 
732
1064
  /*
@@ -746,76 +1078,203 @@ export default class Group extends AbleObject<GroupData> {
746
1078
  }
747
1079
 
748
1080
  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.");
1081
+ let tilmeld = enforceTilmeld(this);
1082
+ if (!this.$skipAcWhenDeleting && !tilmeld.gatekeeper('tilmeld/admin')) {
1083
+ throw new AccessControlError(
1084
+ "You don't have the authority to delete groups.",
1085
+ );
1086
+ }
1087
+ if (this.$data.groupname !== this.$originalGroupname) {
1088
+ throw new AccessControlError(
1089
+ "You can't delete a group while switching its groupname.",
1090
+ );
752
1091
  }
753
1092
 
754
- const transaction = 'tilmeld-delete-' + this.guid;
1093
+ const transaction = 'tilmeld-delete-group-' + this.guid;
755
1094
  const nymph = this.$nymph;
756
1095
  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())) {
1096
+ this.$setNymph(tnymph);
1097
+ tilmeld = enforceTilmeld(this);
1098
+ const User = tnymph.getEntityClass(UserClass);
1099
+
1100
+ try {
1101
+ for (let callback of (this.constructor as typeof Group)
1102
+ .beforeDeleteCallbacks) {
1103
+ if (callback) {
1104
+ await callback(this);
1105
+ }
1106
+ }
1107
+
1108
+ // Delete descendants.
1109
+ const descendants = await this.$getDescendants();
1110
+ if (descendants.length) {
1111
+ for (let curGroup of descendants) {
1112
+ if (
1113
+ this.$skipAcWhenDeleting
1114
+ ? !(await curGroup.$deleteSkipAC())
1115
+ : !(await curGroup.$delete())
1116
+ ) {
1117
+ await tnymph.rollback(transaction);
1118
+ this.$setNymph(nymph);
1119
+ return false;
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ // Remove users from this primary group.
1125
+ const primaryUsers = await tnymph.getEntities(
1126
+ {
1127
+ class: User,
1128
+ skipAc: true,
1129
+ },
1130
+ {
1131
+ type: '&',
1132
+ ref: ['group', this],
1133
+ },
1134
+ );
1135
+ for (let user of primaryUsers) {
1136
+ delete user.group;
1137
+ if (
1138
+ this.$skipAcWhenDeleting
1139
+ ? !(await user.$saveSkipAC())
1140
+ : !(await user.$save())
1141
+ ) {
765
1142
  await tnymph.rollback(transaction);
766
- this.$nymph = nymph;
1143
+ this.$setNymph(nymph);
767
1144
  return false;
768
1145
  }
769
1146
  }
770
- }
771
1147
 
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],
1148
+ // Remove users from this secondary group.
1149
+ const secondaryUsers = await tnymph.getEntities(
1150
+ {
1151
+ class: User,
1152
+ skipAc: true,
1153
+ },
1154
+ {
1155
+ type: '&',
1156
+ ref: ['groups', this],
1157
+ },
1158
+ );
1159
+ for (let user of secondaryUsers) {
1160
+ user.$delGroup(this);
1161
+ if (
1162
+ this.$skipAcWhenDeleting
1163
+ ? !(await user.$saveSkipAC())
1164
+ : !(await user.$save())
1165
+ ) {
1166
+ await tnymph.rollback(transaction);
1167
+ this.$setNymph(nymph);
1168
+ return false;
1169
+ }
781
1170
  }
782
- );
783
- for (let user of primaryUsers) {
784
- delete user.group;
785
- if (!(await user.$save())) {
1171
+
1172
+ // Delete the group.
1173
+ let success = await super.$delete();
1174
+ if (success) {
1175
+ for (let callback of (this.constructor as typeof Group)
1176
+ .afterDeleteCallbacks) {
1177
+ if (callback) {
1178
+ await callback(this);
1179
+ }
1180
+ }
1181
+
1182
+ success = await tnymph.commit(transaction);
1183
+ } else {
786
1184
  await tnymph.rollback(transaction);
787
- this.$nymph = nymph;
788
- return false;
789
1185
  }
1186
+ this.$setNymph(nymph);
1187
+ return success;
1188
+ } catch (e: any) {
1189
+ await tnymph.rollback(transaction);
1190
+ this.$setNymph(nymph);
1191
+ throw e;
790
1192
  }
1193
+ }
791
1194
 
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
- }
1195
+ /*
1196
+ * This should *never* be accessible on the client.
1197
+ */
1198
+ async $deleteSkipAC() {
1199
+ this.$skipAcWhenDeleting = true;
1200
+ return await this.$delete();
1201
+ }
1202
+
1203
+ $tilmeldDeleteSkipAC() {
1204
+ if (this.$skipAcWhenDeleting) {
1205
+ this.$skipAcWhenDeleting = false;
1206
+ return true;
809
1207
  }
1208
+ return false;
1209
+ }
810
1210
 
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);
1211
+ public static on<T extends GroupEventType>(
1212
+ event: T,
1213
+ callback: T extends 'checkGroupname'
1214
+ ? TilmeldCheckGroupnameCallback
1215
+ : T extends 'beforeSave'
1216
+ ? TilmeldBeforeGroupSaveCallback
1217
+ : T extends 'afterSave'
1218
+ ? TilmeldAfterGroupSaveCallback
1219
+ : T extends 'beforeDelete'
1220
+ ? TilmeldBeforeGroupDeleteCallback
1221
+ : T extends 'afterDelete'
1222
+ ? TilmeldAfterGroupDeleteCallback
1223
+ : never,
1224
+ ) {
1225
+ const prop = (event + 'Callbacks') as T extends 'checkGroupname'
1226
+ ? 'checkGroupnameCallbacks'
1227
+ : T extends 'beforeSave'
1228
+ ? 'beforeSaveCallbacks'
1229
+ : T extends 'afterSave'
1230
+ ? 'afterSaveCallbacks'
1231
+ : T extends 'beforeDelete'
1232
+ ? 'beforeDeleteCallbacks'
1233
+ : T extends 'afterDelete'
1234
+ ? 'afterDeleteCallbacks'
1235
+ : never;
1236
+ if (!(prop in this)) {
1237
+ throw new Error('Invalid event type.');
817
1238
  }
818
- this.$nymph = nymph;
819
- return success;
1239
+ // @ts-ignore: The callback should always be the right type here.
1240
+ this[prop].push(callback);
1241
+ return () => this.off(event, callback);
1242
+ }
1243
+
1244
+ public static off<T extends GroupEventType>(
1245
+ event: T,
1246
+ callback: T extends 'checkGroupname'
1247
+ ? TilmeldCheckGroupnameCallback
1248
+ : T extends 'beforeSave'
1249
+ ? TilmeldBeforeGroupSaveCallback
1250
+ : T extends 'afterSave'
1251
+ ? TilmeldAfterGroupSaveCallback
1252
+ : T extends 'beforeDelete'
1253
+ ? TilmeldBeforeGroupDeleteCallback
1254
+ : T extends 'afterDelete'
1255
+ ? TilmeldAfterGroupDeleteCallback
1256
+ : never,
1257
+ ) {
1258
+ const prop = (event + 'Callbacks') as T extends 'checkGroupname'
1259
+ ? 'checkGroupnameCallbacks'
1260
+ : T extends 'beforeSave'
1261
+ ? 'beforeSaveCallbacks'
1262
+ : T extends 'afterSave'
1263
+ ? 'afterSaveCallbacks'
1264
+ : T extends 'beforeDelete'
1265
+ ? 'beforeDeleteCallbacks'
1266
+ : T extends 'afterDelete'
1267
+ ? 'afterDeleteCallbacks'
1268
+ : never;
1269
+ if (!(prop in this)) {
1270
+ return false;
1271
+ }
1272
+ // @ts-ignore: The callback should always be the right type here.
1273
+ const i = this[prop].indexOf(callback);
1274
+ if (i > -1) {
1275
+ // @ts-ignore: The callback should always be the right type here.
1276
+ this[prop].splice(i, 1);
1277
+ }
1278
+ return true;
820
1279
  }
821
1280
  }