@webwaka/core 1.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.
Files changed (151) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/dist/ai.d.ts +25 -0
  3. package/dist/ai.d.ts.map +1 -0
  4. package/dist/ai.js +53 -0
  5. package/dist/ai.js.map +1 -0
  6. package/dist/core/ai/AIEngine.d.ts +69 -0
  7. package/dist/core/ai/AIEngine.d.ts.map +1 -0
  8. package/dist/core/ai/AIEngine.js +185 -0
  9. package/dist/core/ai/AIEngine.js.map +1 -0
  10. package/dist/core/auth/index.d.ts +183 -0
  11. package/dist/core/auth/index.d.ts.map +1 -0
  12. package/dist/core/auth/index.js +369 -0
  13. package/dist/core/auth/index.js.map +1 -0
  14. package/dist/core/billing/index.d.ts +52 -0
  15. package/dist/core/billing/index.d.ts.map +1 -0
  16. package/dist/core/billing/index.js +91 -0
  17. package/dist/core/billing/index.js.map +1 -0
  18. package/dist/core/booking/index.d.ts +38 -0
  19. package/dist/core/booking/index.d.ts.map +1 -0
  20. package/dist/core/booking/index.js +60 -0
  21. package/dist/core/booking/index.js.map +1 -0
  22. package/dist/core/chat/index.d.ts +48 -0
  23. package/dist/core/chat/index.d.ts.map +1 -0
  24. package/dist/core/chat/index.js +83 -0
  25. package/dist/core/chat/index.js.map +1 -0
  26. package/dist/core/document/index.d.ts +41 -0
  27. package/dist/core/document/index.d.ts.map +1 -0
  28. package/dist/core/document/index.js +68 -0
  29. package/dist/core/document/index.js.map +1 -0
  30. package/dist/core/events/index.d.ts +64 -0
  31. package/dist/core/events/index.d.ts.map +1 -0
  32. package/dist/core/events/index.js +60 -0
  33. package/dist/core/events/index.js.map +1 -0
  34. package/dist/core/geolocation/index.d.ts +32 -0
  35. package/dist/core/geolocation/index.d.ts.map +1 -0
  36. package/dist/core/geolocation/index.js +50 -0
  37. package/dist/core/geolocation/index.js.map +1 -0
  38. package/dist/core/kyc/index.d.ts +37 -0
  39. package/dist/core/kyc/index.d.ts.map +1 -0
  40. package/dist/core/kyc/index.js +60 -0
  41. package/dist/core/kyc/index.js.map +1 -0
  42. package/dist/core/logger/index.d.ts +60 -0
  43. package/dist/core/logger/index.d.ts.map +1 -0
  44. package/dist/core/logger/index.js +91 -0
  45. package/dist/core/logger/index.js.map +1 -0
  46. package/dist/core/notifications/index.d.ts +41 -0
  47. package/dist/core/notifications/index.d.ts.map +1 -0
  48. package/dist/core/notifications/index.js +111 -0
  49. package/dist/core/notifications/index.js.map +1 -0
  50. package/dist/core/rbac/index.d.ts +43 -0
  51. package/dist/core/rbac/index.d.ts.map +1 -0
  52. package/dist/core/rbac/index.js +66 -0
  53. package/dist/core/rbac/index.js.map +1 -0
  54. package/dist/events.d.ts +23 -0
  55. package/dist/events.d.ts.map +1 -0
  56. package/dist/events.js +22 -0
  57. package/dist/events.js.map +1 -0
  58. package/dist/index.d.ts +33 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +56 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/kyc.d.ts +12 -0
  63. package/dist/kyc.d.ts.map +1 -0
  64. package/dist/kyc.js +2 -0
  65. package/dist/kyc.js.map +1 -0
  66. package/dist/nanoid.d.ts +8 -0
  67. package/dist/nanoid.d.ts.map +1 -0
  68. package/dist/nanoid.js +15 -0
  69. package/dist/nanoid.js.map +1 -0
  70. package/dist/ndpr.d.ts +13 -0
  71. package/dist/ndpr.d.ts.map +1 -0
  72. package/dist/ndpr.js +19 -0
  73. package/dist/ndpr.js.map +1 -0
  74. package/dist/optimistic-lock.d.ts +11 -0
  75. package/dist/optimistic-lock.d.ts.map +1 -0
  76. package/dist/optimistic-lock.js +24 -0
  77. package/dist/optimistic-lock.js.map +1 -0
  78. package/dist/payment.d.ts +41 -0
  79. package/dist/payment.d.ts.map +1 -0
  80. package/dist/payment.js +116 -0
  81. package/dist/payment.js.map +1 -0
  82. package/dist/pin.d.ts +6 -0
  83. package/dist/pin.d.ts.map +1 -0
  84. package/dist/pin.js +18 -0
  85. package/dist/pin.js.map +1 -0
  86. package/dist/query-helpers.d.ts +18 -0
  87. package/dist/query-helpers.d.ts.map +1 -0
  88. package/dist/query-helpers.js +22 -0
  89. package/dist/query-helpers.js.map +1 -0
  90. package/dist/rate-limit.d.ts +13 -0
  91. package/dist/rate-limit.d.ts.map +1 -0
  92. package/dist/rate-limit.js +16 -0
  93. package/dist/rate-limit.js.map +1 -0
  94. package/dist/sms.d.ts +23 -0
  95. package/dist/sms.d.ts.map +1 -0
  96. package/dist/sms.js +60 -0
  97. package/dist/sms.js.map +1 -0
  98. package/dist/tax.d.ts +25 -0
  99. package/dist/tax.d.ts.map +1 -0
  100. package/dist/tax.js +31 -0
  101. package/dist/tax.js.map +1 -0
  102. package/package.json +99 -0
  103. package/src/ai.test.ts +146 -0
  104. package/src/ai.ts +75 -0
  105. package/src/core/ai/AIEngine.test.ts +386 -0
  106. package/src/core/ai/AIEngine.ts +281 -0
  107. package/src/core/auth/index.test.ts +268 -0
  108. package/src/core/auth/index.ts +570 -0
  109. package/src/core/billing/index.test.ts +154 -0
  110. package/src/core/billing/index.ts +132 -0
  111. package/src/core/booking/index.test.ts +153 -0
  112. package/src/core/booking/index.ts +91 -0
  113. package/src/core/chat/index.test.ts +159 -0
  114. package/src/core/chat/index.ts +130 -0
  115. package/src/core/document/index.test.ts +106 -0
  116. package/src/core/document/index.ts +99 -0
  117. package/src/core/events/index.test.ts +91 -0
  118. package/src/core/events/index.ts +91 -0
  119. package/src/core/geolocation/index.test.ts +70 -0
  120. package/src/core/geolocation/index.ts +69 -0
  121. package/src/core/kyc/index.test.ts +105 -0
  122. package/src/core/kyc/index.ts +86 -0
  123. package/src/core/logger/index.test.ts +110 -0
  124. package/src/core/logger/index.ts +127 -0
  125. package/src/core/notifications/index.test.ts +85 -0
  126. package/src/core/notifications/index.ts +136 -0
  127. package/src/core/rbac/index.test.ts +81 -0
  128. package/src/core/rbac/index.ts +85 -0
  129. package/src/events.test.ts +43 -0
  130. package/src/events.ts +23 -0
  131. package/src/index.test.ts +123 -0
  132. package/src/index.ts +97 -0
  133. package/src/kyc.ts +23 -0
  134. package/src/nanoid.test.ts +43 -0
  135. package/src/nanoid.ts +16 -0
  136. package/src/ndpr.test.ts +68 -0
  137. package/src/ndpr.ts +49 -0
  138. package/src/optimistic-lock.test.ts +75 -0
  139. package/src/optimistic-lock.ts +36 -0
  140. package/src/payment.test.ts +152 -0
  141. package/src/payment.ts +163 -0
  142. package/src/pin.test.ts +57 -0
  143. package/src/pin.ts +38 -0
  144. package/src/query-helpers.test.ts +98 -0
  145. package/src/query-helpers.ts +36 -0
  146. package/src/rate-limit.test.ts +98 -0
  147. package/src/rate-limit.ts +33 -0
  148. package/src/sms.test.ts +112 -0
  149. package/src/sms.ts +85 -0
  150. package/src/tax.test.ts +85 -0
  151. package/src/tax.ts +57 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * CORE-13: Real-Time Chat & Communication
3
+ * Blueprint Reference: Part 10 (All Verticals)
4
+ *
5
+ * In-app messaging system with offline sync support.
6
+ *
7
+ * Tenant Isolation: every mutating and querying method requires a tenantId.
8
+ * Channels and messages are scoped per tenant — cross-tenant leakage is impossible
9
+ * by construction.
10
+ */
11
+
12
+ export interface Message {
13
+ id: string;
14
+ tenantId: string;
15
+ channelId: string;
16
+ senderId: string;
17
+ content: string;
18
+ type: 'text' | 'image' | 'system';
19
+ status: 'sent' | 'delivered' | 'read';
20
+ createdAt: Date;
21
+ }
22
+
23
+ export interface ChatChannel {
24
+ id: string;
25
+ tenantId: string;
26
+ participants: string[];
27
+ metadata: Record<string, any>;
28
+ createdAt: Date;
29
+ }
30
+
31
+ export class ChatEngine {
32
+ private channels: Map<string, ChatChannel> = new Map();
33
+ private messages: Map<string, Message[]> = new Map();
34
+
35
+ /**
36
+ * Creates a new chat channel between participants within a tenant.
37
+ */
38
+ createChannel(
39
+ tenantId: string,
40
+ participants: string[],
41
+ metadata: Record<string, any> = {}
42
+ ): ChatChannel {
43
+ const channel: ChatChannel = {
44
+ id: `ch_${crypto.randomUUID()}`,
45
+ tenantId,
46
+ participants,
47
+ metadata,
48
+ createdAt: new Date(),
49
+ };
50
+
51
+ this.channels.set(channel.id, channel);
52
+ this.messages.set(channel.id, []);
53
+
54
+ // eventBus.publish(WebWakaEventType.CHAT_CHANNEL_CREATED, createEvent(WebWakaEventType.CHAT_CHANNEL_CREATED, tenantId, channel));
55
+
56
+ return channel;
57
+ }
58
+
59
+ /**
60
+ * Sends a message to a channel, validating the channel belongs to the tenant.
61
+ */
62
+ sendMessage(
63
+ tenantId: string,
64
+ channelId: string,
65
+ senderId: string,
66
+ content: string,
67
+ type: 'text' | 'image' | 'system' = 'text'
68
+ ): Message {
69
+ const channel = this.channels.get(channelId);
70
+ if (!channel || channel.tenantId !== tenantId) {
71
+ throw new Error('Channel not found');
72
+ }
73
+
74
+ if (!channel.participants.includes(senderId) && type !== 'system') {
75
+ throw new Error('Sender is not a participant in this channel');
76
+ }
77
+
78
+ const message: Message = {
79
+ id: `msg_${crypto.randomUUID()}`,
80
+ tenantId,
81
+ channelId,
82
+ senderId,
83
+ content,
84
+ type,
85
+ status: 'sent',
86
+ createdAt: new Date(),
87
+ };
88
+
89
+ const channelMessages = this.messages.get(channelId) ?? [];
90
+ channelMessages.push(message);
91
+ this.messages.set(channelId, channelMessages);
92
+
93
+ // eventBus.publish(WebWakaEventType.CHAT_MESSAGE_SENT, createEvent(WebWakaEventType.CHAT_MESSAGE_SENT, tenantId, message));
94
+
95
+ return message;
96
+ }
97
+
98
+ /**
99
+ * Retrieves messages for a channel, scoped to the tenant.
100
+ */
101
+ getMessages(
102
+ tenantId: string,
103
+ channelId: string,
104
+ limit = 50,
105
+ offset = 0
106
+ ): Message[] {
107
+ const channel = this.channels.get(channelId);
108
+ if (!channel || channel.tenantId !== tenantId) {
109
+ return [];
110
+ }
111
+
112
+ const channelMessages = this.messages.get(channelId) ?? [];
113
+ return [...channelMessages].reverse().slice(offset, offset + limit);
114
+ }
115
+
116
+ /**
117
+ * Marks messages as read, scoped to the tenant.
118
+ */
119
+ markAsRead(tenantId: string, channelId: string, messageIds: string[]): void {
120
+ const channel = this.channels.get(channelId);
121
+ if (!channel || channel.tenantId !== tenantId) return;
122
+
123
+ const channelMessages = this.messages.get(channelId) ?? [];
124
+ for (const msg of channelMessages) {
125
+ if (messageIds.includes(msg.id)) {
126
+ msg.status = 'read';
127
+ }
128
+ }
129
+ }
130
+ }
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { DocumentEngine } from './index';
3
+
4
+ const T1 = 'tenant_alpha';
5
+ const T2 = 'tenant_beta';
6
+
7
+ describe('CORE-11: Document & Contract Management', () => {
8
+ let docEngine: DocumentEngine;
9
+
10
+ beforeEach(() => {
11
+ docEngine = new DocumentEngine();
12
+ });
13
+
14
+ it('should create a document in draft status', () => {
15
+ const doc = docEngine.createDocument(T1, 'Lease Agreement', 'Terms and conditions...');
16
+ expect(doc.title).toBe('Lease Agreement');
17
+ expect(doc.tenantId).toBe(T1);
18
+ expect(doc.status).toBe('draft');
19
+ expect(doc.signatures).toHaveLength(0);
20
+ expect(doc.id).toMatch(/^doc_/);
21
+ expect(doc.createdAt).toBeInstanceOf(Date);
22
+ });
23
+
24
+ it('should transition document to pending_signature', () => {
25
+ const doc = docEngine.createDocument(T1, 'Lease Agreement', 'Terms and conditions...');
26
+ const updatedDoc = docEngine.requestSignatures(T1, doc.id);
27
+ expect(updatedDoc.status).toBe('pending_signature');
28
+ });
29
+
30
+ it('should allow signing a pending document', () => {
31
+ const doc = docEngine.createDocument(T1, 'Lease Agreement', 'Terms and conditions...');
32
+ docEngine.requestSignatures(T1, doc.id);
33
+
34
+ const signedDoc = docEngine.signDocument(T1, doc.id, 'user_1', '192.168.1.1');
35
+ expect(signedDoc.status).toBe('signed');
36
+ expect(signedDoc.signatures).toHaveLength(1);
37
+ expect(signedDoc.signatures[0]!.userId).toBe('user_1');
38
+ expect(signedDoc.signatures[0]!.ipAddress).toBe('192.168.1.1');
39
+ expect(signedDoc.signatures[0]!.hash).toBeDefined();
40
+ expect(signedDoc.signatures[0]!.timestamp).toBeInstanceOf(Date);
41
+ });
42
+
43
+ it('should reject signing a draft document', () => {
44
+ const doc = docEngine.createDocument(T1, 'Lease Agreement', 'Terms and conditions...');
45
+
46
+ expect(() => {
47
+ docEngine.signDocument(T1, doc.id, 'user_1', '192.168.1.1');
48
+ }).toThrow('Document is not pending signature');
49
+ });
50
+
51
+ it('should throw when requesting signatures on non-existent document', () => {
52
+ expect(() => {
53
+ docEngine.requestSignatures(T1, 'doc_nonexistent');
54
+ }).toThrow('Document not found');
55
+ });
56
+
57
+ it('should throw when signing a non-existent document', () => {
58
+ expect(() => {
59
+ docEngine.signDocument(T1, 'doc_nonexistent', 'user_1', '10.0.0.1');
60
+ }).toThrow('Document not found');
61
+ });
62
+
63
+ it('should throw when requesting signatures on non-draft document', () => {
64
+ const doc = docEngine.createDocument(T1, 'Contract', 'Content');
65
+ docEngine.requestSignatures(T1, doc.id);
66
+
67
+ expect(() => {
68
+ docEngine.requestSignatures(T1, doc.id);
69
+ }).toThrow('Document must be in draft status to request signatures');
70
+ });
71
+
72
+ it('should throw when trying to sign an already-signed document', () => {
73
+ const doc = docEngine.createDocument(T1, 'Contract', 'Content');
74
+ docEngine.requestSignatures(T1, doc.id);
75
+ docEngine.signDocument(T1, doc.id, 'user_1', '10.0.0.1');
76
+
77
+ expect(() => {
78
+ docEngine.signDocument(T1, doc.id, 'user_2', '10.0.0.2');
79
+ }).toThrow('Document is not pending signature');
80
+ });
81
+
82
+ it('should assign unique IDs to each document', () => {
83
+ const d1 = docEngine.createDocument(T1, 'Doc A', 'Content A');
84
+ const d2 = docEngine.createDocument(T1, 'Doc B', 'Content B');
85
+ expect(d1.id).not.toBe(d2.id);
86
+ });
87
+
88
+ // ─── Cross-Tenant Isolation ───────────────────────────────────────────────
89
+
90
+ it('cross-tenant: tenant_B cannot request signatures on tenant_A document', () => {
91
+ const doc = docEngine.createDocument(T1, 'Lease Agreement', 'Content');
92
+
93
+ expect(() => {
94
+ docEngine.requestSignatures(T2, doc.id);
95
+ }).toThrow('Document not found');
96
+ });
97
+
98
+ it('cross-tenant: tenant_B cannot sign a tenant_A document', () => {
99
+ const doc = docEngine.createDocument(T1, 'Lease Agreement', 'Content');
100
+ docEngine.requestSignatures(T1, doc.id);
101
+
102
+ expect(() => {
103
+ docEngine.signDocument(T2, doc.id, 'user_1', '10.0.0.1');
104
+ }).toThrow('Document not found');
105
+ });
106
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * CORE-11: Document & Contract Management
3
+ * Blueprint Reference: Part 10.5 (Real Estate), Part 10.12 (Legal)
4
+ *
5
+ * Secure system for generating, signing, and storing legal documents.
6
+ *
7
+ * Tenant Isolation: every mutating and querying method requires a tenantId.
8
+ * Documents are scoped per tenant — cross-tenant leakage is impossible by construction.
9
+ */
10
+
11
+ export interface Document {
12
+ id: string;
13
+ tenantId: string;
14
+ title: string;
15
+ content: string;
16
+ status: 'draft' | 'pending_signature' | 'signed';
17
+ signatures: Signature[];
18
+ createdAt: Date;
19
+ }
20
+
21
+ export interface Signature {
22
+ userId: string;
23
+ timestamp: Date;
24
+ ipAddress: string;
25
+ hash: string;
26
+ }
27
+
28
+ export class DocumentEngine {
29
+ private documents: Map<string, Document> = new Map();
30
+
31
+ /**
32
+ * Creates a new document from a template, scoped to the tenant.
33
+ */
34
+ createDocument(tenantId: string, title: string, content: string): Document {
35
+ const doc: Document = {
36
+ id: `doc_${crypto.randomUUID()}`,
37
+ tenantId,
38
+ title,
39
+ content,
40
+ status: 'draft',
41
+ signatures: [],
42
+ createdAt: new Date(),
43
+ };
44
+ this.documents.set(doc.id, doc);
45
+ return doc;
46
+ }
47
+
48
+ /**
49
+ * Requests signatures for a document, scoped to the tenant.
50
+ */
51
+ requestSignatures(tenantId: string, documentId: string): Document {
52
+ const doc = this.documents.get(documentId);
53
+ if (!doc || doc.tenantId !== tenantId) throw new Error('Document not found');
54
+
55
+ if (doc.status !== 'draft') {
56
+ throw new Error('Document must be in draft status to request signatures');
57
+ }
58
+
59
+ doc.status = 'pending_signature';
60
+ return doc;
61
+ }
62
+
63
+ /**
64
+ * Signs a document, scoped to the tenant.
65
+ */
66
+ signDocument(
67
+ tenantId: string,
68
+ documentId: string,
69
+ userId: string,
70
+ ipAddress: string
71
+ ): Document {
72
+ const doc = this.documents.get(documentId);
73
+ if (!doc || doc.tenantId !== tenantId) throw new Error('Document not found');
74
+
75
+ if (doc.status !== 'pending_signature') {
76
+ throw new Error('Document is not pending signature');
77
+ }
78
+
79
+ if (doc.signatures.some(s => s.userId === userId)) {
80
+ throw new Error('User has already signed this document');
81
+ }
82
+
83
+ const signature: Signature = {
84
+ userId,
85
+ timestamp: new Date(),
86
+ ipAddress,
87
+ hash: this.generateSignatureHash(doc.content, userId, ipAddress),
88
+ };
89
+
90
+ doc.signatures.push(signature);
91
+ doc.status = 'signed';
92
+
93
+ return doc;
94
+ }
95
+
96
+ private generateSignatureHash(content: string, userId: string, ipAddress: string): string {
97
+ return `hash_${userId}_${Date.now()}`;
98
+ }
99
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createEvent, WebWakaEventType, type DomainEvent } from './index';
3
+
4
+ describe('CORE-14: Event Bus Primitives', () => {
5
+ // ─── createEvent ─────────────────────────────────────────────────────────
6
+
7
+ it('should create an event with correct structure', () => {
8
+ const payload = { userId: 'u_1', email: 'user@example.com' };
9
+ const event = createEvent(WebWakaEventType.AUTH_USER_LOGIN, 'tenant_alpha', payload);
10
+
11
+ expect(event.id).toBeDefined();
12
+ expect(typeof event.id).toBe('string');
13
+ expect(event.type).toBe(WebWakaEventType.AUTH_USER_LOGIN);
14
+ expect(event.tenantId).toBe('tenant_alpha');
15
+ expect(event.occurredAt).toBeInstanceOf(Date);
16
+ expect(event.payload).toEqual(payload);
17
+ });
18
+
19
+ it('should generate unique IDs for each createEvent call', () => {
20
+ const e1 = createEvent(WebWakaEventType.CHAT_MESSAGE_SENT, 'tenant_alpha', {});
21
+ const e2 = createEvent(WebWakaEventType.CHAT_MESSAGE_SENT, 'tenant_alpha', {});
22
+ expect(e1.id).not.toBe(e2.id);
23
+ });
24
+
25
+ it('should set occurredAt to the current time', () => {
26
+ const before = new Date();
27
+ const event = createEvent(WebWakaEventType.BOOKING_CONFIRMED, 'tenant_beta', {});
28
+ const after = new Date();
29
+
30
+ expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
31
+ expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime());
32
+ });
33
+
34
+ it('should accept an arbitrary typed payload', () => {
35
+ interface BookingPayload { bookingId: string; resourceId: string }
36
+ const payload: BookingPayload = { bookingId: 'bk_123', resourceId: 'res_42' };
37
+
38
+ const event: DomainEvent<BookingPayload> = createEvent(
39
+ WebWakaEventType.BOOKING_CONFIRMED,
40
+ 'tenant_beta',
41
+ payload
42
+ );
43
+
44
+ expect(event.payload.bookingId).toBe('bk_123');
45
+ expect(event.payload.resourceId).toBe('res_42');
46
+ });
47
+
48
+ it('should carry the tenantId supplied to createEvent', () => {
49
+ const event = createEvent(WebWakaEventType.KYC_VERIFIED, 'tenant_gamma', { requestId: 'kyc_1' });
50
+ expect(event.tenantId).toBe('tenant_gamma');
51
+ });
52
+
53
+ // ─── WebWakaEventType enum value stability (regression guard) ─────────────
54
+
55
+ it('Auth event type values are stable strings', () => {
56
+ expect(WebWakaEventType.AUTH_USER_LOGIN).toBe('auth.user.login');
57
+ expect(WebWakaEventType.AUTH_USER_LOGOUT).toBe('auth.user.logout');
58
+ expect(WebWakaEventType.AUTH_TOKEN_REFRESHED).toBe('auth.token.refreshed');
59
+ });
60
+
61
+ it('KYC event type values are stable strings', () => {
62
+ expect(WebWakaEventType.KYC_SUBMITTED).toBe('kyc.submitted');
63
+ expect(WebWakaEventType.KYC_VERIFIED).toBe('kyc.verified');
64
+ expect(WebWakaEventType.KYC_REJECTED).toBe('kyc.rejected');
65
+ });
66
+
67
+ it('Booking event type values are stable strings', () => {
68
+ expect(WebWakaEventType.BOOKING_CONFIRMED).toBe('booking.confirmed');
69
+ expect(WebWakaEventType.BOOKING_CANCELLED).toBe('booking.cancelled');
70
+ });
71
+
72
+ it('Chat event type values are stable strings', () => {
73
+ expect(WebWakaEventType.CHAT_MESSAGE_SENT).toBe('chat.message.sent');
74
+ expect(WebWakaEventType.CHAT_CHANNEL_CREATED).toBe('chat.channel.created');
75
+ });
76
+
77
+ it('Document event type values are stable strings', () => {
78
+ expect(WebWakaEventType.DOCUMENT_CREATED).toBe('document.created');
79
+ expect(WebWakaEventType.DOCUMENT_SIGNED).toBe('document.signed');
80
+ });
81
+
82
+ it('Billing event type values are stable strings', () => {
83
+ expect(WebWakaEventType.BILLING_DEBIT_RECORDED).toBe('billing.debit.recorded');
84
+ expect(WebWakaEventType.BILLING_CREDIT_RECORDED).toBe('billing.credit.recorded');
85
+ });
86
+
87
+ it('Notification event type values are stable strings', () => {
88
+ expect(WebWakaEventType.NOTIFICATION_SENT).toBe('notification.sent');
89
+ expect(WebWakaEventType.NOTIFICATION_FAILED).toBe('notification.failed');
90
+ });
91
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * CORE-14: Event Bus Primitives
3
+ * Blueprint Reference: Part 2 (Platform Architecture — "Event-Driven: NO direct inter-DB access")
4
+ *
5
+ * Canonical event contracts for the entire WebWaka OS v4 platform.
6
+ * Every domain emits typed DomainEvents; consumers subscribe by event type.
7
+ *
8
+ * This module defines shapes and constants only.
9
+ * Actual queue/bus wiring (Cloudflare Queues) is a separate concern.
10
+ */
11
+
12
+ /**
13
+ * Well-known event type names for all WebWaka OS v4 domains.
14
+ *
15
+ * Consumers MUST use these constants — never raw string literals — so that
16
+ * renaming an event causes a compile-time error instead of a silent mismatch.
17
+ */
18
+ export enum WebWakaEventType {
19
+ // ─── Auth ──────────────────────────────────────────────────────────────────
20
+ AUTH_USER_LOGIN = 'auth.user.login',
21
+ AUTH_USER_LOGOUT = 'auth.user.logout',
22
+ AUTH_TOKEN_REFRESHED = 'auth.token.refreshed',
23
+
24
+ // ─── KYC ───────────────────────────────────────────────────────────────────
25
+ KYC_SUBMITTED = 'kyc.submitted',
26
+ KYC_VERIFIED = 'kyc.verified',
27
+ KYC_REJECTED = 'kyc.rejected',
28
+
29
+ // ─── Booking ───────────────────────────────────────────────────────────────
30
+ BOOKING_CONFIRMED = 'booking.confirmed',
31
+ BOOKING_CANCELLED = 'booking.cancelled',
32
+
33
+ // ─── Chat ──────────────────────────────────────────────────────────────────
34
+ CHAT_MESSAGE_SENT = 'chat.message.sent',
35
+ CHAT_CHANNEL_CREATED = 'chat.channel.created',
36
+
37
+ // ─── Document ──────────────────────────────────────────────────────────────
38
+ DOCUMENT_CREATED = 'document.created',
39
+ DOCUMENT_SIGNED = 'document.signed',
40
+
41
+ // ─── Billing ───────────────────────────────────────────────────────────────
42
+ BILLING_DEBIT_RECORDED = 'billing.debit.recorded',
43
+ BILLING_CREDIT_RECORDED = 'billing.credit.recorded',
44
+
45
+ // ─── Notification ──────────────────────────────────────────────────────────
46
+ NOTIFICATION_SENT = 'notification.sent',
47
+ NOTIFICATION_FAILED = 'notification.failed',
48
+ }
49
+
50
+ /**
51
+ * Envelope wrapping every domain event published on the platform bus.
52
+ *
53
+ * `type` is constrained to `WebWakaEventType` — arbitrary strings are rejected
54
+ * at compile time, preventing undeclared event names from entering the bus.
55
+ *
56
+ * @template T The event-specific payload type.
57
+ */
58
+ export interface DomainEvent<T = unknown> {
59
+ /** Unique event identifier (UUID v4). */
60
+ id: string;
61
+ /** Canonical event type — must be a WebWakaEventType constant. */
62
+ type: WebWakaEventType;
63
+ /** The tenant that owns this event. */
64
+ tenantId: string;
65
+ /** Wall-clock time the event was created. */
66
+ occurredAt: Date;
67
+ /** Domain-specific payload. */
68
+ payload: T;
69
+ }
70
+
71
+ /**
72
+ * Factory that creates a well-formed DomainEvent with a generated id and
73
+ * current timestamp.
74
+ *
75
+ * @param type A WebWakaEventType constant.
76
+ * @param tenantId Tenant that owns the event.
77
+ * @param payload Domain-specific payload.
78
+ */
79
+ export function createEvent<T>(
80
+ type: WebWakaEventType,
81
+ tenantId: string,
82
+ payload: T
83
+ ): DomainEvent<T> {
84
+ return {
85
+ id: crypto.randomUUID(),
86
+ type,
87
+ tenantId,
88
+ occurredAt: new Date(),
89
+ payload,
90
+ };
91
+ }
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { GeolocationEngine } from './index';
3
+
4
+ describe('CORE-9: Geolocation & Mapping Engine', () => {
5
+ let geoEngine: GeolocationEngine;
6
+
7
+ beforeEach(() => {
8
+ geoEngine = new GeolocationEngine('osm');
9
+ });
10
+
11
+ it('should calculate distance and ETA between two points', async () => {
12
+ const origin = { lat: 6.5244, lng: 3.3792 }; // Lagos
13
+ const destination = { lat: 9.0579, lng: 7.4951 }; // Abuja
14
+
15
+ const route = await geoEngine.calculateRoute(origin, destination);
16
+
17
+ expect(route.distanceMeters).toBeGreaterThan(500000);
18
+ expect(route.distanceMeters).toBeLessThan(600000);
19
+ expect(route.durationSeconds).toBeGreaterThan(0);
20
+ expect(route.polyline).toBeDefined();
21
+ });
22
+
23
+ it('should correctly identify if a point is within a geofence', () => {
24
+ const center = { lat: 6.5244, lng: 3.3792 }; // Lagos
25
+ const pointInside = { lat: 6.5300, lng: 3.3800 };
26
+ const pointOutside = { lat: 9.0579, lng: 7.4951 }; // Abuja
27
+
28
+ expect(geoEngine.isWithinGeofence(pointInside, center, 5000)).toBe(true);
29
+ expect(geoEngine.isWithinGeofence(pointOutside, center, 5000)).toBe(false);
30
+ });
31
+
32
+ it('should default to OSM provider when none is specified', async () => {
33
+ const defaultEngine = new GeolocationEngine();
34
+ const origin = { lat: 6.5244, lng: 3.3792 };
35
+ const destination = { lat: 6.6018, lng: 3.3515 }; // Victoria Island
36
+ const route = await defaultEngine.calculateRoute(origin, destination);
37
+ expect(route.distanceMeters).toBeGreaterThan(0);
38
+ });
39
+
40
+ it('should support google and mapbox providers', async () => {
41
+ for (const provider of ['google', 'mapbox'] as const) {
42
+ const engine = new GeolocationEngine(provider);
43
+ const route = await engine.calculateRoute(
44
+ { lat: 6.5244, lng: 3.3792 },
45
+ { lat: 6.4550, lng: 3.3841 }
46
+ );
47
+ expect(route.distanceMeters).toBeGreaterThan(0);
48
+ expect(route.durationSeconds).toBeGreaterThan(0);
49
+ }
50
+ });
51
+
52
+ it('should return zero distance for identical coordinates', async () => {
53
+ const point = { lat: 6.5244, lng: 3.3792 };
54
+ const route = await geoEngine.calculateRoute(point, point);
55
+ expect(route.distanceMeters).toBe(0);
56
+ });
57
+
58
+ it('should return true when point is exactly at center of geofence', () => {
59
+ const center = { lat: 6.5244, lng: 3.3792 };
60
+ expect(geoEngine.isWithinGeofence(center, center, 1)).toBe(true);
61
+ });
62
+
63
+ it('should compute duration based on distance', async () => {
64
+ const origin = { lat: 6.5244, lng: 3.3792 };
65
+ const destination = { lat: 9.0579, lng: 7.4951 };
66
+ const route = await geoEngine.calculateRoute(origin, destination);
67
+ // At ~8.33 m/s, 536km should take well over 10000 seconds
68
+ expect(route.durationSeconds).toBeGreaterThan(10000);
69
+ });
70
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * CORE-9: Geolocation & Mapping Engine
3
+ * Blueprint Reference: Part 10.3 (Transport), Part 10.4 (Logistics)
4
+ *
5
+ * Vendor-neutral abstraction layer for maps, routing, and geocoding.
6
+ */
7
+
8
+ export interface Coordinates {
9
+ lat: number;
10
+ lng: number;
11
+ }
12
+
13
+ export interface Route {
14
+ distanceMeters: number;
15
+ durationSeconds: number;
16
+ polyline: string;
17
+ }
18
+
19
+ export class GeolocationEngine {
20
+ private provider: 'google' | 'mapbox' | 'osm';
21
+
22
+ constructor(provider: 'google' | 'mapbox' | 'osm' = 'osm') {
23
+ this.provider = provider;
24
+ }
25
+
26
+ /**
27
+ * Calculates the distance and ETA between two points.
28
+ */
29
+ async calculateRoute(origin: Coordinates, destination: Coordinates): Promise<Route> {
30
+ // In a real implementation, this would call the respective provider's API
31
+ // For now, we return a mock calculation based on straight-line distance
32
+
33
+ const distance = this.calculateStraightLineDistance(origin, destination);
34
+ // Assume average speed of 30 km/h (8.33 m/s) in city traffic
35
+ const duration = Math.floor(distance / 8.33);
36
+
37
+ return {
38
+ distanceMeters: Math.floor(distance),
39
+ durationSeconds: duration,
40
+ polyline: 'mock_polyline_data'
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Checks if a coordinate is within a specific geofence (radius in meters).
46
+ */
47
+ isWithinGeofence(point: Coordinates, center: Coordinates, radiusMeters: number): boolean {
48
+ const distance = this.calculateStraightLineDistance(point, center);
49
+ return distance <= radiusMeters;
50
+ }
51
+
52
+ /**
53
+ * Haversine formula to calculate distance between two coordinates in meters.
54
+ */
55
+ private calculateStraightLineDistance(coord1: Coordinates, coord2: Coordinates): number {
56
+ const R = 6371e3; // Earth's radius in meters
57
+ const φ1 = coord1.lat * Math.PI / 180;
58
+ const φ2 = coord2.lat * Math.PI / 180;
59
+ const Δφ = (coord2.lat - coord1.lat) * Math.PI / 180;
60
+ const Δλ = (coord2.lng - coord1.lng) * Math.PI / 180;
61
+
62
+ const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
63
+ Math.cos(φ1) * Math.cos(φ2) *
64
+ Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
65
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
66
+
67
+ return R * c;
68
+ }
69
+ }