@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.
- package/CHANGELOG.md +70 -0
- package/dist/ai.d.ts +25 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +53 -0
- package/dist/ai.js.map +1 -0
- package/dist/core/ai/AIEngine.d.ts +69 -0
- package/dist/core/ai/AIEngine.d.ts.map +1 -0
- package/dist/core/ai/AIEngine.js +185 -0
- package/dist/core/ai/AIEngine.js.map +1 -0
- package/dist/core/auth/index.d.ts +183 -0
- package/dist/core/auth/index.d.ts.map +1 -0
- package/dist/core/auth/index.js +369 -0
- package/dist/core/auth/index.js.map +1 -0
- package/dist/core/billing/index.d.ts +52 -0
- package/dist/core/billing/index.d.ts.map +1 -0
- package/dist/core/billing/index.js +91 -0
- package/dist/core/billing/index.js.map +1 -0
- package/dist/core/booking/index.d.ts +38 -0
- package/dist/core/booking/index.d.ts.map +1 -0
- package/dist/core/booking/index.js +60 -0
- package/dist/core/booking/index.js.map +1 -0
- package/dist/core/chat/index.d.ts +48 -0
- package/dist/core/chat/index.d.ts.map +1 -0
- package/dist/core/chat/index.js +83 -0
- package/dist/core/chat/index.js.map +1 -0
- package/dist/core/document/index.d.ts +41 -0
- package/dist/core/document/index.d.ts.map +1 -0
- package/dist/core/document/index.js +68 -0
- package/dist/core/document/index.js.map +1 -0
- package/dist/core/events/index.d.ts +64 -0
- package/dist/core/events/index.d.ts.map +1 -0
- package/dist/core/events/index.js +60 -0
- package/dist/core/events/index.js.map +1 -0
- package/dist/core/geolocation/index.d.ts +32 -0
- package/dist/core/geolocation/index.d.ts.map +1 -0
- package/dist/core/geolocation/index.js +50 -0
- package/dist/core/geolocation/index.js.map +1 -0
- package/dist/core/kyc/index.d.ts +37 -0
- package/dist/core/kyc/index.d.ts.map +1 -0
- package/dist/core/kyc/index.js +60 -0
- package/dist/core/kyc/index.js.map +1 -0
- package/dist/core/logger/index.d.ts +60 -0
- package/dist/core/logger/index.d.ts.map +1 -0
- package/dist/core/logger/index.js +91 -0
- package/dist/core/logger/index.js.map +1 -0
- package/dist/core/notifications/index.d.ts +41 -0
- package/dist/core/notifications/index.d.ts.map +1 -0
- package/dist/core/notifications/index.js +111 -0
- package/dist/core/notifications/index.js.map +1 -0
- package/dist/core/rbac/index.d.ts +43 -0
- package/dist/core/rbac/index.d.ts.map +1 -0
- package/dist/core/rbac/index.js +66 -0
- package/dist/core/rbac/index.js.map +1 -0
- package/dist/events.d.ts +23 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +22 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/kyc.d.ts +12 -0
- package/dist/kyc.d.ts.map +1 -0
- package/dist/kyc.js +2 -0
- package/dist/kyc.js.map +1 -0
- package/dist/nanoid.d.ts +8 -0
- package/dist/nanoid.d.ts.map +1 -0
- package/dist/nanoid.js +15 -0
- package/dist/nanoid.js.map +1 -0
- package/dist/ndpr.d.ts +13 -0
- package/dist/ndpr.d.ts.map +1 -0
- package/dist/ndpr.js +19 -0
- package/dist/ndpr.js.map +1 -0
- package/dist/optimistic-lock.d.ts +11 -0
- package/dist/optimistic-lock.d.ts.map +1 -0
- package/dist/optimistic-lock.js +24 -0
- package/dist/optimistic-lock.js.map +1 -0
- package/dist/payment.d.ts +41 -0
- package/dist/payment.d.ts.map +1 -0
- package/dist/payment.js +116 -0
- package/dist/payment.js.map +1 -0
- package/dist/pin.d.ts +6 -0
- package/dist/pin.d.ts.map +1 -0
- package/dist/pin.js +18 -0
- package/dist/pin.js.map +1 -0
- package/dist/query-helpers.d.ts +18 -0
- package/dist/query-helpers.d.ts.map +1 -0
- package/dist/query-helpers.js +22 -0
- package/dist/query-helpers.js.map +1 -0
- package/dist/rate-limit.d.ts +13 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +16 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/sms.d.ts +23 -0
- package/dist/sms.d.ts.map +1 -0
- package/dist/sms.js +60 -0
- package/dist/sms.js.map +1 -0
- package/dist/tax.d.ts +25 -0
- package/dist/tax.d.ts.map +1 -0
- package/dist/tax.js +31 -0
- package/dist/tax.js.map +1 -0
- package/package.json +99 -0
- package/src/ai.test.ts +146 -0
- package/src/ai.ts +75 -0
- package/src/core/ai/AIEngine.test.ts +386 -0
- package/src/core/ai/AIEngine.ts +281 -0
- package/src/core/auth/index.test.ts +268 -0
- package/src/core/auth/index.ts +570 -0
- package/src/core/billing/index.test.ts +154 -0
- package/src/core/billing/index.ts +132 -0
- package/src/core/booking/index.test.ts +153 -0
- package/src/core/booking/index.ts +91 -0
- package/src/core/chat/index.test.ts +159 -0
- package/src/core/chat/index.ts +130 -0
- package/src/core/document/index.test.ts +106 -0
- package/src/core/document/index.ts +99 -0
- package/src/core/events/index.test.ts +91 -0
- package/src/core/events/index.ts +91 -0
- package/src/core/geolocation/index.test.ts +70 -0
- package/src/core/geolocation/index.ts +69 -0
- package/src/core/kyc/index.test.ts +105 -0
- package/src/core/kyc/index.ts +86 -0
- package/src/core/logger/index.test.ts +110 -0
- package/src/core/logger/index.ts +127 -0
- package/src/core/notifications/index.test.ts +85 -0
- package/src/core/notifications/index.ts +136 -0
- package/src/core/rbac/index.test.ts +81 -0
- package/src/core/rbac/index.ts +85 -0
- package/src/events.test.ts +43 -0
- package/src/events.ts +23 -0
- package/src/index.test.ts +123 -0
- package/src/index.ts +97 -0
- package/src/kyc.ts +23 -0
- package/src/nanoid.test.ts +43 -0
- package/src/nanoid.ts +16 -0
- package/src/ndpr.test.ts +68 -0
- package/src/ndpr.ts +49 -0
- package/src/optimistic-lock.test.ts +75 -0
- package/src/optimistic-lock.ts +36 -0
- package/src/payment.test.ts +152 -0
- package/src/payment.ts +163 -0
- package/src/pin.test.ts +57 -0
- package/src/pin.ts +38 -0
- package/src/query-helpers.test.ts +98 -0
- package/src/query-helpers.ts +36 -0
- package/src/rate-limit.test.ts +98 -0
- package/src/rate-limit.ts +33 -0
- package/src/sms.test.ts +112 -0
- package/src/sms.ts +85 -0
- package/src/tax.test.ts +85 -0
- package/src/tax.ts +57 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { KYCEngine } from './index';
|
|
3
|
+
|
|
4
|
+
const T1 = 'tenant_alpha';
|
|
5
|
+
const T2 = 'tenant_beta';
|
|
6
|
+
|
|
7
|
+
describe('CORE-12: Universal KYC/KYB Verification', () => {
|
|
8
|
+
let kycEngine: KYCEngine;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
kycEngine = new KYCEngine();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should submit a verification request', () => {
|
|
15
|
+
const request = kycEngine.submitVerification(T1, 'user_1', 'NIN', '12345678901');
|
|
16
|
+
expect(request.status).toBe('pending');
|
|
17
|
+
expect(request.tenantId).toBe(T1);
|
|
18
|
+
expect(request.userId).toBe('user_1');
|
|
19
|
+
expect(request.documentType).toBe('NIN');
|
|
20
|
+
expect(request.id).toMatch(/^kyc_/);
|
|
21
|
+
expect(request.documentNumber).toBe('12345678901');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should verify a valid document', async () => {
|
|
25
|
+
const request = kycEngine.submitVerification(T1, 'user_1', 'BVN', '12345678901');
|
|
26
|
+
const processed = await kycEngine.processVerification(T1, request.id);
|
|
27
|
+
|
|
28
|
+
expect(processed.status).toBe('verified');
|
|
29
|
+
expect(processed.verifiedAt).toBeDefined();
|
|
30
|
+
expect(processed.rejectionReason).toBeUndefined();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should reject an invalid document', async () => {
|
|
34
|
+
const request = kycEngine.submitVerification(T1, 'user_1', 'NIN', '00012345678');
|
|
35
|
+
const processed = await kycEngine.processVerification(T1, request.id);
|
|
36
|
+
|
|
37
|
+
expect(processed.status).toBe('rejected');
|
|
38
|
+
expect(processed.rejectionReason).toBeDefined();
|
|
39
|
+
expect(processed.verifiedAt).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should retrieve user verification status scoped to tenant', () => {
|
|
43
|
+
kycEngine.submitVerification(T1, 'user_1', 'NIN', '12345678901');
|
|
44
|
+
kycEngine.submitVerification(T1, 'user_1', 'BVN', '12345678901');
|
|
45
|
+
kycEngine.submitVerification(T1, 'user_2', 'NIN', '98765432109');
|
|
46
|
+
|
|
47
|
+
const user1Status = kycEngine.getUserVerificationStatus(T1, 'user_1');
|
|
48
|
+
expect(user1Status).toHaveLength(2);
|
|
49
|
+
|
|
50
|
+
const user2Status = kycEngine.getUserVerificationStatus(T1, 'user_2');
|
|
51
|
+
expect(user2Status).toHaveLength(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return empty array for user with no verifications', () => {
|
|
55
|
+
const status = kycEngine.getUserVerificationStatus(T1, 'unknown_user');
|
|
56
|
+
expect(status).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should throw when processing a non-existent request', async () => {
|
|
60
|
+
await expect(
|
|
61
|
+
kycEngine.processVerification(T1, 'kyc_nonexistent')
|
|
62
|
+
).rejects.toThrow('KYC request not found');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should throw when processing an already-processed request', async () => {
|
|
66
|
+
const request = kycEngine.submitVerification(T1, 'user_1', 'BVN', '12345678901');
|
|
67
|
+
await kycEngine.processVerification(T1, request.id);
|
|
68
|
+
|
|
69
|
+
await expect(
|
|
70
|
+
kycEngine.processVerification(T1, request.id)
|
|
71
|
+
).rejects.toThrow('Request is already processed');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should support all valid document types', () => {
|
|
75
|
+
const types = ['NIN', 'BVN', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
|
|
76
|
+
for (const type of types) {
|
|
77
|
+
const request = kycEngine.submitVerification(T1, 'user_x', type, '12345678901');
|
|
78
|
+
expect(request.documentType).toBe(type);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should assign unique IDs to each request', () => {
|
|
83
|
+
const r1 = kycEngine.submitVerification(T1, 'user_1', 'NIN', '11111111111');
|
|
84
|
+
const r2 = kycEngine.submitVerification(T1, 'user_1', 'BVN', '22222222222');
|
|
85
|
+
expect(r1.id).not.toBe(r2.id);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ─── Cross-Tenant Isolation ───────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
it('cross-tenant: getUserVerificationStatus for tenant_A user returns empty for tenant_B', () => {
|
|
91
|
+
kycEngine.submitVerification(T1, 'user_1', 'NIN', '12345678901');
|
|
92
|
+
kycEngine.submitVerification(T1, 'user_1', 'BVN', '12345678901');
|
|
93
|
+
|
|
94
|
+
const result = kycEngine.getUserVerificationStatus(T2, 'user_1');
|
|
95
|
+
expect(result).toHaveLength(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('cross-tenant: tenant_B cannot process a KYC request belonging to tenant_A', async () => {
|
|
99
|
+
const request = kycEngine.submitVerification(T1, 'user_1', 'NIN', '12345678901');
|
|
100
|
+
|
|
101
|
+
await expect(
|
|
102
|
+
kycEngine.processVerification(T2, request.id)
|
|
103
|
+
).rejects.toThrow('KYC request not found');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORE-12: Universal KYC/KYB Verification
|
|
3
|
+
* Blueprint Reference: Part 10.11 (Fintech), Part 10.3 (Transport)
|
|
4
|
+
*
|
|
5
|
+
* Centralized identity verification system with Nigeria-First integrations.
|
|
6
|
+
*
|
|
7
|
+
* Tenant Isolation: every mutating and querying method requires a tenantId.
|
|
8
|
+
* KYC requests are scoped per tenant — cross-tenant leakage is impossible by construction.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface KYCRequest {
|
|
12
|
+
id: string;
|
|
13
|
+
tenantId: string;
|
|
14
|
+
userId: string;
|
|
15
|
+
documentType: 'NIN' | 'BVN' | 'PASSPORT' | 'DRIVERS_LICENSE';
|
|
16
|
+
documentNumber: string;
|
|
17
|
+
status: 'pending' | 'verified' | 'rejected';
|
|
18
|
+
verifiedAt?: Date;
|
|
19
|
+
rejectionReason?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class KYCEngine {
|
|
23
|
+
private requests: Map<string, KYCRequest> = new Map();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Submits a new KYC verification request, scoped to the tenant.
|
|
27
|
+
*/
|
|
28
|
+
submitVerification(
|
|
29
|
+
tenantId: string,
|
|
30
|
+
userId: string,
|
|
31
|
+
documentType: 'NIN' | 'BVN' | 'PASSPORT' | 'DRIVERS_LICENSE',
|
|
32
|
+
documentNumber: string
|
|
33
|
+
): KYCRequest {
|
|
34
|
+
const request: KYCRequest = {
|
|
35
|
+
id: `kyc_${crypto.randomUUID()}`,
|
|
36
|
+
tenantId,
|
|
37
|
+
userId,
|
|
38
|
+
documentType,
|
|
39
|
+
documentNumber,
|
|
40
|
+
status: 'pending',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
this.requests.set(request.id, request);
|
|
44
|
+
return request;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Processes a verification request, scoped to the tenant.
|
|
49
|
+
* Mocks external API calls to NIMC (NIN) / NIBSS (BVN).
|
|
50
|
+
*/
|
|
51
|
+
async processVerification(tenantId: string, requestId: string): Promise<KYCRequest> {
|
|
52
|
+
const request = this.requests.get(requestId);
|
|
53
|
+
if (!request || request.tenantId !== tenantId) {
|
|
54
|
+
throw new Error('KYC request not found');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (request.status !== 'pending') {
|
|
58
|
+
throw new Error('Request is already processed');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const isValid = this.mockExternalVerification(request.documentNumber);
|
|
62
|
+
|
|
63
|
+
if (isValid) {
|
|
64
|
+
request.status = 'verified';
|
|
65
|
+
request.verifiedAt = new Date();
|
|
66
|
+
} else {
|
|
67
|
+
request.status = 'rejected';
|
|
68
|
+
request.rejectionReason = 'Document verification failed against national database';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return request;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Retrieves all verification requests for a user, scoped to the tenant.
|
|
76
|
+
*/
|
|
77
|
+
getUserVerificationStatus(tenantId: string, userId: string): KYCRequest[] {
|
|
78
|
+
return Array.from(this.requests.values()).filter(
|
|
79
|
+
r => r.tenantId === tenantId && r.userId === userId
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private mockExternalVerification(documentNumber: string): boolean {
|
|
84
|
+
return !documentNumber.startsWith('000');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { logger } from './index';
|
|
3
|
+
|
|
4
|
+
describe('Platform Logger', () => {
|
|
5
|
+
let consoleInfoSpy: any;
|
|
6
|
+
let consoleWarnSpy: any;
|
|
7
|
+
let consoleErrorSpy: any;
|
|
8
|
+
let consoleDebugSpy: any;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
consoleInfoSpy = vi.spyOn(console, 'info').mockImplementation(() => {});
|
|
12
|
+
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
13
|
+
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
14
|
+
consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
consoleInfoSpy.mockRestore();
|
|
19
|
+
consoleWarnSpy.mockRestore();
|
|
20
|
+
consoleErrorSpy.mockRestore();
|
|
21
|
+
consoleDebugSpy.mockRestore();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('logs info messages with context', () => {
|
|
25
|
+
logger.info('User logged in', { tenantId: 'tenant-123', userId: 'user-456' });
|
|
26
|
+
|
|
27
|
+
expect(consoleInfoSpy).toHaveBeenCalledOnce();
|
|
28
|
+
const call = consoleInfoSpy.mock.calls[0][0];
|
|
29
|
+
const entry = JSON.parse(call);
|
|
30
|
+
|
|
31
|
+
expect(entry.level).toBe('info');
|
|
32
|
+
expect(entry.message).toBe('User logged in');
|
|
33
|
+
expect(entry.context.tenantId).toBe('tenant-123');
|
|
34
|
+
expect(entry.context.userId).toBe('user-456');
|
|
35
|
+
expect(entry.timestamp).toBeDefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('logs warning messages', () => {
|
|
39
|
+
logger.warn('API rate limit approaching', { tenantId: 'tenant-123' });
|
|
40
|
+
|
|
41
|
+
expect(consoleWarnSpy).toHaveBeenCalledOnce();
|
|
42
|
+
const call = consoleWarnSpy.mock.calls[0][0];
|
|
43
|
+
const entry = JSON.parse(call);
|
|
44
|
+
|
|
45
|
+
expect(entry.level).toBe('warn');
|
|
46
|
+
expect(entry.message).toBe('API rate limit approaching');
|
|
47
|
+
expect(entry.context.tenantId).toBe('tenant-123');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('logs error messages with error object', () => {
|
|
51
|
+
const error = new Error('Database connection failed');
|
|
52
|
+
logger.error('Database operation failed', { tenantId: 'tenant-123' }, error);
|
|
53
|
+
|
|
54
|
+
expect(consoleErrorSpy).toHaveBeenCalledOnce();
|
|
55
|
+
const call = consoleErrorSpy.mock.calls[0][0];
|
|
56
|
+
const entry = JSON.parse(call);
|
|
57
|
+
|
|
58
|
+
expect(entry.level).toBe('error');
|
|
59
|
+
expect(entry.message).toBe('Database operation failed');
|
|
60
|
+
expect(entry.context.tenantId).toBe('tenant-123');
|
|
61
|
+
expect(entry.error.message).toBe('Database connection failed');
|
|
62
|
+
expect(entry.error.stack).toBeDefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('handles error as second parameter', () => {
|
|
66
|
+
const error = new Error('Connection timeout');
|
|
67
|
+
logger.error('Request failed', error);
|
|
68
|
+
|
|
69
|
+
expect(consoleErrorSpy).toHaveBeenCalledOnce();
|
|
70
|
+
const call = consoleErrorSpy.mock.calls[0][0];
|
|
71
|
+
const entry = JSON.parse(call);
|
|
72
|
+
|
|
73
|
+
expect(entry.level).toBe('error');
|
|
74
|
+
expect(entry.message).toBe('Request failed');
|
|
75
|
+
expect(entry.error.message).toBe('Connection timeout');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('logs messages without context', () => {
|
|
79
|
+
logger.info('System started');
|
|
80
|
+
|
|
81
|
+
expect(consoleInfoSpy).toHaveBeenCalledOnce();
|
|
82
|
+
const call = consoleInfoSpy.mock.calls[0][0];
|
|
83
|
+
const entry = JSON.parse(call);
|
|
84
|
+
|
|
85
|
+
expect(entry.level).toBe('info');
|
|
86
|
+
expect(entry.message).toBe('System started');
|
|
87
|
+
expect(entry.context).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('includes timestamp in all log entries', () => {
|
|
91
|
+
logger.info('Test message');
|
|
92
|
+
|
|
93
|
+
expect(consoleInfoSpy).toHaveBeenCalledOnce();
|
|
94
|
+
const call = consoleInfoSpy.mock.calls[0][0];
|
|
95
|
+
const entry = JSON.parse(call);
|
|
96
|
+
|
|
97
|
+
expect(entry.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('never uses console.log (uses console.info/warn/error)', () => {
|
|
101
|
+
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
102
|
+
|
|
103
|
+
logger.info('Test info');
|
|
104
|
+
logger.warn('Test warn');
|
|
105
|
+
logger.error('Test error');
|
|
106
|
+
|
|
107
|
+
expect(consoleLogSpy).not.toHaveBeenCalled();
|
|
108
|
+
consoleLogSpy.mockRestore();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform Logger Utility
|
|
3
|
+
*
|
|
4
|
+
* Enforces the "Zero Console Logs" invariant across the WebWaka platform.
|
|
5
|
+
* All logging must go through this module, never direct console.log/warn/error calls.
|
|
6
|
+
*
|
|
7
|
+
* Blueprint Reference: Part 9.3 — "Zero Console Logs: No console.log statements. Must use platform logger."
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface LogContext {
|
|
11
|
+
tenantId?: string;
|
|
12
|
+
userId?: string;
|
|
13
|
+
requestId?: string;
|
|
14
|
+
[key: string]: any;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SerializedError {
|
|
18
|
+
message: string;
|
|
19
|
+
stack?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LogEntry {
|
|
23
|
+
timestamp: string;
|
|
24
|
+
level: 'info' | 'warn' | 'error' | 'debug';
|
|
25
|
+
message: string;
|
|
26
|
+
context?: LogContext;
|
|
27
|
+
error?: SerializedError;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Platform Logger
|
|
32
|
+
*
|
|
33
|
+
* Usage:
|
|
34
|
+
* logger.info("User logged in", { tenantId: "tenant-123", userId: "user-456" });
|
|
35
|
+
* logger.warn("API rate limit approaching", { tenantId: "tenant-123" });
|
|
36
|
+
* logger.error("Database connection failed", { tenantId: "tenant-123" }, error);
|
|
37
|
+
* logger.debug("Cache hit", { tenantId: "tenant-123", key: "user-profile" });
|
|
38
|
+
*/
|
|
39
|
+
class PlatformLogger {
|
|
40
|
+
private isDevelopment = (globalThis as any).process?.env?.NODE_ENV === 'development';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Log informational message
|
|
44
|
+
*/
|
|
45
|
+
info(message: string, context?: LogContext): void {
|
|
46
|
+
this.log('info', message, context);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Log warning message
|
|
51
|
+
*/
|
|
52
|
+
warn(message: string, context?: LogContext): void {
|
|
53
|
+
this.log('warn', message, context);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Log error message
|
|
58
|
+
*/
|
|
59
|
+
error(message: string, context?: LogContext | Error, error?: Error): void {
|
|
60
|
+
let ctx = context as LogContext | undefined;
|
|
61
|
+
let err = error;
|
|
62
|
+
|
|
63
|
+
// Handle overload: error(message, error) or error(message, context, error)
|
|
64
|
+
if (context instanceof Error) {
|
|
65
|
+
err = context;
|
|
66
|
+
ctx = undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.log('error', message, ctx, err);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Log debug message (development only)
|
|
74
|
+
*/
|
|
75
|
+
debug(message: string, context?: LogContext): void {
|
|
76
|
+
if (this.isDevelopment) {
|
|
77
|
+
this.log('debug', message, context);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Internal logging implementation
|
|
83
|
+
*/
|
|
84
|
+
private log(
|
|
85
|
+
level: 'info' | 'warn' | 'error' | 'debug',
|
|
86
|
+
message: string,
|
|
87
|
+
context?: LogContext,
|
|
88
|
+
error?: Error
|
|
89
|
+
): void {
|
|
90
|
+
const serializedError: SerializedError | undefined = error !== undefined
|
|
91
|
+
? { message: error.message, ...(error.stack !== undefined ? { stack: error.stack } : {}) }
|
|
92
|
+
: undefined;
|
|
93
|
+
|
|
94
|
+
const entry: LogEntry = {
|
|
95
|
+
timestamp: new Date().toISOString(),
|
|
96
|
+
level,
|
|
97
|
+
message,
|
|
98
|
+
...(context !== undefined ? { context } : {}),
|
|
99
|
+
...(serializedError !== undefined ? { error: serializedError } : {}),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// In production, this would send to a logging service (e.g., Datadog, Sentry)
|
|
103
|
+
// For now, we output to stderr to avoid stdout pollution
|
|
104
|
+
if (typeof console !== 'undefined') {
|
|
105
|
+
const output = JSON.stringify(entry);
|
|
106
|
+
|
|
107
|
+
switch (level) {
|
|
108
|
+
case 'error':
|
|
109
|
+
console.error(output);
|
|
110
|
+
break;
|
|
111
|
+
case 'warn':
|
|
112
|
+
console.warn(output);
|
|
113
|
+
break;
|
|
114
|
+
case 'debug':
|
|
115
|
+
console.debug(output);
|
|
116
|
+
break;
|
|
117
|
+
case 'info':
|
|
118
|
+
default:
|
|
119
|
+
console.info(output);
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Export singleton instance
|
|
127
|
+
export const logger = new PlatformLogger();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { NotificationService, NotificationPayload } from './index';
|
|
3
|
+
|
|
4
|
+
// Mock fetch for external APIs
|
|
5
|
+
global.fetch = vi.fn();
|
|
6
|
+
|
|
7
|
+
describe('CORE-7: Unified Notification Service (Nigeria-First)', () => {
|
|
8
|
+
let notificationService: NotificationService;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.resetAllMocks();
|
|
12
|
+
notificationService = new NotificationService({
|
|
13
|
+
yournotifyApiKey: 'yn-key-123',
|
|
14
|
+
termiiApiKey: 'termii-key-456',
|
|
15
|
+
termiiSenderId: 'WebWakaTest'
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should send email via Yournotify', async () => {
|
|
20
|
+
(global.fetch as any).mockResolvedValueOnce({ ok: true });
|
|
21
|
+
|
|
22
|
+
const payload: NotificationPayload = {
|
|
23
|
+
tenantId: 't1',
|
|
24
|
+
userId: 'u1',
|
|
25
|
+
type: 'email',
|
|
26
|
+
recipient: 'test@example.com',
|
|
27
|
+
subject: 'Test Email',
|
|
28
|
+
body: '<h1>Hello</h1>'
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const result = await notificationService.dispatch(payload);
|
|
32
|
+
|
|
33
|
+
expect(result).toBe(true);
|
|
34
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
35
|
+
|
|
36
|
+
const fetchArgs = (global.fetch as any).mock.calls[0];
|
|
37
|
+
expect(fetchArgs[0]).toBe('https://api.yournotify.com/v1/campaigns/email');
|
|
38
|
+
expect(fetchArgs[1].headers['Authorization']).toBe('Bearer yn-key-123');
|
|
39
|
+
|
|
40
|
+
const body = JSON.parse(fetchArgs[1].body);
|
|
41
|
+
expect(body.to).toBe('test@example.com');
|
|
42
|
+
expect(body.subject).toBe('Test Email');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should send SMS via Termii', async () => {
|
|
46
|
+
(global.fetch as any).mockResolvedValueOnce({ ok: true });
|
|
47
|
+
|
|
48
|
+
const payload: NotificationPayload = {
|
|
49
|
+
tenantId: 't1',
|
|
50
|
+
userId: 'u1',
|
|
51
|
+
type: 'sms',
|
|
52
|
+
recipient: '2348012345678',
|
|
53
|
+
body: 'Hello from WebWaka'
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const result = await notificationService.dispatch(payload);
|
|
57
|
+
|
|
58
|
+
expect(result).toBe(true);
|
|
59
|
+
expect(global.fetch).toHaveBeenCalledTimes(1);
|
|
60
|
+
|
|
61
|
+
const fetchArgs = (global.fetch as any).mock.calls[0];
|
|
62
|
+
expect(fetchArgs[0]).toBe('https://api.ng.termii.com/api/sms/send');
|
|
63
|
+
|
|
64
|
+
const body = JSON.parse(fetchArgs[1].body);
|
|
65
|
+
expect(body.to).toBe('2348012345678');
|
|
66
|
+
expect(body.from).toBe('WebWakaTest');
|
|
67
|
+
expect(body.api_key).toBe('termii-key-456');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should fail gracefully if API keys are missing', async () => {
|
|
71
|
+
const emptyService = new NotificationService({});
|
|
72
|
+
|
|
73
|
+
const emailResult = await emptyService.dispatch({
|
|
74
|
+
tenantId: 't1', userId: 'u1', type: 'email', recipient: 'test@example.com', body: 'test'
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const smsResult = await emptyService.dispatch({
|
|
78
|
+
tenantId: 't1', userId: 'u1', type: 'sms', recipient: '2348012345678', body: 'test'
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(emailResult).toBe(false);
|
|
82
|
+
expect(smsResult).toBe(false);
|
|
83
|
+
expect(global.fetch).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORE-7: Unified Notification Service
|
|
3
|
+
* Blueprint Reference: Part 10.12 (Cross-Cutting Functional Modules)
|
|
4
|
+
* Blueprint Reference: Part 9.1 #5 (Nigeria First - Yournotify, Termii)
|
|
5
|
+
*
|
|
6
|
+
* Implements event-driven email, SMS, and push notification dispatchers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { logger } from '../logger';
|
|
10
|
+
|
|
11
|
+
export interface NotificationPayload {
|
|
12
|
+
tenantId: string;
|
|
13
|
+
userId: string;
|
|
14
|
+
type: 'email' | 'sms' | 'push';
|
|
15
|
+
recipient: string; // email address, phone number, or device token
|
|
16
|
+
subject?: string;
|
|
17
|
+
body: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface NotificationConfig {
|
|
21
|
+
yournotifyApiKey?: string;
|
|
22
|
+
termiiApiKey?: string;
|
|
23
|
+
termiiSenderId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class NotificationService {
|
|
27
|
+
private config: NotificationConfig;
|
|
28
|
+
|
|
29
|
+
constructor(config: NotificationConfig) {
|
|
30
|
+
this.config = config;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Dispatches a notification based on its type.
|
|
35
|
+
*/
|
|
36
|
+
async dispatch(payload: NotificationPayload): Promise<boolean> {
|
|
37
|
+
switch (payload.type) {
|
|
38
|
+
case 'email':
|
|
39
|
+
return this.sendEmail(payload);
|
|
40
|
+
case 'sms':
|
|
41
|
+
return this.sendSms(payload);
|
|
42
|
+
case 'push':
|
|
43
|
+
return this.sendPush(payload);
|
|
44
|
+
default:
|
|
45
|
+
throw new Error(`Unsupported notification type: ${payload.type}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Sends an email using Yournotify (Nigeria-First Service)
|
|
51
|
+
*/
|
|
52
|
+
private async sendEmail(payload: NotificationPayload): Promise<boolean> {
|
|
53
|
+
if (!this.config.yournotifyApiKey) {
|
|
54
|
+
logger.warn('Yournotify API key missing. Email not sent.', {
|
|
55
|
+
tenantId: payload.tenantId,
|
|
56
|
+
recipient: payload.recipient,
|
|
57
|
+
});
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetch('https://api.yournotify.com/v1/campaigns/email', {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
headers: {
|
|
65
|
+
'Authorization': `Bearer ${this.config.yournotifyApiKey}`,
|
|
66
|
+
'Content-Type': 'application/json'
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify({
|
|
69
|
+
to: payload.recipient,
|
|
70
|
+
subject: payload.subject || 'Notification from WebWaka',
|
|
71
|
+
html: payload.body
|
|
72
|
+
})
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(`Yournotify API error: ${response.statusText}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
logger.error('Failed to send email via Yournotify', { tenantId: payload.tenantId }, error as Error);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Sends an SMS using Termii (Nigeria-First Service)
|
|
88
|
+
*/
|
|
89
|
+
private async sendSms(payload: NotificationPayload): Promise<boolean> {
|
|
90
|
+
if (!this.config.termiiApiKey) {
|
|
91
|
+
logger.warn('Termii API key missing. SMS not sent.', {
|
|
92
|
+
tenantId: payload.tenantId,
|
|
93
|
+
recipient: payload.recipient,
|
|
94
|
+
});
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch('https://api.ng.termii.com/api/sms/send', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: {
|
|
102
|
+
'Content-Type': 'application/json'
|
|
103
|
+
},
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
to: payload.recipient,
|
|
106
|
+
from: this.config.termiiSenderId || 'WebWaka',
|
|
107
|
+
sms: payload.body,
|
|
108
|
+
type: 'plain',
|
|
109
|
+
channel: 'generic',
|
|
110
|
+
api_key: this.config.termiiApiKey
|
|
111
|
+
})
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
throw new Error(`Termii API error: ${response.statusText}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return true;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logger.error('Failed to send SMS via Termii', { tenantId: payload.tenantId }, error as Error);
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Sends a push notification (Mock implementation for now)
|
|
127
|
+
*/
|
|
128
|
+
private async sendPush(payload: NotificationPayload): Promise<boolean> {
|
|
129
|
+
logger.info('Push notification sent', {
|
|
130
|
+
tenantId: payload.tenantId,
|
|
131
|
+
recipient: payload.recipient,
|
|
132
|
+
body: payload.body,
|
|
133
|
+
});
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
}
|