@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,154 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { BillingLedger, UsageCategory, LedgerEntryType } from './index';
|
|
3
|
+
import { logger } from '../logger';
|
|
4
|
+
|
|
5
|
+
describe('CORE-8: Platform Billing & Usage Ledger', () => {
|
|
6
|
+
let billingLedger: BillingLedger;
|
|
7
|
+
let mockDb: any;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mockDb = {
|
|
11
|
+
prepare: vi.fn().mockReturnThis(),
|
|
12
|
+
bind: vi.fn().mockReturnThis(),
|
|
13
|
+
run: vi.fn().mockResolvedValue({ success: true }),
|
|
14
|
+
first: vi.fn().mockResolvedValue({ balance: 500000 }) // 5000 NGN
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
billingLedger = new BillingLedger(mockDb);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// ─── recordUsage (existing) ───────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
it('should record usage with integer kobo values', async () => {
|
|
27
|
+
const entry = await billingLedger.recordUsage(
|
|
28
|
+
'tenant-1',
|
|
29
|
+
UsageCategory.AI_TOKENS,
|
|
30
|
+
1500, // 15 NGN
|
|
31
|
+
'OpenRouter GPT-4o-mini usage'
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect(entry.tenantId).toBe('tenant-1');
|
|
35
|
+
expect(entry.type).toBe(LedgerEntryType.DEBIT);
|
|
36
|
+
expect(entry.category).toBe(UsageCategory.AI_TOKENS);
|
|
37
|
+
expect(entry.amountKobo).toBe(1500);
|
|
38
|
+
expect(entry.description).toBe('OpenRouter GPT-4o-mini usage');
|
|
39
|
+
expect(entry.id).toBeDefined();
|
|
40
|
+
expect(entry.createdAt).toBeInstanceOf(Date);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should reject non-integer kobo values', async () => {
|
|
44
|
+
await expect(
|
|
45
|
+
billingLedger.recordUsage('tenant-1', UsageCategory.AI_TOKENS, 1500.5, 'Invalid amount')
|
|
46
|
+
).rejects.toThrow('Amount must be a positive integer in kobo');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should reject negative kobo values', async () => {
|
|
50
|
+
await expect(
|
|
51
|
+
billingLedger.recordUsage('tenant-1', UsageCategory.AI_TOKENS, -100, 'Invalid amount')
|
|
52
|
+
).rejects.toThrow('Amount must be a positive integer in kobo');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ─── recordCredit ─────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
it('should record a credit entry with CREDIT type', async () => {
|
|
58
|
+
const entry = await billingLedger.recordCredit(
|
|
59
|
+
'tenant-1',
|
|
60
|
+
UsageCategory.SUBSCRIPTION_CREDIT,
|
|
61
|
+
500000, // 5000 NGN top-up
|
|
62
|
+
'Wallet top-up via Paystack'
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(entry.tenantId).toBe('tenant-1');
|
|
66
|
+
expect(entry.type).toBe(LedgerEntryType.CREDIT);
|
|
67
|
+
expect(entry.category).toBe(UsageCategory.SUBSCRIPTION_CREDIT);
|
|
68
|
+
expect(entry.amountKobo).toBe(500000);
|
|
69
|
+
expect(entry.description).toBe('Wallet top-up via Paystack');
|
|
70
|
+
expect(entry.id).toBeDefined();
|
|
71
|
+
expect(entry.createdAt).toBeInstanceOf(Date);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should include optional metadata on credit entry when provided', async () => {
|
|
75
|
+
const meta = { reference: 'PAY_REF_001', channel: 'paystack' };
|
|
76
|
+
const entry = await billingLedger.recordCredit(
|
|
77
|
+
'tenant-2',
|
|
78
|
+
UsageCategory.SUBSCRIPTION_CREDIT,
|
|
79
|
+
100000,
|
|
80
|
+
'Promotional credit',
|
|
81
|
+
meta
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
expect(entry.metadata).toEqual(meta);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should omit metadata from credit entry when not provided', async () => {
|
|
88
|
+
const entry = await billingLedger.recordCredit(
|
|
89
|
+
'tenant-2',
|
|
90
|
+
UsageCategory.SUBSCRIPTION_CREDIT,
|
|
91
|
+
100000,
|
|
92
|
+
'Promo credit'
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect(entry.metadata).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should reject non-integer kobo on recordCredit', async () => {
|
|
99
|
+
await expect(
|
|
100
|
+
billingLedger.recordCredit(
|
|
101
|
+
'tenant-1',
|
|
102
|
+
UsageCategory.SUBSCRIPTION_CREDIT,
|
|
103
|
+
999.99,
|
|
104
|
+
'Bad amount'
|
|
105
|
+
)
|
|
106
|
+
).rejects.toThrow('Amount must be a positive integer in kobo');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should reject negative kobo on recordCredit', async () => {
|
|
110
|
+
await expect(
|
|
111
|
+
billingLedger.recordCredit(
|
|
112
|
+
'tenant-1',
|
|
113
|
+
UsageCategory.SUBSCRIPTION_CREDIT,
|
|
114
|
+
-500,
|
|
115
|
+
'Bad amount'
|
|
116
|
+
)
|
|
117
|
+
).rejects.toThrow('Amount must be a positive integer in kobo');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should allow recording both a debit and a credit for the same tenant', async () => {
|
|
121
|
+
const debit = await billingLedger.recordUsage(
|
|
122
|
+
'tenant-1',
|
|
123
|
+
UsageCategory.AI_TOKENS,
|
|
124
|
+
2000,
|
|
125
|
+
'AI usage charge'
|
|
126
|
+
);
|
|
127
|
+
const credit = await billingLedger.recordCredit(
|
|
128
|
+
'tenant-1',
|
|
129
|
+
UsageCategory.SUBSCRIPTION_CREDIT,
|
|
130
|
+
50000,
|
|
131
|
+
'Top-up'
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(debit.type).toBe(LedgerEntryType.DEBIT);
|
|
135
|
+
expect(credit.type).toBe(LedgerEntryType.CREDIT);
|
|
136
|
+
expect(debit.tenantId).toBe(credit.tenantId);
|
|
137
|
+
expect(debit.id).not.toBe(credit.id);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// ─── getTenantBalance stub ────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
it('should return 0 and emit a logger.warn flagging the stub', async () => {
|
|
143
|
+
const warnSpy = vi.spyOn(logger, 'warn');
|
|
144
|
+
|
|
145
|
+
const balance = await billingLedger.getTenantBalance('tenant-1');
|
|
146
|
+
|
|
147
|
+
expect(balance).toBe(0);
|
|
148
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
149
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
150
|
+
expect.stringContaining('stub'),
|
|
151
|
+
expect.objectContaining({ tenantId: 'tenant-1' })
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORE-8: Platform Billing & Usage Ledger
|
|
3
|
+
* Blueprint Reference: Part 10.1 (Central Management & Economics)
|
|
4
|
+
* Blueprint Reference: Part 9.1 #6 (Africa First - Integer Kobo Values)
|
|
5
|
+
*
|
|
6
|
+
* Implements internal ledger for tracking tenant API/AI usage and credits.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { logger } from '../logger';
|
|
10
|
+
|
|
11
|
+
export enum LedgerEntryType {
|
|
12
|
+
CREDIT = 'CREDIT',
|
|
13
|
+
DEBIT = 'DEBIT'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export enum UsageCategory {
|
|
17
|
+
AI_TOKENS = 'AI_TOKENS',
|
|
18
|
+
SMS_SENT = 'SMS_SENT',
|
|
19
|
+
EMAIL_SENT = 'EMAIL_SENT',
|
|
20
|
+
SUBSCRIPTION_FEE = 'SUBSCRIPTION_FEE',
|
|
21
|
+
SUBSCRIPTION_CREDIT = 'SUBSCRIPTION_CREDIT'
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LedgerEntry {
|
|
25
|
+
id: string;
|
|
26
|
+
tenantId: string;
|
|
27
|
+
type: LedgerEntryType;
|
|
28
|
+
category: UsageCategory;
|
|
29
|
+
amountKobo: number; // Integer kobo values (Part 9.1 #6)
|
|
30
|
+
description: string;
|
|
31
|
+
metadata?: Record<string, any>;
|
|
32
|
+
createdAt: Date;
|
|
33
|
+
deletedAt?: Date; // Soft deletes
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Shared kobo validation used by both debit and credit paths. */
|
|
37
|
+
function validateKobo(amountKobo: number): void {
|
|
38
|
+
if (!Number.isInteger(amountKobo) || amountKobo < 0) {
|
|
39
|
+
throw new Error('Amount must be a positive integer in kobo');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class BillingLedger {
|
|
44
|
+
private db: any; // Type would be D1Database from @cloudflare/workers-types
|
|
45
|
+
|
|
46
|
+
constructor(db: any) {
|
|
47
|
+
this.db = db;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Records a usage debit for a tenant.
|
|
52
|
+
*/
|
|
53
|
+
async recordUsage(
|
|
54
|
+
tenantId: string,
|
|
55
|
+
category: UsageCategory,
|
|
56
|
+
amountKobo: number,
|
|
57
|
+
description: string,
|
|
58
|
+
metadata?: Record<string, any>
|
|
59
|
+
): Promise<LedgerEntry> {
|
|
60
|
+
validateKobo(amountKobo);
|
|
61
|
+
|
|
62
|
+
const entry: LedgerEntry = {
|
|
63
|
+
id: crypto.randomUUID(),
|
|
64
|
+
tenantId,
|
|
65
|
+
type: LedgerEntryType.DEBIT,
|
|
66
|
+
category,
|
|
67
|
+
amountKobo,
|
|
68
|
+
description,
|
|
69
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
70
|
+
createdAt: new Date(),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// In a real implementation, this would insert into D1:
|
|
74
|
+
// await this.db.prepare('INSERT INTO ledger_entries ...').bind(...).run();
|
|
75
|
+
|
|
76
|
+
return entry;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Records a credit for a tenant (wallet top-up, refund, promotional credit,
|
|
81
|
+
* or subscription payment).
|
|
82
|
+
*/
|
|
83
|
+
async recordCredit(
|
|
84
|
+
tenantId: string,
|
|
85
|
+
category: UsageCategory,
|
|
86
|
+
amountKobo: number,
|
|
87
|
+
description: string,
|
|
88
|
+
metadata?: Record<string, any>
|
|
89
|
+
): Promise<LedgerEntry> {
|
|
90
|
+
validateKobo(amountKobo);
|
|
91
|
+
|
|
92
|
+
const entry: LedgerEntry = {
|
|
93
|
+
id: crypto.randomUUID(),
|
|
94
|
+
tenantId,
|
|
95
|
+
type: LedgerEntryType.CREDIT,
|
|
96
|
+
category,
|
|
97
|
+
amountKobo,
|
|
98
|
+
description,
|
|
99
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
100
|
+
createdAt: new Date(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// In a real implementation, this would insert into D1:
|
|
104
|
+
// await this.db.prepare('INSERT INTO ledger_entries ...').bind(...).run();
|
|
105
|
+
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Calculates the current balance for a tenant.
|
|
111
|
+
*
|
|
112
|
+
* @stub This implementation always returns 0. Real balance calculation
|
|
113
|
+
* requires D1 database wiring (separate task). Any caller that gates
|
|
114
|
+
* actions on this value will silently allow unlimited usage until
|
|
115
|
+
* D1 integration is complete.
|
|
116
|
+
*/
|
|
117
|
+
async getTenantBalance(tenantId: string): Promise<number> {
|
|
118
|
+
logger.warn(
|
|
119
|
+
'getTenantBalance is a stub and always returns 0. D1 integration is required for real balance calculation.',
|
|
120
|
+
{ tenantId }
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Real implementation:
|
|
124
|
+
// const result = await this.db.prepare(
|
|
125
|
+
// 'SELECT SUM(CASE WHEN type = "CREDIT" THEN amountKobo ELSE -amountKobo END) as balance ' +
|
|
126
|
+
// 'FROM ledger_entries WHERE tenantId = ? AND deletedAt IS NULL'
|
|
127
|
+
// ).bind(tenantId).first();
|
|
128
|
+
// return result.balance ?? 0;
|
|
129
|
+
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { BookingEngine } from './index';
|
|
3
|
+
|
|
4
|
+
const T1 = 'tenant_alpha';
|
|
5
|
+
const T2 = 'tenant_beta';
|
|
6
|
+
|
|
7
|
+
describe('CORE-10: Universal Booking & Scheduling Engine', () => {
|
|
8
|
+
let bookingEngine: BookingEngine;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
bookingEngine = new BookingEngine();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should create a booking if resource is available', () => {
|
|
15
|
+
const slot = {
|
|
16
|
+
startTime: new Date('2026-03-15T10:00:00Z'),
|
|
17
|
+
endTime: new Date('2026-03-15T11:00:00Z'),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const booking = bookingEngine.createBooking(T1, 'res_1', 'user_1', slot);
|
|
21
|
+
expect(booking.status).toBe('confirmed');
|
|
22
|
+
expect(booking.tenantId).toBe(T1);
|
|
23
|
+
expect(booking.resourceId).toBe('res_1');
|
|
24
|
+
expect(booking.userId).toBe('user_1');
|
|
25
|
+
expect(booking.id).toMatch(/^bk_/);
|
|
26
|
+
expect(booking.slot).toEqual(slot);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should reject booking if resource is not available (overlapping)', () => {
|
|
30
|
+
const slot1 = {
|
|
31
|
+
startTime: new Date('2026-03-15T10:00:00Z'),
|
|
32
|
+
endTime: new Date('2026-03-15T11:00:00Z'),
|
|
33
|
+
};
|
|
34
|
+
const slot2 = {
|
|
35
|
+
startTime: new Date('2026-03-15T10:30:00Z'),
|
|
36
|
+
endTime: new Date('2026-03-15T11:30:00Z'),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
bookingEngine.createBooking(T1, 'res_1', 'user_1', slot1);
|
|
40
|
+
|
|
41
|
+
expect(() => {
|
|
42
|
+
bookingEngine.createBooking(T1, 'res_1', 'user_2', slot2);
|
|
43
|
+
}).toThrow('Resource is not available for the requested time slot');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should allow booking after cancellation', () => {
|
|
47
|
+
const slot = {
|
|
48
|
+
startTime: new Date('2026-03-15T10:00:00Z'),
|
|
49
|
+
endTime: new Date('2026-03-15T11:00:00Z'),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const booking1 = bookingEngine.createBooking(T1, 'res_1', 'user_1', slot);
|
|
53
|
+
const cancelled = bookingEngine.cancelBooking(T1, booking1.id);
|
|
54
|
+
expect(cancelled).toBe(true);
|
|
55
|
+
|
|
56
|
+
const booking2 = bookingEngine.createBooking(T1, 'res_1', 'user_2', slot);
|
|
57
|
+
expect(booking2.status).toBe('confirmed');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should allow bookings for different resources in the same slot', () => {
|
|
61
|
+
const slot = {
|
|
62
|
+
startTime: new Date('2026-03-15T10:00:00Z'),
|
|
63
|
+
endTime: new Date('2026-03-15T11:00:00Z'),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const b1 = bookingEngine.createBooking(T1, 'res_1', 'user_1', slot);
|
|
67
|
+
const b2 = bookingEngine.createBooking(T1, 'res_2', 'user_2', slot);
|
|
68
|
+
|
|
69
|
+
expect(b1.status).toBe('confirmed');
|
|
70
|
+
expect(b2.status).toBe('confirmed');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should allow adjacent bookings (no overlap)', () => {
|
|
74
|
+
const slot1 = {
|
|
75
|
+
startTime: new Date('2026-03-15T10:00:00Z'),
|
|
76
|
+
endTime: new Date('2026-03-15T11:00:00Z'),
|
|
77
|
+
};
|
|
78
|
+
const slot2 = {
|
|
79
|
+
startTime: new Date('2026-03-15T11:00:00Z'),
|
|
80
|
+
endTime: new Date('2026-03-15T12:00:00Z'),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
bookingEngine.createBooking(T1, 'res_1', 'user_1', slot1);
|
|
84
|
+
const b2 = bookingEngine.createBooking(T1, 'res_1', 'user_2', slot2);
|
|
85
|
+
expect(b2.status).toBe('confirmed');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should return false when cancelling a non-existent booking', () => {
|
|
89
|
+
const result = bookingEngine.cancelBooking(T1, 'bk_nonexistent');
|
|
90
|
+
expect(result).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should report resource unavailable when booked within a containing slot', () => {
|
|
94
|
+
const outer = {
|
|
95
|
+
startTime: new Date('2026-03-15T09:00:00Z'),
|
|
96
|
+
endTime: new Date('2026-03-15T12:00:00Z'),
|
|
97
|
+
};
|
|
98
|
+
const inner = {
|
|
99
|
+
startTime: new Date('2026-03-15T10:00:00Z'),
|
|
100
|
+
endTime: new Date('2026-03-15T11:00:00Z'),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
bookingEngine.createBooking(T1, 'res_1', 'user_1', outer);
|
|
104
|
+
|
|
105
|
+
expect(() => {
|
|
106
|
+
bookingEngine.createBooking(T1, 'res_1', 'user_2', inner);
|
|
107
|
+
}).toThrow('Resource is not available for the requested time slot');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should check availability without creating a booking', () => {
|
|
111
|
+
const slot = {
|
|
112
|
+
startTime: new Date('2026-03-15T10:00:00Z'),
|
|
113
|
+
endTime: new Date('2026-03-15T11:00:00Z'),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
expect(bookingEngine.isAvailable(T1, 'res_1', slot)).toBe(true);
|
|
117
|
+
bookingEngine.createBooking(T1, 'res_1', 'user_1', slot);
|
|
118
|
+
expect(bookingEngine.isAvailable(T1, 'res_1', slot)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ─── Cross-Tenant Isolation ───────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
it('cross-tenant: booking by tenant_A does not block the same slot for tenant_B', () => {
|
|
124
|
+
const slot = {
|
|
125
|
+
startTime: new Date('2026-03-15T10:00:00Z'),
|
|
126
|
+
endTime: new Date('2026-03-15T11:00:00Z'),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
bookingEngine.createBooking(T1, 'res_1', 'user_1', slot);
|
|
130
|
+
|
|
131
|
+
// Same resource and slot for T2 must still be available
|
|
132
|
+
expect(bookingEngine.isAvailable(T2, 'res_1', slot)).toBe(true);
|
|
133
|
+
const b2 = bookingEngine.createBooking(T2, 'res_1', 'user_2', slot);
|
|
134
|
+
expect(b2.status).toBe('confirmed');
|
|
135
|
+
expect(b2.tenantId).toBe(T2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('cross-tenant: cancelBooking by tenant_B cannot cancel tenant_A booking', () => {
|
|
139
|
+
const slot = {
|
|
140
|
+
startTime: new Date('2026-03-15T10:00:00Z'),
|
|
141
|
+
endTime: new Date('2026-03-15T11:00:00Z'),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const booking = bookingEngine.createBooking(T1, 'res_1', 'user_1', slot);
|
|
145
|
+
|
|
146
|
+
// T2 attempts to cancel T1's booking — must return false (not found in T2)
|
|
147
|
+
const result = bookingEngine.cancelBooking(T2, booking.id);
|
|
148
|
+
expect(result).toBe(false);
|
|
149
|
+
|
|
150
|
+
// Original booking should still be active (blocking T1's slot)
|
|
151
|
+
expect(bookingEngine.isAvailable(T1, 'res_1', slot)).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORE-10: Universal Booking & Scheduling Engine
|
|
3
|
+
* Blueprint Reference: Part 10.3 (Transport), Part 10.7 (Health)
|
|
4
|
+
*
|
|
5
|
+
* Unified system for managing time slots, availability, and reservations.
|
|
6
|
+
*
|
|
7
|
+
* Tenant Isolation: every mutating and querying method requires a tenantId.
|
|
8
|
+
* All data is scoped per tenant — cross-tenant leakage is impossible by construction.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface TimeSlot {
|
|
12
|
+
startTime: Date;
|
|
13
|
+
endTime: Date;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface Booking {
|
|
17
|
+
id: string;
|
|
18
|
+
tenantId: string;
|
|
19
|
+
resourceId: string;
|
|
20
|
+
userId: string;
|
|
21
|
+
slot: TimeSlot;
|
|
22
|
+
status: 'pending' | 'confirmed' | 'cancelled';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class BookingEngine {
|
|
26
|
+
private bookings: Booking[] = [];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Checks if a resource is available for a given time slot within a tenant.
|
|
30
|
+
*/
|
|
31
|
+
isAvailable(tenantId: string, resourceId: string, requestedSlot: TimeSlot): boolean {
|
|
32
|
+
const resourceBookings = this.bookings.filter(b =>
|
|
33
|
+
b.tenantId === tenantId &&
|
|
34
|
+
b.resourceId === resourceId &&
|
|
35
|
+
b.status !== 'cancelled'
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
for (const booking of resourceBookings) {
|
|
39
|
+
if (
|
|
40
|
+
requestedSlot.startTime < booking.slot.endTime &&
|
|
41
|
+
requestedSlot.endTime > booking.slot.startTime
|
|
42
|
+
) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a new booking if the resource is available within the tenant.
|
|
52
|
+
*/
|
|
53
|
+
createBooking(tenantId: string, resourceId: string, userId: string, slot: TimeSlot): Booking {
|
|
54
|
+
if (!this.isAvailable(tenantId, resourceId, slot)) {
|
|
55
|
+
throw new Error('Resource is not available for the requested time slot');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const newBooking: Booking = {
|
|
59
|
+
id: `bk_${crypto.randomUUID()}`,
|
|
60
|
+
tenantId,
|
|
61
|
+
resourceId,
|
|
62
|
+
userId,
|
|
63
|
+
slot,
|
|
64
|
+
status: 'confirmed',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
this.bookings.push(newBooking);
|
|
68
|
+
|
|
69
|
+
// eventBus.publish(WebWakaEventType.BOOKING_CONFIRMED, createEvent(WebWakaEventType.BOOKING_CONFIRMED, tenantId, newBooking));
|
|
70
|
+
|
|
71
|
+
return newBooking;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Cancels an existing booking, scoped to the tenant.
|
|
76
|
+
* Returns false if the booking does not exist within the tenant.
|
|
77
|
+
*/
|
|
78
|
+
cancelBooking(tenantId: string, bookingId: string): boolean {
|
|
79
|
+
const booking = this.bookings.find(
|
|
80
|
+
b => b.id === bookingId && b.tenantId === tenantId
|
|
81
|
+
);
|
|
82
|
+
if (booking) {
|
|
83
|
+
booking.status = 'cancelled';
|
|
84
|
+
|
|
85
|
+
// eventBus.publish(WebWakaEventType.BOOKING_CANCELLED, createEvent(WebWakaEventType.BOOKING_CANCELLED, tenantId, booking));
|
|
86
|
+
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { ChatEngine } from './index';
|
|
3
|
+
|
|
4
|
+
const T1 = 'tenant_alpha';
|
|
5
|
+
const T2 = 'tenant_beta';
|
|
6
|
+
|
|
7
|
+
describe('CORE-13: Real-Time Chat & Communication', () => {
|
|
8
|
+
let chatEngine: ChatEngine;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
chatEngine = new ChatEngine();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should create a chat channel', () => {
|
|
15
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2'], { type: 'support' });
|
|
16
|
+
|
|
17
|
+
expect(channel.id).toMatch(/^ch_/);
|
|
18
|
+
expect(channel.tenantId).toBe(T1);
|
|
19
|
+
expect(channel.participants).toContain('user1');
|
|
20
|
+
expect(channel.participants).toContain('user2');
|
|
21
|
+
expect(channel.metadata['type']).toBe('support');
|
|
22
|
+
expect(channel.createdAt).toBeInstanceOf(Date);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should create a channel with default empty metadata', () => {
|
|
26
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
27
|
+
expect(channel.metadata).toEqual({});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should send a message to a channel', () => {
|
|
31
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
32
|
+
|
|
33
|
+
const message = chatEngine.sendMessage(T1, channel.id, 'user1', 'Hello, world!');
|
|
34
|
+
|
|
35
|
+
expect(message.id).toMatch(/^msg_/);
|
|
36
|
+
expect(message.tenantId).toBe(T1);
|
|
37
|
+
expect(message.content).toBe('Hello, world!');
|
|
38
|
+
expect(message.senderId).toBe('user1');
|
|
39
|
+
expect(message.status).toBe('sent');
|
|
40
|
+
expect(message.type).toBe('text');
|
|
41
|
+
expect(message.channelId).toBe(channel.id);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should not allow non-participants to send text messages', () => {
|
|
45
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
46
|
+
|
|
47
|
+
expect(() => {
|
|
48
|
+
chatEngine.sendMessage(T1, channel.id, 'user3', 'Hello, world!');
|
|
49
|
+
}).toThrow('Sender is not a participant in this channel');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should allow system messages from non-participants', () => {
|
|
53
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
54
|
+
const msg = chatEngine.sendMessage(T1, channel.id, 'system', 'User joined', 'system');
|
|
55
|
+
expect(msg.type).toBe('system');
|
|
56
|
+
expect(msg.status).toBe('sent');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should throw when sending to a non-existent channel', () => {
|
|
60
|
+
expect(() => {
|
|
61
|
+
chatEngine.sendMessage(T1, 'ch_nonexistent', 'user1', 'Hi');
|
|
62
|
+
}).toThrow('Channel not found');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should retrieve messages for a channel in reverse order', () => {
|
|
66
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
67
|
+
|
|
68
|
+
chatEngine.sendMessage(T1, channel.id, 'user1', 'Message 1');
|
|
69
|
+
chatEngine.sendMessage(T1, channel.id, 'user2', 'Message 2');
|
|
70
|
+
chatEngine.sendMessage(T1, channel.id, 'user1', 'Message 3');
|
|
71
|
+
|
|
72
|
+
const messages = chatEngine.getMessages(T1, channel.id);
|
|
73
|
+
|
|
74
|
+
expect(messages).toHaveLength(3);
|
|
75
|
+
expect(messages[0]!.content).toBe('Message 3');
|
|
76
|
+
expect(messages[2]!.content).toBe('Message 1');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should support pagination with limit and offset', () => {
|
|
80
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
81
|
+
|
|
82
|
+
for (let i = 1; i <= 5; i++) {
|
|
83
|
+
chatEngine.sendMessage(T1, channel.id, 'user1', `Message ${i}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const page1 = chatEngine.getMessages(T1, channel.id, 2, 0);
|
|
87
|
+
expect(page1).toHaveLength(2);
|
|
88
|
+
expect(page1[0]!.content).toBe('Message 5');
|
|
89
|
+
|
|
90
|
+
const page2 = chatEngine.getMessages(T1, channel.id, 2, 2);
|
|
91
|
+
expect(page2).toHaveLength(2);
|
|
92
|
+
expect(page2[0]!.content).toBe('Message 3');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should return empty array for channel with no messages', () => {
|
|
96
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
97
|
+
expect(chatEngine.getMessages(T1, channel.id)).toHaveLength(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should mark messages as read', () => {
|
|
101
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
102
|
+
|
|
103
|
+
const msg1 = chatEngine.sendMessage(T1, channel.id, 'user1', 'Message 1');
|
|
104
|
+
const msg2 = chatEngine.sendMessage(T1, channel.id, 'user1', 'Message 2');
|
|
105
|
+
|
|
106
|
+
chatEngine.markAsRead(T1, channel.id, [msg1.id, msg2.id]);
|
|
107
|
+
|
|
108
|
+
const messages = chatEngine.getMessages(T1, channel.id);
|
|
109
|
+
expect(messages[0]!.status).toBe('read');
|
|
110
|
+
expect(messages[1]!.status).toBe('read');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should only mark specified messages as read', () => {
|
|
114
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
115
|
+
|
|
116
|
+
const msg1 = chatEngine.sendMessage(T1, channel.id, 'user1', 'Message 1');
|
|
117
|
+
chatEngine.sendMessage(T1, channel.id, 'user1', 'Message 2');
|
|
118
|
+
|
|
119
|
+
chatEngine.markAsRead(T1, channel.id, [msg1.id]);
|
|
120
|
+
|
|
121
|
+
const messages = chatEngine.getMessages(T1, channel.id);
|
|
122
|
+
expect(messages[0]!.status).toBe('sent');
|
|
123
|
+
expect(messages[1]!.status).toBe('read');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should send image message type', () => {
|
|
127
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
128
|
+
const msg = chatEngine.sendMessage(T1, channel.id, 'user1', 'https://img.url/photo.jpg', 'image');
|
|
129
|
+
expect(msg.type).toBe('image');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ─── Cross-Tenant Isolation ───────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
it('cross-tenant: tenant_B cannot send messages into tenant_A channel', () => {
|
|
135
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
136
|
+
|
|
137
|
+
expect(() => {
|
|
138
|
+
chatEngine.sendMessage(T2, channel.id, 'user1', 'Cross-tenant hack');
|
|
139
|
+
}).toThrow('Channel not found');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('cross-tenant: tenant_B getMessages returns empty for tenant_A channel', () => {
|
|
143
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
144
|
+
chatEngine.sendMessage(T1, channel.id, 'user1', 'Secret message');
|
|
145
|
+
|
|
146
|
+
const result = chatEngine.getMessages(T2, channel.id);
|
|
147
|
+
expect(result).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('cross-tenant: markAsRead by tenant_B has no effect on tenant_A messages', () => {
|
|
151
|
+
const channel = chatEngine.createChannel(T1, ['user1', 'user2']);
|
|
152
|
+
const msg = chatEngine.sendMessage(T1, channel.id, 'user1', 'Hi');
|
|
153
|
+
|
|
154
|
+
chatEngine.markAsRead(T2, channel.id, [msg.id]);
|
|
155
|
+
|
|
156
|
+
const messages = chatEngine.getMessages(T1, channel.id);
|
|
157
|
+
expect(messages[0]!.status).toBe('sent');
|
|
158
|
+
});
|
|
159
|
+
});
|