@vobase/core 0.9.0 → 0.11.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/package.json +9 -10
- package/src/__tests__/drizzle-introspection.test.ts +77 -0
- package/src/__tests__/e2e.test.ts +225 -0
- package/src/__tests__/permissions.test.ts +157 -0
- package/src/__tests__/rpc-types.test.ts +92 -0
- package/src/app.test.ts +99 -0
- package/src/app.ts +178 -0
- package/src/audit.test.ts +126 -0
- package/src/auth.test.ts +74 -0
- package/src/contracts/auth.ts +37 -0
- package/{dist/contracts/module.d.ts → src/contracts/module.ts} +6 -6
- package/src/contracts/notify.ts +47 -0
- package/src/contracts/permissions.ts +10 -0
- package/src/contracts/storage.ts +61 -0
- package/src/ctx.test.ts +162 -0
- package/src/ctx.ts +64 -0
- package/src/db/client.test.ts +75 -0
- package/src/db/client.ts +15 -0
- package/src/db/helpers.test.ts +147 -0
- package/src/db/helpers.ts +51 -0
- package/src/db/index.ts +8 -0
- package/{dist/index.d.ts → src/index.ts} +105 -6
- package/src/infra/circuit-breaker.test.ts +74 -0
- package/src/infra/circuit-breaker.ts +57 -0
- package/src/infra/errors.test.ts +175 -0
- package/src/infra/errors.ts +64 -0
- package/src/infra/http-client.test.ts +482 -0
- package/src/infra/http-client.ts +221 -0
- package/src/infra/index.ts +35 -0
- package/src/infra/job.test.ts +85 -0
- package/src/infra/job.ts +94 -0
- package/src/infra/logger.test.ts +65 -0
- package/src/infra/logger.ts +18 -0
- package/src/infra/queue.test.ts +46 -0
- package/src/infra/queue.ts +147 -0
- package/src/infra/throw-proxy.test.ts +34 -0
- package/src/infra/throw-proxy.ts +17 -0
- package/src/infra/webhooks-schema.ts +17 -0
- package/src/infra/webhooks.test.ts +364 -0
- package/src/infra/webhooks.ts +146 -0
- package/src/mcp/auth.test.ts +129 -0
- package/src/mcp/crud.test.ts +128 -0
- package/src/mcp/crud.ts +171 -0
- package/{dist/mcp/index.d.ts → src/mcp/index.ts} +0 -1
- package/src/mcp/server.test.ts +153 -0
- package/src/mcp/server.ts +178 -0
- package/src/middleware/audit.test.ts +169 -0
- package/src/module-registry.ts +18 -0
- package/src/module.test.ts +168 -0
- package/src/module.ts +111 -0
- package/src/modules/audit/index.ts +18 -0
- package/src/modules/audit/middleware.ts +33 -0
- package/src/modules/audit/schema.ts +35 -0
- package/src/modules/audit/track-changes.ts +70 -0
- package/src/modules/auth/audit-hooks.ts +74 -0
- package/src/modules/auth/index.ts +101 -0
- package/src/modules/auth/middleware.ts +51 -0
- package/src/modules/auth/permissions.ts +46 -0
- package/src/modules/auth/schema.ts +184 -0
- package/src/modules/credentials/encrypt.ts +95 -0
- package/src/modules/credentials/index.ts +15 -0
- package/src/modules/credentials/schema.ts +10 -0
- package/src/modules/notify/index.ts +90 -0
- package/src/modules/notify/notify.test.ts +145 -0
- package/src/modules/notify/providers/resend.ts +47 -0
- package/src/modules/notify/providers/smtp.ts +117 -0
- package/src/modules/notify/providers/waba.ts +82 -0
- package/src/modules/notify/schema.ts +27 -0
- package/src/modules/notify/service.ts +93 -0
- package/src/modules/sequences/index.ts +15 -0
- package/src/modules/sequences/next-sequence.ts +48 -0
- package/src/modules/sequences/schema.ts +12 -0
- package/src/modules/storage/index.ts +44 -0
- package/src/modules/storage/providers/local.ts +124 -0
- package/src/modules/storage/providers/s3.ts +83 -0
- package/src/modules/storage/routes.ts +76 -0
- package/src/modules/storage/schema.ts +26 -0
- package/src/modules/storage/service.ts +202 -0
- package/src/modules/storage/storage.test.ts +225 -0
- package/src/schemas.test.ts +44 -0
- package/src/schemas.ts +63 -0
- package/src/sequence.test.ts +56 -0
- package/dist/app.d.ts +0 -37
- package/dist/app.d.ts.map +0 -1
- package/dist/contracts/auth.d.ts +0 -35
- package/dist/contracts/auth.d.ts.map +0 -1
- package/dist/contracts/module.d.ts.map +0 -1
- package/dist/contracts/notify.d.ts +0 -46
- package/dist/contracts/notify.d.ts.map +0 -1
- package/dist/contracts/permissions.d.ts +0 -10
- package/dist/contracts/permissions.d.ts.map +0 -1
- package/dist/contracts/storage.d.ts +0 -54
- package/dist/contracts/storage.d.ts.map +0 -1
- package/dist/ctx.d.ts +0 -40
- package/dist/ctx.d.ts.map +0 -1
- package/dist/db/client.d.ts +0 -4
- package/dist/db/client.d.ts.map +0 -1
- package/dist/db/helpers.d.ts +0 -26
- package/dist/db/helpers.d.ts.map +0 -1
- package/dist/db/index.d.ts +0 -3
- package/dist/db/index.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -89026
- package/dist/infra/circuit-breaker.d.ts +0 -17
- package/dist/infra/circuit-breaker.d.ts.map +0 -1
- package/dist/infra/errors.d.ts +0 -26
- package/dist/infra/errors.d.ts.map +0 -1
- package/dist/infra/http-client.d.ts +0 -31
- package/dist/infra/http-client.d.ts.map +0 -1
- package/dist/infra/index.d.ts +0 -11
- package/dist/infra/index.d.ts.map +0 -1
- package/dist/infra/job.d.ts +0 -14
- package/dist/infra/job.d.ts.map +0 -1
- package/dist/infra/logger.d.ts +0 -7
- package/dist/infra/logger.d.ts.map +0 -1
- package/dist/infra/queue.d.ts +0 -18
- package/dist/infra/queue.d.ts.map +0 -1
- package/dist/infra/throw-proxy.d.ts +0 -7
- package/dist/infra/throw-proxy.d.ts.map +0 -1
- package/dist/infra/webhooks-schema.d.ts +0 -60
- package/dist/infra/webhooks-schema.d.ts.map +0 -1
- package/dist/infra/webhooks.d.ts +0 -46
- package/dist/infra/webhooks.d.ts.map +0 -1
- package/dist/mcp/crud.d.ts +0 -12
- package/dist/mcp/crud.d.ts.map +0 -1
- package/dist/mcp/index.d.ts.map +0 -1
- package/dist/mcp/server.d.ts +0 -10
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/module-registry.d.ts +0 -3
- package/dist/module-registry.d.ts.map +0 -1
- package/dist/module.d.ts +0 -29
- package/dist/module.d.ts.map +0 -1
- package/dist/modules/audit/index.d.ts +0 -5
- package/dist/modules/audit/index.d.ts.map +0 -1
- package/dist/modules/audit/middleware.d.ts +0 -3
- package/dist/modules/audit/middleware.d.ts.map +0 -1
- package/dist/modules/audit/schema.d.ts +0 -247
- package/dist/modules/audit/schema.d.ts.map +0 -1
- package/dist/modules/audit/track-changes.d.ts +0 -3
- package/dist/modules/audit/track-changes.d.ts.map +0 -1
- package/dist/modules/auth/audit-hooks.d.ts +0 -6
- package/dist/modules/auth/audit-hooks.d.ts.map +0 -1
- package/dist/modules/auth/index.d.ts +0 -17
- package/dist/modules/auth/index.d.ts.map +0 -1
- package/dist/modules/auth/middleware.d.ts +0 -15
- package/dist/modules/auth/middleware.d.ts.map +0 -1
- package/dist/modules/auth/permissions.d.ts +0 -5
- package/dist/modules/auth/permissions.d.ts.map +0 -1
- package/dist/modules/auth/schema.d.ts +0 -2519
- package/dist/modules/auth/schema.d.ts.map +0 -1
- package/dist/modules/credentials/encrypt.d.ts +0 -12
- package/dist/modules/credentials/encrypt.d.ts.map +0 -1
- package/dist/modules/credentials/index.d.ts +0 -4
- package/dist/modules/credentials/index.d.ts.map +0 -1
- package/dist/modules/credentials/schema.d.ts +0 -56
- package/dist/modules/credentials/schema.d.ts.map +0 -1
- package/dist/modules/notify/index.d.ts +0 -36
- package/dist/modules/notify/index.d.ts.map +0 -1
- package/dist/modules/notify/providers/resend.d.ts +0 -7
- package/dist/modules/notify/providers/resend.d.ts.map +0 -1
- package/dist/modules/notify/providers/smtp.d.ts +0 -18
- package/dist/modules/notify/providers/smtp.d.ts.map +0 -1
- package/dist/modules/notify/providers/waba.d.ts +0 -12
- package/dist/modules/notify/providers/waba.d.ts.map +0 -1
- package/dist/modules/notify/schema.d.ts +0 -337
- package/dist/modules/notify/schema.d.ts.map +0 -1
- package/dist/modules/notify/service.d.ts +0 -22
- package/dist/modules/notify/service.d.ts.map +0 -1
- package/dist/modules/sequences/index.d.ts +0 -4
- package/dist/modules/sequences/index.d.ts.map +0 -1
- package/dist/modules/sequences/next-sequence.d.ts +0 -8
- package/dist/modules/sequences/next-sequence.d.ts.map +0 -1
- package/dist/modules/sequences/schema.d.ts +0 -72
- package/dist/modules/sequences/schema.d.ts.map +0 -1
- package/dist/modules/storage/index.d.ts +0 -24
- package/dist/modules/storage/index.d.ts.map +0 -1
- package/dist/modules/storage/providers/local.d.ts +0 -3
- package/dist/modules/storage/providers/local.d.ts.map +0 -1
- package/dist/modules/storage/providers/s3.d.ts +0 -3
- package/dist/modules/storage/providers/s3.d.ts.map +0 -1
- package/dist/modules/storage/routes.d.ts +0 -4
- package/dist/modules/storage/routes.d.ts.map +0 -1
- package/dist/modules/storage/schema.d.ts +0 -273
- package/dist/modules/storage/schema.d.ts.map +0 -1
- package/dist/modules/storage/service.d.ts +0 -35
- package/dist/modules/storage/service.d.ts.map +0 -1
- package/dist/schemas.d.ts +0 -19
- package/dist/schemas.d.ts.map +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
3
|
+
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
|
4
|
+
|
|
5
|
+
import type { VobaseDb } from '../../db/client';
|
|
6
|
+
import type { EmailProvider, EmailMessage, EmailResult } from '../../contracts/notify';
|
|
7
|
+
import { createNotifyService } from './service';
|
|
8
|
+
import { createResendProvider } from './providers/resend';
|
|
9
|
+
import * as notifySchemaModule from './schema';
|
|
10
|
+
|
|
11
|
+
function createTestDb(): { db: VobaseDb; sqlite: Database } {
|
|
12
|
+
const sqlite = new Database(':memory:');
|
|
13
|
+
sqlite.run('PRAGMA journal_mode=WAL');
|
|
14
|
+
sqlite.exec(`
|
|
15
|
+
CREATE TABLE _notify_log (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
channel TEXT NOT NULL,
|
|
18
|
+
provider TEXT NOT NULL,
|
|
19
|
+
"to" TEXT NOT NULL,
|
|
20
|
+
subject TEXT,
|
|
21
|
+
template TEXT,
|
|
22
|
+
provider_message_id TEXT,
|
|
23
|
+
status TEXT NOT NULL DEFAULT 'sent',
|
|
24
|
+
error TEXT,
|
|
25
|
+
created_at INTEGER NOT NULL
|
|
26
|
+
)
|
|
27
|
+
`);
|
|
28
|
+
const db = drizzle({ client: sqlite, schema: notifySchemaModule }) as unknown as VobaseDb;
|
|
29
|
+
return { db, sqlite };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createMockEmailProvider(result: EmailResult): EmailProvider {
|
|
33
|
+
return {
|
|
34
|
+
async send(_message: EmailMessage) {
|
|
35
|
+
return result;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('NotifyService', () => {
|
|
41
|
+
let db: VobaseDb;
|
|
42
|
+
let sqlite: Database;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
const result = createTestDb();
|
|
46
|
+
db = result.db;
|
|
47
|
+
sqlite = result.sqlite;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
sqlite.close();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('sends email and logs success', async () => {
|
|
55
|
+
const provider = createMockEmailProvider({ success: true, messageId: 'msg-123' });
|
|
56
|
+
const svc = createNotifyService({
|
|
57
|
+
db,
|
|
58
|
+
emailProvider: provider,
|
|
59
|
+
emailProviderName: 'resend',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const result = await svc.email.send({
|
|
63
|
+
to: 'user@test.com',
|
|
64
|
+
subject: 'Test',
|
|
65
|
+
text: 'Hello',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result.success).toBe(true);
|
|
69
|
+
expect(result.messageId).toBe('msg-123');
|
|
70
|
+
|
|
71
|
+
const rows = sqlite.prepare('SELECT * FROM _notify_log').all() as Array<Record<string, unknown>>;
|
|
72
|
+
expect(rows).toHaveLength(1);
|
|
73
|
+
expect(rows[0].channel).toBe('email');
|
|
74
|
+
expect(rows[0].provider).toBe('resend');
|
|
75
|
+
expect(rows[0].to).toBe('user@test.com');
|
|
76
|
+
expect(rows[0].subject).toBe('Test');
|
|
77
|
+
expect(rows[0].status).toBe('sent');
|
|
78
|
+
expect(rows[0].provider_message_id).toBe('msg-123');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('logs failure when email send fails', async () => {
|
|
82
|
+
const provider = createMockEmailProvider({ success: false, error: 'API error' });
|
|
83
|
+
const svc = createNotifyService({
|
|
84
|
+
db,
|
|
85
|
+
emailProvider: provider,
|
|
86
|
+
emailProviderName: 'resend',
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const result = await svc.email.send({
|
|
90
|
+
to: 'user@test.com',
|
|
91
|
+
subject: 'Fail test',
|
|
92
|
+
text: 'Hello',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(result.success).toBe(false);
|
|
96
|
+
expect(result.error).toBe('API error');
|
|
97
|
+
|
|
98
|
+
const rows = sqlite.prepare('SELECT * FROM _notify_log').all() as Array<Record<string, unknown>>;
|
|
99
|
+
expect(rows).toHaveLength(1);
|
|
100
|
+
expect(rows[0].status).toBe('failed');
|
|
101
|
+
expect(rows[0].error).toBe('API error');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('throws when accessing unconfigured whatsapp channel', () => {
|
|
105
|
+
const svc = createNotifyService({
|
|
106
|
+
db,
|
|
107
|
+
emailProvider: createMockEmailProvider({ success: true }),
|
|
108
|
+
emailProviderName: 'resend',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(() => svc.whatsapp.send).toThrow();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('throws when accessing unconfigured email channel', () => {
|
|
115
|
+
const svc = createNotifyService({ db });
|
|
116
|
+
|
|
117
|
+
expect(() => svc.email.send).toThrow();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('handles multiple recipients', async () => {
|
|
121
|
+
const provider = createMockEmailProvider({ success: true, messageId: 'multi-123' });
|
|
122
|
+
const svc = createNotifyService({
|
|
123
|
+
db,
|
|
124
|
+
emailProvider: provider,
|
|
125
|
+
emailProviderName: 'resend',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await svc.email.send({
|
|
129
|
+
to: ['a@test.com', 'b@test.com'],
|
|
130
|
+
subject: 'Multi',
|
|
131
|
+
text: 'Hello all',
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const rows = sqlite.prepare('SELECT "to" FROM _notify_log').all() as Array<Record<string, unknown>>;
|
|
135
|
+
expect(rows[0].to).toBe('a@test.com,b@test.com');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('Resend Provider', () => {
|
|
140
|
+
it('returns a provider with send method', () => {
|
|
141
|
+
const provider = createResendProvider({ apiKey: 'test-key', from: 'test@test.com' });
|
|
142
|
+
expect(provider).toHaveProperty('send');
|
|
143
|
+
expect(typeof provider.send).toBe('function');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { EmailProvider, EmailMessage, EmailResult } from '../../../contracts/notify';
|
|
2
|
+
|
|
3
|
+
export interface ResendConfig {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
from: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createResendProvider(config: ResendConfig): EmailProvider {
|
|
9
|
+
return {
|
|
10
|
+
async send(message: EmailMessage): Promise<EmailResult> {
|
|
11
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch('https://api.resend.com/emails', {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: {
|
|
17
|
+
'Authorization': `Bearer ${config.apiKey}`,
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify({
|
|
21
|
+
from: message.from ?? config.from,
|
|
22
|
+
to,
|
|
23
|
+
subject: message.subject,
|
|
24
|
+
html: message.html,
|
|
25
|
+
text: message.text,
|
|
26
|
+
cc: message.cc,
|
|
27
|
+
bcc: message.bcc,
|
|
28
|
+
reply_to: message.replyTo,
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const body = await response.text();
|
|
34
|
+
return { success: false, error: `Resend API error (${response.status}): ${body}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const data = (await response.json()) as { id?: string };
|
|
38
|
+
return { success: true, messageId: data.id };
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return {
|
|
41
|
+
success: false,
|
|
42
|
+
error: err instanceof Error ? err.message : String(err),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { EmailProvider, EmailMessage, EmailResult } from '../../../contracts/notify';
|
|
2
|
+
|
|
3
|
+
export interface SmtpConfig {
|
|
4
|
+
host: string;
|
|
5
|
+
port: number;
|
|
6
|
+
secure?: boolean;
|
|
7
|
+
auth?: { user: string; pass: string };
|
|
8
|
+
from: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Minimal SMTP email provider using Bun's TCP socket.
|
|
13
|
+
* Handles basic SMTP conversation (EHLO, AUTH, MAIL FROM, RCPT TO, DATA, QUIT).
|
|
14
|
+
* For production use with complex SMTP requirements, consider a dedicated library.
|
|
15
|
+
*/
|
|
16
|
+
export function createSmtpProvider(config: SmtpConfig): EmailProvider {
|
|
17
|
+
async function sendSmtp(message: EmailMessage): Promise<EmailResult> {
|
|
18
|
+
const to = Array.isArray(message.to) ? message.to : [message.to];
|
|
19
|
+
const from = message.from ?? config.from;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const socket = await Bun.connect({
|
|
23
|
+
hostname: config.host,
|
|
24
|
+
port: config.port,
|
|
25
|
+
tls: config.secure ?? config.port === 465,
|
|
26
|
+
socket: {
|
|
27
|
+
data(_socket, data) {
|
|
28
|
+
responses.push(new TextDecoder().decode(data));
|
|
29
|
+
},
|
|
30
|
+
open() {},
|
|
31
|
+
close() {},
|
|
32
|
+
error(_socket, err) {
|
|
33
|
+
lastError = err;
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const responses: string[] = [];
|
|
39
|
+
let lastError: Error | undefined;
|
|
40
|
+
|
|
41
|
+
const send = (cmd: string) => {
|
|
42
|
+
socket.write(new TextEncoder().encode(`${cmd}\r\n`));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const wait = (ms = 500) => new Promise<void>((r) => setTimeout(r, ms));
|
|
46
|
+
|
|
47
|
+
// Wait for greeting
|
|
48
|
+
await wait(300);
|
|
49
|
+
|
|
50
|
+
send(`EHLO localhost`);
|
|
51
|
+
await wait(200);
|
|
52
|
+
|
|
53
|
+
// AUTH if configured
|
|
54
|
+
if (config.auth) {
|
|
55
|
+
const credentials = Buffer.from(`\0${config.auth.user}\0${config.auth.pass}`).toString('base64');
|
|
56
|
+
send(`AUTH PLAIN ${credentials}`);
|
|
57
|
+
await wait(200);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
send(`MAIL FROM:<${from}>`);
|
|
61
|
+
await wait(100);
|
|
62
|
+
|
|
63
|
+
for (const recipient of to) {
|
|
64
|
+
send(`RCPT TO:<${recipient}>`);
|
|
65
|
+
await wait(100);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
send('DATA');
|
|
69
|
+
await wait(100);
|
|
70
|
+
|
|
71
|
+
// Build email headers and body
|
|
72
|
+
const headers = [
|
|
73
|
+
`From: ${from}`,
|
|
74
|
+
`To: ${to.join(', ')}`,
|
|
75
|
+
`Subject: ${message.subject}`,
|
|
76
|
+
`Date: ${new Date().toUTCString()}`,
|
|
77
|
+
`MIME-Version: 1.0`,
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
if (message.cc?.length) {
|
|
81
|
+
headers.push(`Cc: ${message.cc.join(', ')}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (message.replyTo) {
|
|
85
|
+
headers.push(`Reply-To: ${message.replyTo}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (message.html) {
|
|
89
|
+
headers.push('Content-Type: text/html; charset=utf-8');
|
|
90
|
+
send(`${headers.join('\r\n')}\r\n\r\n${message.html}\r\n.`);
|
|
91
|
+
} else {
|
|
92
|
+
headers.push('Content-Type: text/plain; charset=utf-8');
|
|
93
|
+
send(`${headers.join('\r\n')}\r\n\r\n${message.text ?? ''}\r\n.`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await wait(200);
|
|
97
|
+
|
|
98
|
+
send('QUIT');
|
|
99
|
+
await wait(100);
|
|
100
|
+
|
|
101
|
+
socket.end();
|
|
102
|
+
|
|
103
|
+
if (lastError) {
|
|
104
|
+
return { success: false, error: lastError.message };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { success: true };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return {
|
|
110
|
+
success: false,
|
|
111
|
+
error: err instanceof Error ? err.message : String(err),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { send: sendSmtp };
|
|
117
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { WhatsAppProvider, WhatsAppMessage, WhatsAppResult } from '../../../contracts/notify';
|
|
2
|
+
|
|
3
|
+
export interface WabaConfig {
|
|
4
|
+
phoneNumberId: string;
|
|
5
|
+
accessToken: string;
|
|
6
|
+
apiVersion?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* WhatsApp Business API (WABA) provider.
|
|
11
|
+
* Uses the Graph API to send template and text messages.
|
|
12
|
+
*/
|
|
13
|
+
export function createWabaProvider(config: WabaConfig): WhatsAppProvider {
|
|
14
|
+
const apiVersion = config.apiVersion ?? 'v21.0';
|
|
15
|
+
const baseUrl = `https://graph.facebook.com/${apiVersion}/${config.phoneNumberId}/messages`;
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
async send(message: WhatsAppMessage): Promise<WhatsAppResult> {
|
|
19
|
+
try {
|
|
20
|
+
let body: Record<string, unknown>;
|
|
21
|
+
|
|
22
|
+
if (message.template) {
|
|
23
|
+
// Template message
|
|
24
|
+
const components = message.template.parameters?.length
|
|
25
|
+
? [{
|
|
26
|
+
type: 'body',
|
|
27
|
+
parameters: message.template.parameters.map((p) => ({
|
|
28
|
+
type: 'text',
|
|
29
|
+
text: p,
|
|
30
|
+
})),
|
|
31
|
+
}]
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
|
+
body = {
|
|
35
|
+
messaging_product: 'whatsapp',
|
|
36
|
+
to: message.to,
|
|
37
|
+
type: 'template',
|
|
38
|
+
template: {
|
|
39
|
+
name: message.template.name,
|
|
40
|
+
language: { code: message.template.language },
|
|
41
|
+
...(components && { components }),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
} else if (message.text) {
|
|
45
|
+
// Text message
|
|
46
|
+
body = {
|
|
47
|
+
messaging_product: 'whatsapp',
|
|
48
|
+
to: message.to,
|
|
49
|
+
type: 'text',
|
|
50
|
+
text: { body: message.text },
|
|
51
|
+
};
|
|
52
|
+
} else {
|
|
53
|
+
return { success: false, error: 'Message must have either template or text' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const response = await fetch(baseUrl, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'Authorization': `Bearer ${config.accessToken}`,
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify(body),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
const errorBody = await response.text();
|
|
67
|
+
return { success: false, error: `WABA API error (${response.status}): ${errorBody}` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const data = (await response.json()) as { messages?: Array<{ id: string }> };
|
|
71
|
+
const messageId = data.messages?.[0]?.id;
|
|
72
|
+
|
|
73
|
+
return { success: true, messageId };
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return {
|
|
76
|
+
success: false,
|
|
77
|
+
error: err instanceof Error ? err.message : String(err),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
|
|
3
|
+
import { nanoidPrimaryKey } from '../../db/helpers';
|
|
4
|
+
|
|
5
|
+
export const notifyLog = sqliteTable(
|
|
6
|
+
'_notify_log',
|
|
7
|
+
{
|
|
8
|
+
id: nanoidPrimaryKey(),
|
|
9
|
+
channel: text('channel').notNull(),
|
|
10
|
+
provider: text('provider').notNull(),
|
|
11
|
+
to: text('to').notNull(),
|
|
12
|
+
subject: text('subject'),
|
|
13
|
+
template: text('template'),
|
|
14
|
+
providerMessageId: text('provider_message_id'),
|
|
15
|
+
status: text('status').notNull().default('sent'),
|
|
16
|
+
error: text('error'),
|
|
17
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
18
|
+
.notNull()
|
|
19
|
+
.$defaultFn(() => new Date()),
|
|
20
|
+
},
|
|
21
|
+
(table) => [
|
|
22
|
+
index('notify_log_channel_idx').on(table.channel),
|
|
23
|
+
index('notify_log_status_idx').on(table.status),
|
|
24
|
+
],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export const notifySchema = { notifyLog };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { VobaseDb } from '../../db/client';
|
|
2
|
+
import type {
|
|
3
|
+
EmailProvider,
|
|
4
|
+
EmailMessage,
|
|
5
|
+
EmailResult,
|
|
6
|
+
WhatsAppProvider,
|
|
7
|
+
WhatsAppMessage,
|
|
8
|
+
WhatsAppResult,
|
|
9
|
+
} from '../../contracts/notify';
|
|
10
|
+
import { createThrowProxy } from '../../infra/throw-proxy';
|
|
11
|
+
import { notifyLog } from './schema';
|
|
12
|
+
|
|
13
|
+
export interface EmailChannel {
|
|
14
|
+
send(message: EmailMessage): Promise<EmailResult>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WhatsAppChannel {
|
|
18
|
+
send(message: WhatsAppMessage): Promise<WhatsAppResult>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NotifyService {
|
|
22
|
+
email: EmailChannel;
|
|
23
|
+
whatsapp: WhatsAppChannel;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface NotifyServiceDeps {
|
|
27
|
+
db: VobaseDb;
|
|
28
|
+
emailProvider?: EmailProvider;
|
|
29
|
+
emailProviderName?: string;
|
|
30
|
+
whatsappProvider?: WhatsAppProvider;
|
|
31
|
+
whatsappProviderName?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function logNotification(
|
|
35
|
+
db: VobaseDb,
|
|
36
|
+
channel: string,
|
|
37
|
+
provider: string,
|
|
38
|
+
to: string,
|
|
39
|
+
result: { success: boolean; messageId?: string; error?: string },
|
|
40
|
+
extra?: { subject?: string; template?: string },
|
|
41
|
+
) {
|
|
42
|
+
db.insert(notifyLog)
|
|
43
|
+
.values({
|
|
44
|
+
channel,
|
|
45
|
+
provider,
|
|
46
|
+
to,
|
|
47
|
+
subject: extra?.subject ?? null,
|
|
48
|
+
template: extra?.template ?? null,
|
|
49
|
+
providerMessageId: result.messageId ?? null,
|
|
50
|
+
status: result.success ? 'sent' : 'failed',
|
|
51
|
+
error: result.error ?? null,
|
|
52
|
+
})
|
|
53
|
+
.run();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createNotifyService(deps: NotifyServiceDeps): NotifyService {
|
|
57
|
+
const { db } = deps;
|
|
58
|
+
|
|
59
|
+
const email: EmailChannel = deps.emailProvider
|
|
60
|
+
? (() => {
|
|
61
|
+
const provider = deps.emailProvider;
|
|
62
|
+
const providerName = deps.emailProviderName ?? 'unknown';
|
|
63
|
+
return {
|
|
64
|
+
async send(message) {
|
|
65
|
+
const result = await provider.send(message);
|
|
66
|
+
const to = Array.isArray(message.to) ? message.to.join(',') : message.to;
|
|
67
|
+
logNotification(db, 'email', providerName, to, result, {
|
|
68
|
+
subject: message.subject,
|
|
69
|
+
});
|
|
70
|
+
return result;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
})()
|
|
74
|
+
: createThrowProxy<EmailChannel>('email notify channel');
|
|
75
|
+
|
|
76
|
+
const whatsapp: WhatsAppChannel = deps.whatsappProvider
|
|
77
|
+
? (() => {
|
|
78
|
+
const provider = deps.whatsappProvider;
|
|
79
|
+
const providerName = deps.whatsappProviderName ?? 'unknown';
|
|
80
|
+
return {
|
|
81
|
+
async send(message) {
|
|
82
|
+
const result = await provider.send(message);
|
|
83
|
+
logNotification(db, 'whatsapp', providerName, message.to, result, {
|
|
84
|
+
template: message.template?.name,
|
|
85
|
+
});
|
|
86
|
+
return result;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
})()
|
|
90
|
+
: createThrowProxy<WhatsAppChannel>('WhatsApp notify channel');
|
|
91
|
+
|
|
92
|
+
return { email, whatsapp };
|
|
93
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { defineBuiltinModule } from '../../module';
|
|
3
|
+
import { sequences } from './schema';
|
|
4
|
+
|
|
5
|
+
export { sequences } from './schema';
|
|
6
|
+
export { nextSequence, type SequenceOptions } from './next-sequence';
|
|
7
|
+
|
|
8
|
+
export function createSequencesModule() {
|
|
9
|
+
return defineBuiltinModule({
|
|
10
|
+
name: '_sequences',
|
|
11
|
+
schema: { sequences },
|
|
12
|
+
routes: new Hono(),
|
|
13
|
+
init: () => {},
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createNanoid } from '../../db/helpers';
|
|
2
|
+
import type { VobaseDb } from '../../db/client';
|
|
3
|
+
|
|
4
|
+
export interface SequenceOptions {
|
|
5
|
+
padLength?: number;
|
|
6
|
+
separator?: string;
|
|
7
|
+
yearPrefix?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const generateSequenceId = createNanoid();
|
|
11
|
+
|
|
12
|
+
interface SequenceRow {
|
|
13
|
+
currentValue: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function nextSequence(
|
|
17
|
+
db: VobaseDb,
|
|
18
|
+
prefix: string,
|
|
19
|
+
options?: SequenceOptions,
|
|
20
|
+
): string {
|
|
21
|
+
const padLength = options?.padLength ?? 4;
|
|
22
|
+
const separator = options?.separator ?? '-';
|
|
23
|
+
const yearPrefix = options?.yearPrefix ?? false;
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
|
|
26
|
+
const statement = db.$client.prepare(`
|
|
27
|
+
INSERT INTO _sequences (id, prefix, current_value, updated_at)
|
|
28
|
+
VALUES (?, ?, 1, ?)
|
|
29
|
+
ON CONFLICT (prefix) DO UPDATE
|
|
30
|
+
SET current_value = current_value + 1, updated_at = ?
|
|
31
|
+
RETURNING current_value AS currentValue;
|
|
32
|
+
`);
|
|
33
|
+
|
|
34
|
+
const row = statement.get(generateSequenceId(), prefix, now, now) as
|
|
35
|
+
| SequenceRow
|
|
36
|
+
| undefined;
|
|
37
|
+
if (!row) {
|
|
38
|
+
throw new Error(`Failed to generate next sequence for prefix: ${prefix}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const formattedValue = String(row.currentValue).padStart(padLength, '0');
|
|
42
|
+
if (yearPrefix) {
|
|
43
|
+
const year = new Date(now).getFullYear();
|
|
44
|
+
return `${prefix}${separator}${year}${separator}${formattedValue}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return `${prefix}${separator}${formattedValue}`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
import { nanoidPrimaryKey } from '../../db/helpers';
|
|
3
|
+
|
|
4
|
+
export const sequences = sqliteTable('_sequences', {
|
|
5
|
+
id: nanoidPrimaryKey(),
|
|
6
|
+
prefix: text('prefix').notNull().unique(),
|
|
7
|
+
currentValue: integer('current_value').notNull().default(0),
|
|
8
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
|
9
|
+
.notNull()
|
|
10
|
+
.$defaultFn(() => new Date())
|
|
11
|
+
.$onUpdate(() => new Date()),
|
|
12
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { defineBuiltinModule } from '../../module';
|
|
2
|
+
import { createLocalProvider } from './providers/local';
|
|
3
|
+
import { createS3Provider } from './providers/s3';
|
|
4
|
+
import { createStorageRoutes } from './routes';
|
|
5
|
+
import { createStorageService, type StorageService, type BucketConfig } from './service';
|
|
6
|
+
import { storageSchema } from './schema';
|
|
7
|
+
import type { StorageProviderConfig } from '../../contracts/storage';
|
|
8
|
+
import type { VobaseDb } from '../../db/client';
|
|
9
|
+
|
|
10
|
+
export interface StorageModuleConfig {
|
|
11
|
+
provider: StorageProviderConfig;
|
|
12
|
+
buckets: Record<string, BucketConfig>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createStorageModule(db: VobaseDb, config: StorageModuleConfig) {
|
|
16
|
+
let service: StorageService;
|
|
17
|
+
|
|
18
|
+
if (config.provider.type === 'local') {
|
|
19
|
+
const provider = createLocalProvider(config.provider);
|
|
20
|
+
service = createStorageService(provider, config.buckets, db);
|
|
21
|
+
} else if (config.provider.type === 's3') {
|
|
22
|
+
const provider = createS3Provider(config.provider);
|
|
23
|
+
service = createStorageService(provider, config.buckets, db);
|
|
24
|
+
} else {
|
|
25
|
+
throw new Error(`Unknown storage provider type`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const routes = createStorageRoutes(service);
|
|
29
|
+
|
|
30
|
+
const mod = defineBuiltinModule({
|
|
31
|
+
name: '_storage',
|
|
32
|
+
schema: storageSchema,
|
|
33
|
+
routes,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return { ...mod, service };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { storageObjects, storageSchema } from './schema';
|
|
40
|
+
export { createStorageService } from './service';
|
|
41
|
+
export type { StorageService, BucketConfig, BucketHandle, StorageObject, BucketListOptions } from './service';
|
|
42
|
+
export { createLocalProvider } from './providers/local';
|
|
43
|
+
export { createS3Provider } from './providers/s3';
|
|
44
|
+
export { createStorageRoutes } from './routes';
|