@majikah/majik-message 0.2.20 → 0.3.0

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.
@@ -0,0 +1,557 @@
1
+ /* -------------------------------
2
+ * Types
3
+ * ------------------------------- */
4
+ import { isSystemGroupId, MajikContactGroup, SYSTEM_GROUP_IDS, } from "@majikah/majik-contact";
5
+ import { MajikContactDirectory } from "./majik-contact-directory";
6
+ import { MajikContactGroupManagerError } from "./errors";
7
+ /* -------------------------------
8
+ * MajikContactGroupManager Class
9
+ * ------------------------------- */
10
+ /**
11
+ * Manages the full lifecycle of MajikContactGroup instances.
12
+ *
13
+ * Responsibilities:
14
+ * - Owns the canonical Map of all groups (user-created + system groups)
15
+ * - Maintains a reverse index: contactId → Set<groupId> for O(1) group lookups per contact
16
+ * - Hydrates group members into full MajikContact instances via an injected MajikContactDirectory
17
+ * - Automatically syncs system group side-effects (e.g. Blocked group ↔ MajikContact.block())
18
+ * - Provides a handleContactRemoved() hook for MajikContactDirectory to call on contact removal
19
+ * - Handles serialization / deserialization of all groups together
20
+ *
21
+ * System groups (Favorites, Blocked) are always present and are bootstrapped in the constructor.
22
+ * They cannot be deleted or renamed but their membership is fully manageable.
23
+ */
24
+ export class MajikContactGroupManager {
25
+ groups = new Map();
26
+ /**
27
+ * Reverse index: contactId → Set of groupIds the contact belongs to.
28
+ * Kept in sync on every membership mutation so getGroupsForContact() is O(1).
29
+ */
30
+ contactGroupIndex = new Map();
31
+ directory;
32
+ constructor(directory) {
33
+ this.assertDirectory(directory);
34
+ this.directory = directory;
35
+ this.bootstrapSystemGroups();
36
+ }
37
+ /* ================================
38
+ * Initialization
39
+ * ================================ */
40
+ /**
41
+ * Ensures the two system groups always exist on construction.
42
+ * Safe to call multiple times — skips if already present.
43
+ */
44
+ bootstrapSystemGroups() {
45
+ if (!this.groups.has(SYSTEM_GROUP_IDS.FAVORITES)) {
46
+ const favorites = MajikContactGroup.createFavorites();
47
+ this.groups.set(favorites.id, favorites);
48
+ }
49
+ if (!this.groups.has(SYSTEM_GROUP_IDS.BLOCKED)) {
50
+ const blocked = MajikContactGroup.createBlocked();
51
+ this.groups.set(blocked.id, blocked);
52
+ }
53
+ }
54
+ /* ================================
55
+ * Group CRUD
56
+ * ================================ */
57
+ /**
58
+ * Creates and registers a new user group.
59
+ * Throws if a group with the same ID already exists.
60
+ */
61
+ createGroup(id, name, meta, initialMemberIds) {
62
+ this.assertGroupId(id);
63
+ this.assertNotSystemId(id, "createGroup");
64
+ if (this.groups.has(id)) {
65
+ throw new MajikContactGroupManagerError(`Group with id "${id}" already exists`);
66
+ }
67
+ if (initialMemberIds?.length) {
68
+ this.assertContactsExist(initialMemberIds, "createGroup");
69
+ }
70
+ const group = MajikContactGroup.create(id, name, meta, initialMemberIds);
71
+ this.groups.set(group.id, group);
72
+ if (initialMemberIds?.length) {
73
+ initialMemberIds.forEach((contactId) => this.indexAdd(contactId, group.id));
74
+ }
75
+ return group;
76
+ }
77
+ /**
78
+ * Registers an already-constructed MajikContactGroup instance.
79
+ * Useful when importing groups from external sources.
80
+ * Throws if a group with the same ID already exists.
81
+ */
82
+ addGroup(group) {
83
+ this.assertGroupInstance(group, "addGroup");
84
+ this.assertNotSystemId(group.id, "addGroup");
85
+ if (this.groups.has(group.id)) {
86
+ throw new MajikContactGroupManagerError(`Group with id "${group.id}" already exists`);
87
+ }
88
+ const memberIds = group.listMemberIds();
89
+ if (memberIds.length) {
90
+ this.assertContactsExist(memberIds, "addGroup");
91
+ }
92
+ this.groups.set(group.id, group);
93
+ memberIds.forEach((contactId) => this.indexAdd(contactId, group.id));
94
+ return this;
95
+ }
96
+ /**
97
+ * Removes a user group by ID.
98
+ * System groups cannot be deleted.
99
+ * Cleans up the reverse index for all former members.
100
+ */
101
+ removeGroup(id) {
102
+ this.assertGroupId(id);
103
+ this.assertNotSystemId(id, "removeGroup");
104
+ const group = this.groups.get(id);
105
+ if (!group) {
106
+ return { success: false, message: `Group "${id}" not found` };
107
+ }
108
+ const originalGroup = MajikContactGroup.fromJSON(group.toJSON());
109
+ group
110
+ .listMemberIds()
111
+ .forEach((contactId) => this.indexRemove(contactId, id));
112
+ this.groups.delete(id);
113
+ return {
114
+ success: true,
115
+ message: "Group removed successfully",
116
+ data: originalGroup,
117
+ };
118
+ }
119
+ getGroup(id) {
120
+ this.assertGroupId(id);
121
+ return this.groups.get(id);
122
+ }
123
+ getGroupOrThrow(id) {
124
+ const group = this.getGroup(id);
125
+ if (!group) {
126
+ throw new MajikContactGroupManagerError(`Group "${id}" not found`);
127
+ }
128
+ return group;
129
+ }
130
+ hasGroup(id) {
131
+ this.assertGroupId(id);
132
+ return this.groups.has(id);
133
+ }
134
+ /**
135
+ * Returns all groups, optionally filtered and/or sorted.
136
+ *
137
+ * @param includeSystem Include system groups (Favorites, Blocked). Default: true.
138
+ * @param sortedByName Sort results by group name. Default: false.
139
+ */
140
+ listGroups(includeSystem = true, sortedByName = false) {
141
+ let result = [...this.groups.values()];
142
+ if (!includeSystem) {
143
+ result = result.filter((g) => !g.isSystem);
144
+ }
145
+ if (sortedByName) {
146
+ result.sort((a, b) => a.meta.name.localeCompare(b.meta.name));
147
+ }
148
+ return result;
149
+ }
150
+ /**
151
+ * Updates mutable metadata fields on a group.
152
+ * Name is protected on system groups (delegates to MajikContactGroup.updateName which throws).
153
+ */
154
+ updateGroupMeta(id, meta) {
155
+ const group = this.getGroupOrThrow(id);
156
+ if (meta.name !== undefined)
157
+ group.updateName(meta.name);
158
+ if (meta.description !== undefined)
159
+ group.updateDescription(meta.description);
160
+ return group;
161
+ }
162
+ /* ================================
163
+ * Membership Management
164
+ * ================================ */
165
+ /**
166
+ * Adds a contact to a group.
167
+ *
168
+ * - Validates the contact exists in the directory.
169
+ * - If the target group is the system Blocked group, also calls contact.block()
170
+ * on the directory to keep MajikContact state in sync.
171
+ * - Throws if the contact is already a member (strict — use addMemberIfAbsent for idempotent).
172
+ */
173
+ addContactToGroup(groupId, contactId) {
174
+ this.assertGroupId(groupId);
175
+ this.assertContactId(contactId);
176
+ const group = this.getGroupOrThrow(groupId);
177
+ const contact = this.getContactOrThrow(contactId, "addContactToGroup");
178
+ group.addMember(contactId);
179
+ this.indexAdd(contactId, groupId);
180
+ // Blocked group sync
181
+ if (group.isBlocked() && !contact.isBlocked()) {
182
+ contact.block();
183
+ }
184
+ return group;
185
+ }
186
+ /**
187
+ * Idempotent variant — does not throw if the contact is already a member.
188
+ * Still validates contact existence and handles Blocked sync.
189
+ */
190
+ addContactToGroupIfAbsent(groupId, contactId) {
191
+ this.assertGroupId(groupId);
192
+ this.assertContactId(contactId);
193
+ const group = this.getGroupOrThrow(groupId);
194
+ const contact = this.getContactOrThrow(contactId, "addContactToGroupIfAbsent");
195
+ if (!group.hasMember(contactId)) {
196
+ group.addMember(contactId);
197
+ this.indexAdd(contactId, groupId);
198
+ if (group.isBlocked() && !contact.isBlocked()) {
199
+ contact.block();
200
+ }
201
+ }
202
+ return group;
203
+ }
204
+ /**
205
+ * Adds multiple contacts to a group in one call.
206
+ * All contacts are validated before any mutation is applied (all-or-nothing).
207
+ */
208
+ addContactsToGroup(groupId, contactIds) {
209
+ this.assertGroupId(groupId);
210
+ this.assertContactIdArray(contactIds, "addContactsToGroup");
211
+ const group = this.getGroupOrThrow(groupId);
212
+ // Validate all contacts exist before touching any state
213
+ const contacts = contactIds.map((id) => this.getContactOrThrow(id, "addContactsToGroup"));
214
+ // Check for existing membership up-front for a clean error
215
+ const alreadyMembers = contactIds.filter((id) => group.hasMember(id));
216
+ if (alreadyMembers.length > 0) {
217
+ throw new MajikContactGroupManagerError(`addContactsToGroup: the following contacts are already members of "${group.meta.name}": ${alreadyMembers.join(", ")}`);
218
+ }
219
+ contactIds.forEach((id, i) => {
220
+ group.addMember(id);
221
+ this.indexAdd(id, groupId);
222
+ if (group.isBlocked() && !contacts[i].isBlocked()) {
223
+ contacts[i].block();
224
+ }
225
+ });
226
+ return group;
227
+ }
228
+ /**
229
+ * Removes a contact from a group.
230
+ *
231
+ * - If removing from the system Blocked group, also calls contact.unblock()
232
+ * to keep MajikContact state in sync.
233
+ * - Throws if the contact is not a member (strict — use removeContactFromGroupIfPresent for idempotent).
234
+ */
235
+ removeContactFromGroup(groupId, contactId) {
236
+ this.assertGroupId(groupId);
237
+ this.assertContactId(contactId);
238
+ const group = this.getGroupOrThrow(groupId);
239
+ group.removeMember(contactId); // throws if not a member
240
+ this.indexRemove(contactId, groupId);
241
+ // Blocked group sync — unblock only if not blocked by a different group
242
+ if (group.isBlocked()) {
243
+ this.syncUnblock(contactId);
244
+ }
245
+ return group;
246
+ }
247
+ /**
248
+ * Idempotent variant — does not throw if the contact is not a member.
249
+ */
250
+ removeContactFromGroupIfPresent(groupId, contactId) {
251
+ this.assertGroupId(groupId);
252
+ this.assertContactId(contactId);
253
+ const group = this.getGroupOrThrow(groupId);
254
+ if (group.hasMember(contactId)) {
255
+ group.removeMember(contactId);
256
+ this.indexRemove(contactId, groupId);
257
+ if (group.isBlocked()) {
258
+ this.syncUnblock(contactId);
259
+ }
260
+ }
261
+ return group;
262
+ }
263
+ /**
264
+ * Moves a contact from one group to another atomically.
265
+ * Throws if the contact is not a member of the source group.
266
+ */
267
+ moveContact(contactId, fromGroupId, toGroupId) {
268
+ this.assertContactId(contactId);
269
+ this.assertGroupId(fromGroupId);
270
+ this.assertGroupId(toGroupId);
271
+ if (fromGroupId === toGroupId) {
272
+ throw new MajikContactGroupManagerError("moveContact: source and destination groups must be different");
273
+ }
274
+ this.getContactOrThrow(contactId, "moveContact");
275
+ // Validate both groups exist before touching state
276
+ const fromGroup = this.getGroupOrThrow(fromGroupId);
277
+ this.getGroupOrThrow(toGroupId);
278
+ if (!fromGroup.hasMember(contactId)) {
279
+ throw new MajikContactGroupManagerError(`moveContact: contact "${contactId}" is not a member of group "${fromGroupId}"`);
280
+ }
281
+ this.removeContactFromGroup(fromGroupId, contactId);
282
+ this.addContactToGroupIfAbsent(toGroupId, contactId);
283
+ }
284
+ /* ================================
285
+ * Querying
286
+ * ================================ */
287
+ /**
288
+ * Returns all group IDs the contact belongs to.
289
+ * O(1) — backed by the reverse index.
290
+ */
291
+ getGroupIdsForContact(contactId) {
292
+ this.assertContactId(contactId);
293
+ return [...(this.contactGroupIndex.get(contactId) ?? [])];
294
+ }
295
+ /**
296
+ * Returns all groups the contact belongs to as MajikContactGroup instances.
297
+ */
298
+ getGroupsForContact(contactId) {
299
+ this.assertContactId(contactId);
300
+ const groupIds = this.contactGroupIndex.get(contactId) ?? new Set();
301
+ return [...groupIds]
302
+ .map((id) => this.groups.get(id))
303
+ .filter((g) => g !== undefined);
304
+ }
305
+ /**
306
+ * Returns all hydrated MajikContact instances that are members of the given group.
307
+ * Contacts that exist in the group but have been removed from the directory are silently skipped.
308
+ */
309
+ getContactsInGroup(groupId) {
310
+ const group = this.getGroupOrThrow(groupId);
311
+ return group
312
+ .listMemberIds()
313
+ .map((id) => this.directory.getContact(id))
314
+ .filter((c) => c !== undefined);
315
+ }
316
+ /**
317
+ * Returns hydrated contacts that are members of the given group,
318
+ * sorted by their display label (or ID if no label set).
319
+ */
320
+ getContactsInGroupSorted(groupId) {
321
+ return this.getContactsInGroup(groupId).sort((a, b) => (a.meta.label || a.id).localeCompare(b.meta.label || b.id));
322
+ }
323
+ /**
324
+ * Returns true if the contact is a member of the given group.
325
+ */
326
+ isContactInGroup(groupId, contactId) {
327
+ this.assertGroupId(groupId);
328
+ this.assertContactId(contactId);
329
+ return this.groups.get(groupId)?.hasMember(contactId) ?? false;
330
+ }
331
+ /**
332
+ * Returns true if the contact is in the system Favorites group.
333
+ */
334
+ isFavorite(contactId) {
335
+ this.assertContactId(contactId);
336
+ return (this.groups.get(SYSTEM_GROUP_IDS.FAVORITES)?.hasMember(contactId) ?? false);
337
+ }
338
+ /**
339
+ * Returns true if the contact is in the system Blocked group.
340
+ */
341
+ isBlocked(contactId) {
342
+ this.assertContactId(contactId);
343
+ return (this.groups.get(SYSTEM_GROUP_IDS.BLOCKED)?.hasMember(contactId) ?? false);
344
+ }
345
+ /* ================================
346
+ * System Group Convenience Methods
347
+ * ================================ */
348
+ addToFavorites(contactId) {
349
+ return this.addContactToGroupIfAbsent(SYSTEM_GROUP_IDS.FAVORITES, contactId);
350
+ }
351
+ removeFromFavorites(contactId) {
352
+ return this.removeContactFromGroupIfPresent(SYSTEM_GROUP_IDS.FAVORITES, contactId);
353
+ }
354
+ /**
355
+ * Adds the contact to the Blocked group and calls contact.block() on the directory.
356
+ */
357
+ blockContact(contactId) {
358
+ return this.addContactToGroupIfAbsent(SYSTEM_GROUP_IDS.BLOCKED, contactId);
359
+ }
360
+ /**
361
+ * Removes the contact from the Blocked group.
362
+ * Calls contact.unblock() only if the contact is not re-blocked by any other mechanism.
363
+ */
364
+ unblockContact(contactId) {
365
+ return this.removeContactFromGroupIfPresent(SYSTEM_GROUP_IDS.BLOCKED, contactId);
366
+ }
367
+ getFavoritesGroup() {
368
+ return this.getGroupOrThrow(SYSTEM_GROUP_IDS.FAVORITES);
369
+ }
370
+ getBlockedGroup() {
371
+ return this.getGroupOrThrow(SYSTEM_GROUP_IDS.BLOCKED);
372
+ }
373
+ /* ================================
374
+ * Directory Sync Hook
375
+ * ================================ */
376
+ /**
377
+ * Must be called whenever a contact is removed from MajikContactDirectory.
378
+ * Auto-removes the contact from every group it belongs to and cleans up the reverse index.
379
+ *
380
+ * Usage:
381
+ * const response = directory.removeContact(id);
382
+ * if (response.success) manager.handleContactRemoved(id);
383
+ */
384
+ handleContactRemoved(contactId) {
385
+ this.assertContactId(contactId);
386
+ const groupIds = this.contactGroupIndex.get(contactId);
387
+ if (!groupIds || groupIds.size === 0) {
388
+ this.contactGroupIndex.delete(contactId);
389
+ return;
390
+ }
391
+ for (const groupId of [...groupIds]) {
392
+ const group = this.groups.get(groupId);
393
+ if (group?.hasMember(contactId)) {
394
+ group.removeMemberIfPresent(contactId);
395
+ }
396
+ }
397
+ this.contactGroupIndex.delete(contactId);
398
+ }
399
+ /* ================================
400
+ * Serialization / Persistence
401
+ * ================================ */
402
+ toJSON() {
403
+ return {
404
+ groups: [...this.groups.values()].map((g) => g.toJSON()),
405
+ };
406
+ }
407
+ /**
408
+ * Restores all groups from serialized data.
409
+ * Clears existing user groups but preserves system groups (they are re-bootstrapped).
410
+ * Rebuilds the reverse index from scratch.
411
+ */
412
+ fromJSON(data) {
413
+ if (!data || !Array.isArray(data.groups)) {
414
+ throw new MajikContactGroupManagerError("fromJSON: invalid serialized data — expected { groups: [...] }");
415
+ }
416
+ // Clear only user groups; system groups are re-bootstrapped below
417
+ for (const [id] of this.groups) {
418
+ if (!isSystemGroupId(id))
419
+ this.groups.delete(id);
420
+ }
421
+ this.contactGroupIndex.clear();
422
+ // Re-bootstrap so system groups are always present
423
+ this.bootstrapSystemGroups();
424
+ for (const serialized of data.groups) {
425
+ if (!serialized || typeof serialized !== "object" || !serialized.id) {
426
+ throw new MajikContactGroupManagerError("fromJSON: encountered an invalid serialized group entry");
427
+ }
428
+ try {
429
+ const group = MajikContactGroup.fromJSON(serialized);
430
+ // System groups are already bootstrapped — just restore their membership
431
+ if (isSystemGroupId(group.id)) {
432
+ const existing = this.groups.get(group.id);
433
+ existing.clearMembers();
434
+ group.listMemberIds().forEach((cId) => {
435
+ existing.addMemberIfAbsent(cId);
436
+ this.indexAdd(cId, group.id);
437
+ });
438
+ }
439
+ else {
440
+ if (this.groups.has(group.id)) {
441
+ throw new MajikContactGroupManagerError(`fromJSON: duplicate group id "${group.id}"`);
442
+ }
443
+ this.groups.set(group.id, group);
444
+ group.listMemberIds().forEach((cId) => this.indexAdd(cId, group.id));
445
+ }
446
+ }
447
+ catch (err) {
448
+ if (err instanceof MajikContactGroupManagerError)
449
+ throw err;
450
+ throw new MajikContactGroupManagerError(`fromJSON: failed to restore group "${serialized.id}"`, err);
451
+ }
452
+ }
453
+ return this;
454
+ }
455
+ /* ================================
456
+ * Reverse Index Helpers
457
+ * ================================ */
458
+ indexAdd(contactId, groupId) {
459
+ if (!this.contactGroupIndex.has(contactId)) {
460
+ this.contactGroupIndex.set(contactId, new Set());
461
+ }
462
+ this.contactGroupIndex.get(contactId).add(groupId);
463
+ }
464
+ indexRemove(contactId, groupId) {
465
+ const groupSet = this.contactGroupIndex.get(contactId);
466
+ if (!groupSet)
467
+ return;
468
+ groupSet.delete(groupId);
469
+ if (groupSet.size === 0) {
470
+ this.contactGroupIndex.delete(contactId);
471
+ }
472
+ }
473
+ /* ================================
474
+ * Blocked Sync Helper
475
+ * ================================ */
476
+ /**
477
+ * Unblocks a contact on the directory only if the contact is no longer
478
+ * a member of the system Blocked group. Guards against a race where
479
+ * the contact was re-added before unblock is processed.
480
+ */
481
+ syncUnblock(contactId) {
482
+ const blockedGroup = this.groups.get(SYSTEM_GROUP_IDS.BLOCKED);
483
+ const stillBlocked = blockedGroup?.hasMember(contactId) ?? false;
484
+ if (!stillBlocked) {
485
+ const contact = this.directory.getContact(contactId);
486
+ if (contact?.isBlocked()) {
487
+ contact.unblock();
488
+ }
489
+ }
490
+ }
491
+ /* ================================
492
+ * Assertions / Validation
493
+ * ================================ */
494
+ assertDirectory(directory) {
495
+ if (!directory || !(directory instanceof MajikContactDirectory)) {
496
+ throw new MajikContactGroupManagerError("MajikContactGroupManager requires a valid MajikContactDirectory instance");
497
+ }
498
+ }
499
+ assertGroupId(id) {
500
+ if (!id || typeof id !== "string" || id.trim().length === 0) {
501
+ throw new MajikContactGroupManagerError("Group ID must be a non-empty string");
502
+ }
503
+ }
504
+ assertContactId(id) {
505
+ if (!id || typeof id !== "string" || id.trim().length === 0) {
506
+ throw new MajikContactGroupManagerError("Contact ID must be a non-empty string");
507
+ }
508
+ }
509
+ assertContactIdArray(ids, caller) {
510
+ if (!Array.isArray(ids)) {
511
+ throw new MajikContactGroupManagerError(`${caller}: contactIds must be an array`);
512
+ }
513
+ if (ids.length === 0) {
514
+ throw new MajikContactGroupManagerError(`${caller}: contactIds array must not be empty`);
515
+ }
516
+ ids.forEach((id, index) => {
517
+ if (!id || typeof id !== "string" || id.trim().length === 0) {
518
+ throw new MajikContactGroupManagerError(`${caller}: invalid contact ID at index ${index}`);
519
+ }
520
+ });
521
+ const unique = new Set(ids);
522
+ if (unique.size !== ids.length) {
523
+ throw new MajikContactGroupManagerError(`${caller}: contactIds must not contain duplicates`);
524
+ }
525
+ }
526
+ assertGroupInstance(group, caller) {
527
+ if (!group || !(group instanceof MajikContactGroup)) {
528
+ throw new MajikContactGroupManagerError(`${caller}: expected a MajikContactGroup instance`);
529
+ }
530
+ }
531
+ assertNotSystemId(id, caller) {
532
+ if (isSystemGroupId(id)) {
533
+ throw new MajikContactGroupManagerError(`${caller}: cannot perform this operation on system group "${id}"`);
534
+ }
535
+ }
536
+ /**
537
+ * Resolves a contact from the directory and throws a descriptive error if not found.
538
+ */
539
+ getContactOrThrow(contactId, caller) {
540
+ const contact = this.directory.getContact(contactId);
541
+ if (!contact) {
542
+ throw new MajikContactGroupManagerError(`${caller}: contact "${contactId}" does not exist in the directory`);
543
+ }
544
+ return contact;
545
+ }
546
+ /**
547
+ * Validates that all provided contact IDs exist in the directory.
548
+ * Collects all missing IDs and throws once with the full list,
549
+ * rather than failing on the first missing contact.
550
+ */
551
+ assertContactsExist(contactIds, caller) {
552
+ const missing = contactIds.filter((id) => !this.directory.hasContact(id));
553
+ if (missing.length > 0) {
554
+ throw new MajikContactGroupManagerError(`${caller}: the following contacts do not exist in the directory: ${missing.join(", ")}`);
555
+ }
556
+ }
557
+ }