@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.
- package/dist/core/contacts/errors.d.ts +12 -0
- package/dist/core/contacts/errors.js +27 -0
- package/dist/core/contacts/majik-contact-directory.d.ts +2 -8
- package/dist/core/contacts/majik-contact-directory.js +1 -11
- package/dist/core/contacts/majik-contact-groups.d.ts +185 -0
- package/dist/core/contacts/majik-contact-groups.js +557 -0
- package/dist/core/contacts/majik-contact-manager.d.ts +240 -0
- package/dist/core/contacts/majik-contact-manager.js +449 -0
- package/dist/core/contacts/majik-contact-migration.d.ts +27 -0
- package/dist/core/contacts/majik-contact-migration.js +84 -0
- package/dist/core/contacts/types.d.ts +11 -0
- package/dist/core/contacts/types.js +4 -0
- package/dist/majik-message.d.ts +162 -37
- package/dist/majik-message.js +292 -127
- package/package.json +4 -4
|
@@ -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
|
+
}
|