@thezelijah/majik-message 1.1.1 → 1.1.3
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/crypto/constants.d.ts +1 -0
- package/dist/core/crypto/constants.js +1 -0
- package/dist/core/crypto/crypto-provider.d.ts +1 -0
- package/dist/core/crypto/crypto-provider.js +4 -1
- package/dist/core/crypto/keystore.d.ts +2 -0
- package/dist/core/crypto/keystore.js +16 -3
- package/dist/core/database/thread/enums.d.ts +7 -0
- package/dist/core/database/thread/enums.js +6 -0
- package/dist/core/database/thread/mail/majik-message-mail.d.ts +177 -0
- package/dist/core/database/thread/mail/majik-message-mail.js +704 -0
- package/dist/core/database/thread/majik-message-thread.d.ts +166 -0
- package/dist/core/database/thread/majik-message-thread.js +637 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/majik-message.js +23 -21
- package/package.json +6 -1
- package/dist/core/database/system/majik-user/enums.d.ts +0 -44
- package/dist/core/database/system/majik-user/enums.js +0 -40
- package/dist/core/database/system/majik-user/majik-user.d.ts +0 -261
- package/dist/core/database/system/majik-user/majik-user.js +0 -839
- package/dist/core/database/system/majik-user/types.d.ts +0 -186
- package/dist/core/database/system/majik-user/types.js +0 -1
- package/dist/core/database/system/majik-user/utils.d.ts +0 -32
- package/dist/core/database/system/majik-user/utils.js +0 -110
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
import { ThreadStatus } from "./enums";
|
|
3
|
+
import { sha256 } from "../../crypto/crypto-provider";
|
|
4
|
+
// ==================== Custom Errors ====================
|
|
5
|
+
export class MajikThreadError extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(message, code) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.name = "MajikThreadError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class ValidationError extends MajikThreadError {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message, "VALIDATION_ERROR");
|
|
16
|
+
this.name = "ValidationError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export class OperationNotAllowedError extends MajikThreadError {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message, "OPERATION_NOT_ALLOWED");
|
|
22
|
+
this.name = "OperationNotAllowedError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// ==================== Main Class ====================
|
|
26
|
+
export class MajikMessageThread {
|
|
27
|
+
_id;
|
|
28
|
+
_userID;
|
|
29
|
+
_owner; // Owner's identity account ID
|
|
30
|
+
_metadata;
|
|
31
|
+
_timestamp;
|
|
32
|
+
_participants;
|
|
33
|
+
_status;
|
|
34
|
+
_hash;
|
|
35
|
+
_deletionApprovals;
|
|
36
|
+
_starred;
|
|
37
|
+
// ==================== Private Constructor ====================
|
|
38
|
+
constructor(id, userID, owner, metadata, timestamp, participants, status, hash, deletionApprovals = [], starred = false) {
|
|
39
|
+
this._id = id;
|
|
40
|
+
this._userID = userID;
|
|
41
|
+
this._owner = owner;
|
|
42
|
+
this._metadata = metadata;
|
|
43
|
+
this._timestamp = timestamp;
|
|
44
|
+
this._participants = participants;
|
|
45
|
+
this._status = status;
|
|
46
|
+
this._hash = hash;
|
|
47
|
+
this._deletionApprovals = deletionApprovals;
|
|
48
|
+
this._starred = starred;
|
|
49
|
+
// Validate on construction
|
|
50
|
+
this.validate();
|
|
51
|
+
}
|
|
52
|
+
// ==================== Getters ====================
|
|
53
|
+
get id() {
|
|
54
|
+
return this._id;
|
|
55
|
+
}
|
|
56
|
+
get userID() {
|
|
57
|
+
return this._userID;
|
|
58
|
+
}
|
|
59
|
+
get owner() {
|
|
60
|
+
return this._owner;
|
|
61
|
+
}
|
|
62
|
+
get metadata() {
|
|
63
|
+
return { ...this._metadata };
|
|
64
|
+
}
|
|
65
|
+
get timestamp() {
|
|
66
|
+
return new Date(this._timestamp);
|
|
67
|
+
}
|
|
68
|
+
get participants() {
|
|
69
|
+
return [...this._participants];
|
|
70
|
+
}
|
|
71
|
+
get status() {
|
|
72
|
+
return this._status;
|
|
73
|
+
}
|
|
74
|
+
get hash() {
|
|
75
|
+
return this._hash;
|
|
76
|
+
}
|
|
77
|
+
get deletionApprovals() {
|
|
78
|
+
return [...this._deletionApprovals];
|
|
79
|
+
}
|
|
80
|
+
get starred() {
|
|
81
|
+
return this._starred;
|
|
82
|
+
}
|
|
83
|
+
// ==================== Static Create Method ====================
|
|
84
|
+
static create(userID, owner, participants, metadata = {}) {
|
|
85
|
+
try {
|
|
86
|
+
// Validate inputs
|
|
87
|
+
if (!userID || typeof userID !== "string" || userID.trim().length === 0) {
|
|
88
|
+
throw new ValidationError("userID is required and must be a non-empty string");
|
|
89
|
+
}
|
|
90
|
+
if (!Array.isArray(participants) || participants.length === 0) {
|
|
91
|
+
throw new ValidationError("participants must be a non-empty array");
|
|
92
|
+
}
|
|
93
|
+
// Normalize participants (deduplicate + sort)
|
|
94
|
+
const uniqueParticipants = MajikMessageThread.normalizeParticipants([
|
|
95
|
+
owner.publicKey,
|
|
96
|
+
...participants,
|
|
97
|
+
]);
|
|
98
|
+
// Validate all participants
|
|
99
|
+
for (const participant of uniqueParticipants) {
|
|
100
|
+
if (!participant ||
|
|
101
|
+
typeof participant !== "string" ||
|
|
102
|
+
participant.trim().length === 0) {
|
|
103
|
+
throw new ValidationError("All participants must be non-empty strings");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const id = uuidv4();
|
|
107
|
+
const timestamp = new Date();
|
|
108
|
+
const status = ThreadStatus.ONGOING;
|
|
109
|
+
// Generate hash
|
|
110
|
+
const hash = MajikMessageThread.generateHash(userID, timestamp, id, uniqueParticipants);
|
|
111
|
+
return new MajikMessageThread(id, userID, owner.id, metadata, timestamp, uniqueParticipants, status, hash, []);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof MajikThreadError) {
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
throw new MajikThreadError(`Failed to create MajikMessageThread: ${error instanceof Error ? error.message : "Unknown error"}`, "CREATE_FAILED");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// ==================== Star Management ====================
|
|
121
|
+
/**
|
|
122
|
+
* Stars the thread for the user
|
|
123
|
+
*/
|
|
124
|
+
star() {
|
|
125
|
+
try {
|
|
126
|
+
if (this._starred) {
|
|
127
|
+
throw new OperationNotAllowedError("Thread is already starred");
|
|
128
|
+
}
|
|
129
|
+
this._starred = true;
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof MajikThreadError) {
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
throw new MajikThreadError(`Failed to star thread: ${error instanceof Error ? error.message : "Unknown error"}`, "STAR_FAILED");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Unstars the thread for the user
|
|
140
|
+
*/
|
|
141
|
+
unstar() {
|
|
142
|
+
try {
|
|
143
|
+
if (!this._starred) {
|
|
144
|
+
throw new OperationNotAllowedError("Thread is not starred");
|
|
145
|
+
}
|
|
146
|
+
this._starred = false;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error instanceof MajikThreadError) {
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
throw new MajikThreadError(`Failed to unstar thread: ${error instanceof Error ? error.message : "Unknown error"}`, "UNSTAR_FAILED");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Toggles the starred status of the thread
|
|
157
|
+
* @returns The new starred state
|
|
158
|
+
*/
|
|
159
|
+
toggleStar() {
|
|
160
|
+
if (this._starred) {
|
|
161
|
+
this.unstar();
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
this.star();
|
|
165
|
+
}
|
|
166
|
+
return this._starred;
|
|
167
|
+
}
|
|
168
|
+
// ==================== Hash Generation ====================
|
|
169
|
+
static generateHash(userID, timestamp, id, participants) {
|
|
170
|
+
// Normalize participants (they should already be normalized, but ensure consistency)
|
|
171
|
+
const normalized = MajikMessageThread.normalizeParticipants(participants);
|
|
172
|
+
// Join with delimiter
|
|
173
|
+
const combined = normalized.join("|");
|
|
174
|
+
const dataString = `${userID}:${timestamp.toISOString()}:${id}:${combined}`;
|
|
175
|
+
return sha256(dataString);
|
|
176
|
+
}
|
|
177
|
+
static generateApprovalHash(publicKey, threadID, timestamp) {
|
|
178
|
+
const dataString = `${publicKey}:${threadID}:${timestamp.toISOString()}`;
|
|
179
|
+
return sha256(dataString);
|
|
180
|
+
}
|
|
181
|
+
// ==================== Validation ====================
|
|
182
|
+
validate() {
|
|
183
|
+
try {
|
|
184
|
+
// Validate ID
|
|
185
|
+
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
186
|
+
if (!uuidRegex.test(this._id)) {
|
|
187
|
+
throw new ValidationError("Invalid UUID v4 format for id");
|
|
188
|
+
}
|
|
189
|
+
// Validate userID
|
|
190
|
+
if (!this._userID ||
|
|
191
|
+
typeof this._userID !== "string" ||
|
|
192
|
+
this._userID.trim().length === 0) {
|
|
193
|
+
throw new ValidationError("userID is required and must be a non-empty string");
|
|
194
|
+
}
|
|
195
|
+
// Validate owner public key
|
|
196
|
+
if (!this._owner ||
|
|
197
|
+
typeof this._owner !== "string" ||
|
|
198
|
+
this._owner.trim().length === 0) {
|
|
199
|
+
throw new ValidationError("owner public key is required and must be a non-empty string");
|
|
200
|
+
}
|
|
201
|
+
// Validate participants
|
|
202
|
+
if (!Array.isArray(this._participants) ||
|
|
203
|
+
this._participants.length === 0) {
|
|
204
|
+
throw new ValidationError("participants must be a non-empty array");
|
|
205
|
+
}
|
|
206
|
+
// Check if owner's public key is in participants
|
|
207
|
+
if (!this._participants.includes(this._owner)) {
|
|
208
|
+
throw new ValidationError("Owner public key must be included in participants");
|
|
209
|
+
}
|
|
210
|
+
// Validate timestamp
|
|
211
|
+
if (!(this._timestamp instanceof Date) ||
|
|
212
|
+
isNaN(this._timestamp.getTime())) {
|
|
213
|
+
throw new ValidationError("timestamp must be a valid Date object");
|
|
214
|
+
}
|
|
215
|
+
// Validate status
|
|
216
|
+
if (!Object.values(ThreadStatus).includes(this._status)) {
|
|
217
|
+
throw new ValidationError(`Invalid status: ${this._status}`);
|
|
218
|
+
}
|
|
219
|
+
// Validate hash
|
|
220
|
+
const expectedHash = MajikMessageThread.generateHash(this._userID, this._timestamp, this._id, this._participants);
|
|
221
|
+
if (this._hash !== expectedHash) {
|
|
222
|
+
throw new ValidationError("Hash mismatch - data integrity compromised");
|
|
223
|
+
}
|
|
224
|
+
// Validate deletion approvals
|
|
225
|
+
if (this._deletionApprovals.length > 0) {
|
|
226
|
+
for (const approval of this._deletionApprovals) {
|
|
227
|
+
// Check participant validity
|
|
228
|
+
if (!this._participants.includes(approval.publicKey)) {
|
|
229
|
+
throw new ValidationError(`Deletion approval from non-participant: ${approval.publicKey}`);
|
|
230
|
+
}
|
|
231
|
+
// Verify approval hash
|
|
232
|
+
const expectedHash = MajikMessageThread.generateApprovalHash(approval.publicKey, this._id, approval.timestamp);
|
|
233
|
+
if (approval.approvalHash !== expectedHash) {
|
|
234
|
+
throw new ValidationError(`Invalid approval hash for participant: ${approval.publicKey}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Check for duplicate approvals
|
|
238
|
+
const approvedKeys = this._deletionApprovals.map((approval) => approval.publicKey);
|
|
239
|
+
const uniqueKeys = new Set(approvedKeys);
|
|
240
|
+
if (approvedKeys.length !== uniqueKeys.size) {
|
|
241
|
+
throw new ValidationError("Duplicate deletion approvals detected");
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (error instanceof ValidationError) {
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
throw new ValidationError(`Validation failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// ==================== Status Management ====================
|
|
254
|
+
close() {
|
|
255
|
+
try {
|
|
256
|
+
if (this._status === ThreadStatus.CLOSED) {
|
|
257
|
+
throw new OperationNotAllowedError("Thread is already closed");
|
|
258
|
+
}
|
|
259
|
+
if (this._status === ThreadStatus.PENDING_DELETION ||
|
|
260
|
+
this._status === ThreadStatus.MARKED_FOR_DELETION) {
|
|
261
|
+
throw new OperationNotAllowedError("Cannot close a thread pending deletion");
|
|
262
|
+
}
|
|
263
|
+
this._status = ThreadStatus.CLOSED;
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
if (error instanceof MajikThreadError) {
|
|
267
|
+
throw error;
|
|
268
|
+
}
|
|
269
|
+
throw new MajikThreadError(`Failed to close thread: ${error instanceof Error ? error.message : "Unknown error"}`, "CLOSE_FAILED");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// ==================== Deletion Approval System ====================
|
|
273
|
+
requestDeletion(publicKey) {
|
|
274
|
+
try {
|
|
275
|
+
// Validate public key is a participant
|
|
276
|
+
if (!this._participants.includes(publicKey)) {
|
|
277
|
+
throw new OperationNotAllowedError("Only participants can request thread deletion");
|
|
278
|
+
}
|
|
279
|
+
// Don't allow deletion requests on closed threads
|
|
280
|
+
if (this._status === ThreadStatus.CLOSED) {
|
|
281
|
+
throw new OperationNotAllowedError("Cannot request deletion of a closed thread");
|
|
282
|
+
}
|
|
283
|
+
// Check if already approved
|
|
284
|
+
const existingApproval = this._deletionApprovals.find((approval) => approval.publicKey === publicKey);
|
|
285
|
+
if (existingApproval) {
|
|
286
|
+
throw new OperationNotAllowedError("This participant has already approved deletion");
|
|
287
|
+
}
|
|
288
|
+
// Create approval
|
|
289
|
+
const timestamp = new Date();
|
|
290
|
+
const approvalHash = MajikMessageThread.generateApprovalHash(publicKey, this._id, timestamp);
|
|
291
|
+
const approval = {
|
|
292
|
+
publicKey,
|
|
293
|
+
approvalHash,
|
|
294
|
+
timestamp,
|
|
295
|
+
};
|
|
296
|
+
this._deletionApprovals.push(approval);
|
|
297
|
+
// Update status
|
|
298
|
+
this.updateDeletionStatus();
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
if (error instanceof MajikThreadError) {
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
throw new MajikThreadError(`Failed to request deletion: ${error instanceof Error ? error.message : "Unknown error"}`, "DELETION_REQUEST_FAILED");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
updateDeletionStatus() {
|
|
308
|
+
if (this._deletionApprovals.length === 0) {
|
|
309
|
+
// No approvals - revert to non-deletion status
|
|
310
|
+
// Don't change status if already ONGOING or CLOSED
|
|
311
|
+
if (this._status === ThreadStatus.PENDING_DELETION ||
|
|
312
|
+
this._status === ThreadStatus.MARKED_FOR_DELETION) {
|
|
313
|
+
// Default to ONGOING when all approvals are revoked
|
|
314
|
+
this._status = ThreadStatus.ONGOING;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
else if (this._deletionApprovals.length === this._participants.length) {
|
|
318
|
+
// All participants approved
|
|
319
|
+
this._status = ThreadStatus.MARKED_FOR_DELETION;
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Partial approvals (at least 1, but not all)
|
|
323
|
+
this._status = ThreadStatus.PENDING_DELETION;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
revokeDeletionRequest(publicKey) {
|
|
327
|
+
try {
|
|
328
|
+
const approvalIndex = this._deletionApprovals.findIndex((approval) => approval.publicKey === publicKey);
|
|
329
|
+
if (approvalIndex === -1) {
|
|
330
|
+
throw new OperationNotAllowedError("No deletion approval found for this participant");
|
|
331
|
+
}
|
|
332
|
+
this._deletionApprovals.splice(approvalIndex, 1);
|
|
333
|
+
this.updateDeletionStatus();
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
if (error instanceof MajikThreadError) {
|
|
337
|
+
throw error;
|
|
338
|
+
}
|
|
339
|
+
throw new MajikThreadError(`Failed to revoke deletion request: ${error instanceof Error ? error.message : "Unknown error"}`, "REVOKE_DELETION_FAILED");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
canBeDeleted() {
|
|
343
|
+
// First check if status allows deletion
|
|
344
|
+
if (this._status !== ThreadStatus.MARKED_FOR_DELETION) {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
// Verify all participants have approved
|
|
348
|
+
if (this._deletionApprovals.length !== this._participants.length) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
// Verify each approval hash
|
|
352
|
+
return this.verifyDeletionApprovals();
|
|
353
|
+
}
|
|
354
|
+
getDeletionProgress() {
|
|
355
|
+
return {
|
|
356
|
+
approved: this._deletionApprovals.length,
|
|
357
|
+
total: this._participants.length,
|
|
358
|
+
percentage: (this._deletionApprovals.length / this._participants.length) * 100,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Verifies that all deletion approvals have valid hashes and all participants have approved
|
|
363
|
+
* @returns true if all approvals are valid and complete, false otherwise
|
|
364
|
+
*/
|
|
365
|
+
verifyDeletionApprovals() {
|
|
366
|
+
try {
|
|
367
|
+
// Check if we have approvals from all participants
|
|
368
|
+
const approvedKeys = new Set(this._deletionApprovals.map((approval) => approval.publicKey));
|
|
369
|
+
// Verify all participants have approved
|
|
370
|
+
for (const participant of this._participants) {
|
|
371
|
+
if (!approvedKeys.has(participant)) {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Verify each approval has a valid hash
|
|
376
|
+
for (const approval of this._deletionApprovals) {
|
|
377
|
+
const expectedHash = MajikMessageThread.generateApprovalHash(approval.publicKey, this._id, approval.timestamp);
|
|
378
|
+
if (approval.approvalHash !== expectedHash) {
|
|
379
|
+
// Hash mismatch - approval is invalid
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
// Ensure the public key is actually a participant
|
|
383
|
+
if (!this._participants.includes(approval.publicKey)) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// Check for duplicate approvals
|
|
388
|
+
if (approvedKeys.size !== this._deletionApprovals.length) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
// If any error occurs during verification, fail safely
|
|
395
|
+
return false;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Get detailed verification status of deletion approvals
|
|
400
|
+
* @returns Detailed information about approval validity
|
|
401
|
+
*/
|
|
402
|
+
getDeletionApprovalStatus() {
|
|
403
|
+
const approvedKeys = this._deletionApprovals.map((approval) => approval.publicKey);
|
|
404
|
+
const approvedSet = new Set(approvedKeys);
|
|
405
|
+
const invalidApprovals = [];
|
|
406
|
+
const missingApprovals = [];
|
|
407
|
+
const duplicateApprovals = [];
|
|
408
|
+
// Check for duplicates
|
|
409
|
+
approvedKeys.forEach((key, index) => {
|
|
410
|
+
if (approvedKeys.indexOf(key) !== index) {
|
|
411
|
+
if (!duplicateApprovals.includes(key)) {
|
|
412
|
+
duplicateApprovals.push(key);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
// Check for missing participants
|
|
417
|
+
for (const participant of this._participants) {
|
|
418
|
+
if (!approvedSet.has(participant)) {
|
|
419
|
+
missingApprovals.push(participant);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Verify each approval hash
|
|
423
|
+
for (const approval of this._deletionApprovals) {
|
|
424
|
+
const expectedHash = MajikMessageThread.generateApprovalHash(approval.publicKey, this._id, approval.timestamp);
|
|
425
|
+
if (approval.approvalHash !== expectedHash) {
|
|
426
|
+
invalidApprovals.push(approval.publicKey);
|
|
427
|
+
}
|
|
428
|
+
// Check if approval is from non-participant
|
|
429
|
+
if (!this._participants.includes(approval.publicKey)) {
|
|
430
|
+
if (!invalidApprovals.includes(approval.publicKey)) {
|
|
431
|
+
invalidApprovals.push(approval.publicKey);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const allParticipantsApproved = missingApprovals.length === 0;
|
|
436
|
+
const isValid = allParticipantsApproved &&
|
|
437
|
+
invalidApprovals.length === 0 &&
|
|
438
|
+
duplicateApprovals.length === 0;
|
|
439
|
+
return {
|
|
440
|
+
isValid,
|
|
441
|
+
allParticipantsApproved,
|
|
442
|
+
invalidApprovals,
|
|
443
|
+
missingApprovals,
|
|
444
|
+
duplicateApprovals,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
// ==================== Metadata Management ====================
|
|
448
|
+
updateMetadata(metadata) {
|
|
449
|
+
try {
|
|
450
|
+
if (this._status === ThreadStatus.MARKED_FOR_DELETION) {
|
|
451
|
+
throw new OperationNotAllowedError("Cannot update metadata of a thread marked for deletion");
|
|
452
|
+
}
|
|
453
|
+
this._metadata = {
|
|
454
|
+
...this._metadata,
|
|
455
|
+
...metadata,
|
|
456
|
+
lastActivity: new Date().toISOString(),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (error instanceof MajikThreadError) {
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
throw new MajikThreadError(`Failed to update metadata: ${error instanceof Error ? error.message : "Unknown error"}`, "METADATA_UPDATE_FAILED");
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// ==================== Serialization ====================
|
|
467
|
+
toJSON() {
|
|
468
|
+
return {
|
|
469
|
+
id: this._id,
|
|
470
|
+
user_id: this._userID,
|
|
471
|
+
owner: this._owner,
|
|
472
|
+
metadata: { ...this._metadata },
|
|
473
|
+
timestamp: this._timestamp.toISOString(),
|
|
474
|
+
participants: [...this._participants],
|
|
475
|
+
status: this._status,
|
|
476
|
+
hash: this._hash,
|
|
477
|
+
deletion_approvals: this._deletionApprovals.length > 0
|
|
478
|
+
? this._deletionApprovals.map((approval) => ({
|
|
479
|
+
...approval,
|
|
480
|
+
timestamp: approval.timestamp,
|
|
481
|
+
}))
|
|
482
|
+
: [],
|
|
483
|
+
starred: this._starred,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
static fromJSON(json) {
|
|
487
|
+
try {
|
|
488
|
+
const data = typeof json === "string" ? JSON.parse(json) : json;
|
|
489
|
+
// Parse timestamp
|
|
490
|
+
const timestamp = new Date(data.timestamp);
|
|
491
|
+
if (isNaN(timestamp.getTime())) {
|
|
492
|
+
throw new ValidationError("Invalid timestamp in JSON data");
|
|
493
|
+
}
|
|
494
|
+
// Parse deletion approvals
|
|
495
|
+
const deletionApprovals = (data.deletion_approvals || []).map((approval) => ({
|
|
496
|
+
...approval,
|
|
497
|
+
timestamp: new Date(approval.timestamp),
|
|
498
|
+
}));
|
|
499
|
+
return new MajikMessageThread(data.id, data.user_id, data.owner, data.metadata, timestamp, data.participants, data.status, data.hash, deletionApprovals, data.starred);
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
if (error instanceof MajikThreadError) {
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
throw new MajikThreadError(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`, "JSON_PARSE_FAILED");
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// ==================== Utility Methods ====================
|
|
509
|
+
isOwner(publicKey) {
|
|
510
|
+
return this._owner === publicKey;
|
|
511
|
+
}
|
|
512
|
+
isParticipant(publicKey) {
|
|
513
|
+
return this._participants.includes(publicKey);
|
|
514
|
+
}
|
|
515
|
+
toString() {
|
|
516
|
+
return JSON.stringify(this.toJSON(), null, 2);
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Deduplicates and sorts participants to ensure consistent ordering
|
|
520
|
+
*/
|
|
521
|
+
static normalizeParticipants(participants) {
|
|
522
|
+
const participantsSet = new Set();
|
|
523
|
+
participants.forEach((p) => participantsSet.add(p));
|
|
524
|
+
return Array.from(participantsSet).sort();
|
|
525
|
+
}
|
|
526
|
+
// ==================== Final Export Method ====================
|
|
527
|
+
/**
|
|
528
|
+
* Exports the thread with finalized metadata for analytics and archival purposes.
|
|
529
|
+
* This method updates metadata with actual counts and stats, sets lastActivity,
|
|
530
|
+
* and optionally closes the thread if no deletion is pending.
|
|
531
|
+
*
|
|
532
|
+
* @param messageCount - The actual number of messages in this thread
|
|
533
|
+
* @param additionalTags - Optional tags to add to existing tags
|
|
534
|
+
* @param autoClose - Whether to automatically close the thread (default: true)
|
|
535
|
+
* @returns MajikMessageThreadJSON with updated metadata
|
|
536
|
+
*/
|
|
537
|
+
exportFinalStats(messageCount, additionalTags, autoClose = true) {
|
|
538
|
+
try {
|
|
539
|
+
// Validate message count
|
|
540
|
+
if (typeof messageCount !== "number" ||
|
|
541
|
+
messageCount < 0 ||
|
|
542
|
+
!Number.isInteger(messageCount)) {
|
|
543
|
+
throw new ValidationError("messageCount must be a non-negative integer");
|
|
544
|
+
}
|
|
545
|
+
// Cannot finalize a thread marked for deletion
|
|
546
|
+
if (this._status === ThreadStatus.MARKED_FOR_DELETION) {
|
|
547
|
+
throw new OperationNotAllowedError("Cannot export final stats for a thread marked for deletion");
|
|
548
|
+
}
|
|
549
|
+
// Merge tags
|
|
550
|
+
const existingTags = this._metadata.tags || [];
|
|
551
|
+
const mergedTags = additionalTags
|
|
552
|
+
? Array.from(new Set([...existingTags, ...additionalTags]))
|
|
553
|
+
: existingTags;
|
|
554
|
+
// Update metadata with final stats
|
|
555
|
+
const finalMetadata = {
|
|
556
|
+
...this._metadata,
|
|
557
|
+
messageCount,
|
|
558
|
+
lastActivity: new Date().toISOString(),
|
|
559
|
+
tags: mergedTags.length > 0 ? mergedTags : undefined,
|
|
560
|
+
};
|
|
561
|
+
// Determine final status
|
|
562
|
+
let finalStatus = this._status;
|
|
563
|
+
// Auto-close if requested and thread is not in deletion state
|
|
564
|
+
if (autoClose && this._status === ThreadStatus.ONGOING) {
|
|
565
|
+
finalStatus = ThreadStatus.CLOSED;
|
|
566
|
+
}
|
|
567
|
+
// If status is PENDING_DELETION, keep it as is
|
|
568
|
+
if (this._status === ThreadStatus.PENDING_DELETION) {
|
|
569
|
+
finalStatus = ThreadStatus.PENDING_DELETION;
|
|
570
|
+
}
|
|
571
|
+
// Create the export object
|
|
572
|
+
const exportData = {
|
|
573
|
+
id: this._id,
|
|
574
|
+
user_id: this._userID,
|
|
575
|
+
owner: this._owner,
|
|
576
|
+
metadata: finalMetadata,
|
|
577
|
+
timestamp: this._timestamp.toISOString(),
|
|
578
|
+
participants: [...this._participants],
|
|
579
|
+
status: finalStatus,
|
|
580
|
+
hash: this._hash,
|
|
581
|
+
deletion_approvals: this._deletionApprovals.map((approval) => ({
|
|
582
|
+
...approval,
|
|
583
|
+
timestamp: approval.timestamp,
|
|
584
|
+
})),
|
|
585
|
+
starred: this._starred,
|
|
586
|
+
};
|
|
587
|
+
// If we're auto-closing and status changed, actually update the instance
|
|
588
|
+
if (autoClose &&
|
|
589
|
+
finalStatus === ThreadStatus.CLOSED &&
|
|
590
|
+
this._status === ThreadStatus.ONGOING) {
|
|
591
|
+
this._status = ThreadStatus.CLOSED;
|
|
592
|
+
this._metadata = finalMetadata;
|
|
593
|
+
}
|
|
594
|
+
else if (!autoClose) {
|
|
595
|
+
// Just update metadata without changing status
|
|
596
|
+
this._metadata = finalMetadata;
|
|
597
|
+
}
|
|
598
|
+
return exportData;
|
|
599
|
+
}
|
|
600
|
+
catch (error) {
|
|
601
|
+
if (error instanceof MajikThreadError) {
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
604
|
+
throw new MajikThreadError(`Failed to export final stats: ${error instanceof Error ? error.message : "Unknown error"}`, "EXPORT_FINAL_STATS_FAILED");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Exports analytics-ready data for the thread
|
|
609
|
+
* @returns Object with analytics metadata
|
|
610
|
+
*/
|
|
611
|
+
getAnalyticsData() {
|
|
612
|
+
const now = new Date();
|
|
613
|
+
const duration = now.getTime() - this._timestamp.getTime();
|
|
614
|
+
return {
|
|
615
|
+
threadID: this._id,
|
|
616
|
+
owner: this._owner,
|
|
617
|
+
userID: this._userID,
|
|
618
|
+
participantCount: this._participants.length,
|
|
619
|
+
messageCount: this._metadata.messageCount || 0,
|
|
620
|
+
status: this._status,
|
|
621
|
+
createdAt: this._timestamp.toISOString(),
|
|
622
|
+
lastActivity: this._metadata.lastActivity,
|
|
623
|
+
duration,
|
|
624
|
+
tags: this._metadata.tags || [],
|
|
625
|
+
category: this._metadata.category,
|
|
626
|
+
priority: this._metadata.priority,
|
|
627
|
+
deletionStatus: {
|
|
628
|
+
isPendingDeletion: this._status === ThreadStatus.PENDING_DELETION,
|
|
629
|
+
isMarkedForDeletion: this._status === ThreadStatus.MARKED_FOR_DELETION,
|
|
630
|
+
approvalProgress: (this._deletionApprovals.length / this._participants.length) * 100,
|
|
631
|
+
approvedCount: this._deletionApprovals.length,
|
|
632
|
+
totalParticipants: this._participants.length,
|
|
633
|
+
},
|
|
634
|
+
starred: this._starred,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export type ISODateString = string;
|
|
|
2
2
|
export type MajikMessageAccountID = string;
|
|
3
3
|
export type MajikMessagePublicKey = string;
|
|
4
4
|
export type MajikMessageChatID = string;
|
|
5
|
+
export type MajikMessageThreadID = string;
|
|
6
|
+
export type MajikMessageMailID = string;
|
|
5
7
|
export interface MAJIK_API_RESPONSE {
|
|
6
8
|
success: boolean;
|
|
7
9
|
message: string;
|
package/dist/index.d.ts
CHANGED
|
@@ -15,3 +15,6 @@ export * from "./core/database/chat/majik-message-chat";
|
|
|
15
15
|
export type * from "./core/database/chat/types";
|
|
16
16
|
export * from "./core/database/system/identity";
|
|
17
17
|
export * from "./core/compressor/majik-compressor";
|
|
18
|
+
export * from "./core/database/thread/majik-message-thread";
|
|
19
|
+
export * from "./core/database/thread/mail/majik-message-mail";
|
|
20
|
+
export * from "./core/database/thread/enums";
|
package/dist/index.js
CHANGED
|
@@ -13,3 +13,6 @@ export * from "./core/utils/utilities";
|
|
|
13
13
|
export * from "./core/database/chat/majik-message-chat";
|
|
14
14
|
export * from "./core/database/system/identity";
|
|
15
15
|
export * from "./core/compressor/majik-compressor";
|
|
16
|
+
export * from "./core/database/thread/majik-message-thread";
|
|
17
|
+
export * from "./core/database/thread/mail/majik-message-mail";
|
|
18
|
+
export * from "./core/database/thread/enums";
|