@fufulog/brevomorphic-cms-sdk 1.0.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/INTEGRATION.md +101 -0
- package/README.md +336 -0
- package/dist/brevoClient.d.ts +53 -0
- package/dist/brevoClient.js +141 -0
- package/dist/controller.d.ts +46 -0
- package/dist/controller.js +173 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/repository.d.ts +34 -0
- package/dist/repository.js +224 -0
- package/dist/service.d.ts +41 -0
- package/dist/service.js +185 -0
- package/dist/types.d.ts +69 -0
- package/dist/types.js +1 -0
- package/env.example +25 -0
- package/package.json +35 -0
- package/prd.md +135 -0
- package/react/BrevoWysiwyg.css +233 -0
- package/react/BrevoWysiwyg.tsx +388 -0
- package/src/brevoClient.ts +171 -0
- package/src/controller.ts +186 -0
- package/src/index.ts +5 -0
- package/src/repository.ts +229 -0
- package/src/service.ts +221 -0
- package/src/types.ts +77 -0
- package/svelte/BrevoWysiwyg.svelte +572 -0
- package/tests/sdk.test.ts +239 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Request, Response, Router } from 'express';
|
|
2
|
+
import { EmailTemplateService } from './service.js';
|
|
3
|
+
|
|
4
|
+
export class EmailTemplateController {
|
|
5
|
+
private service: EmailTemplateService;
|
|
6
|
+
|
|
7
|
+
constructor(service: EmailTemplateService) {
|
|
8
|
+
this.service = service;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generates a fully configured Express Router.
|
|
13
|
+
* Can be directly mounted into any Express app: `app.use('/api/cms', controller.getRouter())`
|
|
14
|
+
*/
|
|
15
|
+
getRouter(): Router {
|
|
16
|
+
const router = Router();
|
|
17
|
+
|
|
18
|
+
// Standard express mapping
|
|
19
|
+
router.get('/templates', this.listTemplates.bind(this));
|
|
20
|
+
router.get('/templates/:id', this.getTemplate.bind(this));
|
|
21
|
+
router.post('/templates', this.createTemplate.bind(this));
|
|
22
|
+
router.put('/templates/:id', this.updateTemplate.bind(this));
|
|
23
|
+
router.post('/templates/:id/toggle', this.toggleTemplate.bind(this));
|
|
24
|
+
router.get('/senders', this.listSenders.bind(this));
|
|
25
|
+
router.post('/send', this.sendEventEmail.bind(this));
|
|
26
|
+
|
|
27
|
+
return router;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* GET /templates
|
|
32
|
+
* Lists all templates synchronized from Brevo and mapped locally.
|
|
33
|
+
*/
|
|
34
|
+
async listTemplates(req: Request, res: Response): Promise<void> {
|
|
35
|
+
try {
|
|
36
|
+
const templates = await this.service.listTemplates();
|
|
37
|
+
res.status(200).json({ success: true, data: templates });
|
|
38
|
+
} catch (error: any) {
|
|
39
|
+
res.status(500).json({ success: false, error: error.message });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* GET /templates/:id
|
|
45
|
+
* Fetches a detailed template (including HTML body content) by Brevo ID.
|
|
46
|
+
*/
|
|
47
|
+
async getTemplate(req: Request, res: Response): Promise<void> {
|
|
48
|
+
try {
|
|
49
|
+
const id = parseInt(req.params.id, 10);
|
|
50
|
+
if (isNaN(id)) {
|
|
51
|
+
res.status(400).json({ success: false, error: 'Invalid template ID parameter' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const template = await this.service.getTemplate(id);
|
|
55
|
+
res.status(200).json({ success: true, data: template });
|
|
56
|
+
} catch (error: any) {
|
|
57
|
+
if (error.message.includes('not found') || error.message.includes('404')) {
|
|
58
|
+
res.status(404).json({ success: false, error: error.message });
|
|
59
|
+
} else {
|
|
60
|
+
res.status(500).json({ success: false, error: error.message });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* POST /templates
|
|
67
|
+
* Instantiates a new draft template on Brevo and registers local mapping.
|
|
68
|
+
*/
|
|
69
|
+
async createTemplate(req: Request, res: Response): Promise<void> {
|
|
70
|
+
try {
|
|
71
|
+
const { name, subject, senderEmail, senderName } = req.body;
|
|
72
|
+
const newTemplate = await this.service.createTemplate({
|
|
73
|
+
name,
|
|
74
|
+
subject,
|
|
75
|
+
senderEmail,
|
|
76
|
+
senderName,
|
|
77
|
+
});
|
|
78
|
+
res.status(201).json({ success: true, data: newTemplate });
|
|
79
|
+
} catch (error: any) {
|
|
80
|
+
res.status(500).json({ success: false, error: error.message });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* PUT /templates/:id
|
|
86
|
+
* Validates and updates a template configuration both on Brevo and the local DB.
|
|
87
|
+
*/
|
|
88
|
+
async updateTemplate(req: Request, res: Response): Promise<void> {
|
|
89
|
+
try {
|
|
90
|
+
const id = parseInt(req.params.id, 10);
|
|
91
|
+
if (isNaN(id)) {
|
|
92
|
+
res.status(400).json({ success: false, error: 'Invalid template ID parameter' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { templateName, subject, sender, htmlContent, eventName, isActive } = req.body;
|
|
97
|
+
|
|
98
|
+
const updated = await this.service.updateTemplate(id, {
|
|
99
|
+
templateName,
|
|
100
|
+
subject,
|
|
101
|
+
sender,
|
|
102
|
+
htmlContent,
|
|
103
|
+
eventName,
|
|
104
|
+
isActive: !!isActive,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
res.status(200).json({ success: true, data: updated });
|
|
108
|
+
} catch (error: any) {
|
|
109
|
+
if (
|
|
110
|
+
error.message.includes('cannot be blank') ||
|
|
111
|
+
error.message.includes('must exceed') ||
|
|
112
|
+
error.message.includes('greater than 10')
|
|
113
|
+
) {
|
|
114
|
+
res.status(400).json({ success: false, error: error.message });
|
|
115
|
+
} else {
|
|
116
|
+
res.status(500).json({ success: false, error: error.message });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* POST /templates/:id/toggle
|
|
123
|
+
* Flips active/inactive status across Brevo and DB.
|
|
124
|
+
*/
|
|
125
|
+
async toggleTemplate(req: Request, res: Response): Promise<void> {
|
|
126
|
+
try {
|
|
127
|
+
const id = parseInt(req.params.id, 10);
|
|
128
|
+
if (isNaN(id)) {
|
|
129
|
+
res.status(400).json({ success: false, error: 'Invalid template ID parameter' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const { isActive } = req.body;
|
|
134
|
+
if (isActive === undefined) {
|
|
135
|
+
res.status(400).json({ success: false, error: "Field 'isActive' boolean is required in request body" });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
await this.service.toggleTemplateActive(id, !!isActive);
|
|
140
|
+
res.status(200).json({ success: true, message: `Template status flipped to ${!!isActive}` });
|
|
141
|
+
} catch (error: any) {
|
|
142
|
+
res.status(500).json({ success: false, error: error.message });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* GET /senders
|
|
148
|
+
* Lists verified sender profiles from Brevo.
|
|
149
|
+
*/
|
|
150
|
+
async listSenders(req: Request, res: Response): Promise<void> {
|
|
151
|
+
try {
|
|
152
|
+
const senders = await this.service.getSenders();
|
|
153
|
+
res.status(200).json({ success: true, data: senders });
|
|
154
|
+
} catch (error: any) {
|
|
155
|
+
res.status(500).json({ success: false, error: error.message });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* POST /send
|
|
161
|
+
* Triggers an email send for a given backend event.
|
|
162
|
+
*/
|
|
163
|
+
async sendEventEmail(req: Request, res: Response): Promise<void> {
|
|
164
|
+
try {
|
|
165
|
+
const { eventName, recipientEmail, variables } = req.body;
|
|
166
|
+
|
|
167
|
+
if (!eventName || !recipientEmail) {
|
|
168
|
+
res.status(400).json({
|
|
169
|
+
success: false,
|
|
170
|
+
error: "Fields 'eventName' and 'recipientEmail' are required in request body",
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const outcome = await this.service.sendEventEmail(eventName, recipientEmail, variables);
|
|
176
|
+
|
|
177
|
+
if (!outcome.sent) {
|
|
178
|
+
res.status(200).json({ success: true, sent: false, message: outcome.message });
|
|
179
|
+
} else {
|
|
180
|
+
res.status(200).json({ success: true, sent: true, message: outcome.message });
|
|
181
|
+
}
|
|
182
|
+
} catch (error: any) {
|
|
183
|
+
res.status(500).json({ success: false, error: error.message });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { SDKConfig, LocalMapping } from './types.js';
|
|
2
|
+
|
|
3
|
+
export class EmailTemplateRepository {
|
|
4
|
+
private dbType: 'postgres' | 'mysql' | 'firestore';
|
|
5
|
+
private dbClient: any;
|
|
6
|
+
private tableName: string;
|
|
7
|
+
|
|
8
|
+
constructor(config: SDKConfig) {
|
|
9
|
+
this.dbType = config.dbType;
|
|
10
|
+
this.dbClient = config.dbClient;
|
|
11
|
+
this.tableName = config.tableNameOrCollection || 'email_event_templates';
|
|
12
|
+
|
|
13
|
+
if (!this.dbClient) {
|
|
14
|
+
throw new Error(`Database client is required for database type: ${this.dbType}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Helper to parse SQL queries results.
|
|
20
|
+
* Handles PostgreSQL result format (res.rows) and MySQL format ([rows, fields]).
|
|
21
|
+
*/
|
|
22
|
+
private parseSQLResult(res: any): any[] {
|
|
23
|
+
if (!res) return [];
|
|
24
|
+
if (res.rows && Array.isArray(res.rows)) {
|
|
25
|
+
return res.rows;
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(res)) {
|
|
28
|
+
if (res.length > 0 && Array.isArray(res[0])) {
|
|
29
|
+
// MySQL connection.query returns [rows, fields]
|
|
30
|
+
return res[0];
|
|
31
|
+
}
|
|
32
|
+
return res; // Flat rows array
|
|
33
|
+
}
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fetch all event-to-template mappings from the local database
|
|
39
|
+
*/
|
|
40
|
+
async getAllMappings(): Promise<LocalMapping[]> {
|
|
41
|
+
if (this.dbType === 'firestore') {
|
|
42
|
+
const snapshot = await this.dbClient.collection(this.tableName).get();
|
|
43
|
+
const mappings: LocalMapping[] = [];
|
|
44
|
+
snapshot.forEach((doc: any) => {
|
|
45
|
+
const data = doc.data();
|
|
46
|
+
mappings.push({
|
|
47
|
+
template_id: Number(data.template_id),
|
|
48
|
+
event_name: data.event_name || '',
|
|
49
|
+
is_active: !!data.is_active,
|
|
50
|
+
updated_at: data.updated_at?.toDate ? data.updated_at.toDate() : data.updated_at,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
return mappings;
|
|
54
|
+
} else {
|
|
55
|
+
const sql = `SELECT id, template_id, event_name, is_active, updated_at FROM ${this.tableName}`;
|
|
56
|
+
const res = await this.dbClient.query(sql, []);
|
|
57
|
+
const rows = this.parseSQLResult(res);
|
|
58
|
+
return rows.map((r: any) => ({
|
|
59
|
+
id: r.id,
|
|
60
|
+
template_id: Number(r.template_id),
|
|
61
|
+
event_name: r.event_name || '',
|
|
62
|
+
is_active: Boolean(r.is_active),
|
|
63
|
+
updated_at: r.updated_at,
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Fetch mapping for a specific template ID
|
|
70
|
+
*/
|
|
71
|
+
async getMappingByTemplateId(templateId: number): Promise<LocalMapping | null> {
|
|
72
|
+
if (this.dbType === 'firestore') {
|
|
73
|
+
const doc = await this.dbClient.collection(this.tableName).doc(templateId.toString()).get();
|
|
74
|
+
if (!doc.exists) return null;
|
|
75
|
+
const data = doc.data();
|
|
76
|
+
return {
|
|
77
|
+
template_id: Number(data.template_id),
|
|
78
|
+
event_name: data.event_name || '',
|
|
79
|
+
is_active: !!data.is_active,
|
|
80
|
+
updated_at: data.updated_at?.toDate ? data.updated_at.toDate() : data.updated_at,
|
|
81
|
+
};
|
|
82
|
+
} else {
|
|
83
|
+
const paramChar = this.dbType === 'postgres' ? '$1' : '?';
|
|
84
|
+
const sql = `SELECT id, template_id, event_name, is_active, updated_at FROM ${this.tableName} WHERE template_id = ${paramChar}`;
|
|
85
|
+
const res = await this.dbClient.query(sql, [templateId]);
|
|
86
|
+
const rows = this.parseSQLResult(res);
|
|
87
|
+
if (rows.length === 0) return null;
|
|
88
|
+
|
|
89
|
+
const r = rows[0];
|
|
90
|
+
return {
|
|
91
|
+
id: r.id,
|
|
92
|
+
template_id: Number(r.template_id),
|
|
93
|
+
event_name: r.event_name || '',
|
|
94
|
+
is_active: Boolean(r.is_active),
|
|
95
|
+
updated_at: r.updated_at,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fetch mapping for a specific event name.
|
|
102
|
+
* Assumes we want the active template linked to this event.
|
|
103
|
+
*/
|
|
104
|
+
async getActiveMappingByEvent(eventName: string): Promise<LocalMapping | null> {
|
|
105
|
+
if (this.dbType === 'firestore') {
|
|
106
|
+
const snapshot = await this.dbClient
|
|
107
|
+
.collection(this.tableName)
|
|
108
|
+
.where('event_name', '==', eventName)
|
|
109
|
+
.where('is_active', '==', true)
|
|
110
|
+
.limit(1)
|
|
111
|
+
.get();
|
|
112
|
+
|
|
113
|
+
if (snapshot.empty) return null;
|
|
114
|
+
const doc = snapshot.docs[0];
|
|
115
|
+
const data = doc.data();
|
|
116
|
+
return {
|
|
117
|
+
template_id: Number(data.template_id),
|
|
118
|
+
event_name: data.event_name || '',
|
|
119
|
+
is_active: !!data.is_active,
|
|
120
|
+
updated_at: data.updated_at?.toDate ? data.updated_at.toDate() : data.updated_at,
|
|
121
|
+
};
|
|
122
|
+
} else {
|
|
123
|
+
const param1 = this.dbType === 'postgres' ? '$1' : '?';
|
|
124
|
+
const param2 = this.dbType === 'postgres' ? '$2' : '?';
|
|
125
|
+
// Wait, is_active could be 1/0 or true/false, standard database handles boolean conversion, but is_active = true or is_active = 1
|
|
126
|
+
const sql = `SELECT id, template_id, event_name, is_active, updated_at FROM ${this.tableName} WHERE event_name = ${param1} AND is_active = ${param2} LIMIT 1`;
|
|
127
|
+
|
|
128
|
+
// Pass both parameters for portability
|
|
129
|
+
const res = await this.dbClient.query(sql, [eventName, true]);
|
|
130
|
+
const rows = this.parseSQLResult(res);
|
|
131
|
+
if (rows.length === 0) return null;
|
|
132
|
+
|
|
133
|
+
const r = rows[0];
|
|
134
|
+
return {
|
|
135
|
+
id: r.id,
|
|
136
|
+
template_id: Number(r.template_id),
|
|
137
|
+
event_name: r.event_name || '',
|
|
138
|
+
is_active: Boolean(r.is_active),
|
|
139
|
+
updated_at: r.updated_at,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Upsert a mapping (insert or update template config)
|
|
146
|
+
*/
|
|
147
|
+
async upsertMapping(templateId: number, eventName: string, isActive: boolean): Promise<void> {
|
|
148
|
+
if (this.dbType === 'firestore') {
|
|
149
|
+
await this.dbClient
|
|
150
|
+
.collection(this.tableName)
|
|
151
|
+
.doc(templateId.toString())
|
|
152
|
+
.set(
|
|
153
|
+
{
|
|
154
|
+
template_id: templateId,
|
|
155
|
+
event_name: eventName,
|
|
156
|
+
is_active: isActive,
|
|
157
|
+
updated_at: new Date(),
|
|
158
|
+
},
|
|
159
|
+
{ merge: true }
|
|
160
|
+
);
|
|
161
|
+
} else {
|
|
162
|
+
let sql = '';
|
|
163
|
+
let params: any[] = [];
|
|
164
|
+
if (this.dbType === 'postgres') {
|
|
165
|
+
sql = `
|
|
166
|
+
INSERT INTO ${this.tableName} (template_id, event_name, is_active, updated_at)
|
|
167
|
+
VALUES ($1, $2, $3, CURRENT_TIMESTAMP)
|
|
168
|
+
ON CONFLICT (template_id)
|
|
169
|
+
DO UPDATE SET
|
|
170
|
+
event_name = EXCLUDED.event_name,
|
|
171
|
+
is_active = EXCLUDED.is_active,
|
|
172
|
+
updated_at = CURRENT_TIMESTAMP
|
|
173
|
+
`;
|
|
174
|
+
params = [templateId, eventName, isActive];
|
|
175
|
+
} else {
|
|
176
|
+
// MySQL ON DUPLICATE KEY UPDATE
|
|
177
|
+
sql = `
|
|
178
|
+
INSERT INTO ${this.tableName} (template_id, event_name, is_active, updated_at)
|
|
179
|
+
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
|
180
|
+
ON DUPLICATE KEY UPDATE
|
|
181
|
+
event_name = VALUES(event_name),
|
|
182
|
+
is_active = VALUES(is_active),
|
|
183
|
+
updated_at = CURRENT_TIMESTAMP
|
|
184
|
+
`;
|
|
185
|
+
params = [templateId, eventName, isActive];
|
|
186
|
+
}
|
|
187
|
+
await this.dbClient.query(sql, params);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Just-In-Time (JIT) Local Mapping Generation.
|
|
193
|
+
* If mapping does not exist, insert blank inactive mapping.
|
|
194
|
+
*/
|
|
195
|
+
async insertJITMapping(templateId: number): Promise<void> {
|
|
196
|
+
if (this.dbType === 'firestore') {
|
|
197
|
+
const docRef = this.dbClient.collection(this.tableName).doc(templateId.toString());
|
|
198
|
+
const doc = await docRef.get();
|
|
199
|
+
if (!doc.exists) {
|
|
200
|
+
await docRef.set({
|
|
201
|
+
template_id: templateId,
|
|
202
|
+
event_name: '',
|
|
203
|
+
is_active: false,
|
|
204
|
+
updated_at: new Date(),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
let sql = '';
|
|
209
|
+
let params: any[] = [];
|
|
210
|
+
if (this.dbType === 'postgres') {
|
|
211
|
+
sql = `
|
|
212
|
+
INSERT INTO ${this.tableName} (template_id, event_name, is_active, updated_at)
|
|
213
|
+
VALUES ($1, $2, $3, CURRENT_TIMESTAMP)
|
|
214
|
+
ON CONFLICT (template_id)
|
|
215
|
+
DO NOTHING
|
|
216
|
+
`;
|
|
217
|
+
params = [templateId, '', false];
|
|
218
|
+
} else {
|
|
219
|
+
// MySQL INSERT IGNORE
|
|
220
|
+
sql = `
|
|
221
|
+
INSERT IGNORE INTO ${this.tableName} (template_id, event_name, is_active, updated_at)
|
|
222
|
+
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
|
223
|
+
`;
|
|
224
|
+
params = [templateId, '', false];
|
|
225
|
+
}
|
|
226
|
+
await this.dbClient.query(sql, params);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { BrevoClient } from './brevoClient.js';
|
|
2
|
+
import { EmailTemplateRepository } from './repository.js';
|
|
3
|
+
import { SDKConfig, CombinedTemplate, CreateTemplateInput, UpdateTemplateInput, BrevoSender } from './types.js';
|
|
4
|
+
|
|
5
|
+
export class EmailTemplateService {
|
|
6
|
+
private brevoClient: BrevoClient;
|
|
7
|
+
private repository: EmailTemplateRepository;
|
|
8
|
+
private defaultSender: { name?: string; email: string };
|
|
9
|
+
|
|
10
|
+
constructor(config: SDKConfig) {
|
|
11
|
+
this.brevoClient = new BrevoClient(config.brevoApiKey);
|
|
12
|
+
this.repository = new EmailTemplateRepository(config);
|
|
13
|
+
this.defaultSender = config.defaultSender;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Syncs and returns all templates with their event mappings.
|
|
18
|
+
* Performs Just-In-Time (JIT) mapping creation for any templates missing in local DB.
|
|
19
|
+
*/
|
|
20
|
+
async listTemplates(): Promise<CombinedTemplate[]> {
|
|
21
|
+
// 1. Fetch remote templates from Brevo
|
|
22
|
+
const brevoTemplates = await this.brevoClient.getTemplates();
|
|
23
|
+
|
|
24
|
+
// 2. Fetch local mappings
|
|
25
|
+
const localMappings = await this.repository.getAllMappings();
|
|
26
|
+
const mappingMap = new Map(localMappings.map((m) => [m.template_id, m]));
|
|
27
|
+
|
|
28
|
+
const combined: CombinedTemplate[] = [];
|
|
29
|
+
|
|
30
|
+
for (const bt of brevoTemplates) {
|
|
31
|
+
let mapping = mappingMap.get(bt.id);
|
|
32
|
+
|
|
33
|
+
// 3. JIT Mapping Creation: If template exists on Brevo but not locally
|
|
34
|
+
if (!mapping) {
|
|
35
|
+
await this.repository.insertJITMapping(bt.id);
|
|
36
|
+
mapping = {
|
|
37
|
+
template_id: bt.id,
|
|
38
|
+
event_name: '',
|
|
39
|
+
is_active: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
combined.push({
|
|
44
|
+
templateId: bt.id,
|
|
45
|
+
templateName: bt.name,
|
|
46
|
+
subject: bt.subject,
|
|
47
|
+
eventName: mapping.event_name,
|
|
48
|
+
isActive: bt.isActive && mapping.is_active, // Sync state
|
|
49
|
+
sender: bt.sender,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return combined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Fetch a single combined template by ID.
|
|
58
|
+
* Creates JIT mapping if it is not in local DB.
|
|
59
|
+
*/
|
|
60
|
+
async getTemplate(id: number): Promise<CombinedTemplate> {
|
|
61
|
+
const bt = await this.brevoClient.getTemplate(id);
|
|
62
|
+
let mapping = await this.repository.getMappingByTemplateId(id);
|
|
63
|
+
|
|
64
|
+
if (!mapping) {
|
|
65
|
+
await this.repository.insertJITMapping(id);
|
|
66
|
+
mapping = {
|
|
67
|
+
template_id: id,
|
|
68
|
+
event_name: '',
|
|
69
|
+
is_active: false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
templateId: bt.id,
|
|
75
|
+
templateName: bt.name,
|
|
76
|
+
subject: bt.subject,
|
|
77
|
+
eventName: mapping.event_name,
|
|
78
|
+
isActive: bt.isActive && mapping.is_active,
|
|
79
|
+
sender: bt.sender,
|
|
80
|
+
htmlContent: bt.htmlContent,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a new Brevo Template and its corresponding local DB mapping
|
|
86
|
+
*/
|
|
87
|
+
async createTemplate(input: CreateTemplateInput): Promise<CombinedTemplate> {
|
|
88
|
+
const name = input.name || 'New Untitled Template';
|
|
89
|
+
const subject = input.subject || 'Drafting Layout';
|
|
90
|
+
const senderEmail = input.senderEmail || this.defaultSender.email;
|
|
91
|
+
const senderName = input.senderName || this.defaultSender.name;
|
|
92
|
+
|
|
93
|
+
const htmlContent = '<html><body><p>Drafting...</p></body></html>';
|
|
94
|
+
|
|
95
|
+
// 1. Create on Brevo
|
|
96
|
+
const templateId = await this.brevoClient.createTemplate({
|
|
97
|
+
templateName: name,
|
|
98
|
+
subject: subject,
|
|
99
|
+
sender: { name: senderName, email: senderEmail },
|
|
100
|
+
htmlContent: htmlContent,
|
|
101
|
+
isActive: false,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 2. Insert mapping in local DB
|
|
105
|
+
await this.repository.upsertMapping(templateId, '', false);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
templateId: templateId,
|
|
109
|
+
templateName: name,
|
|
110
|
+
subject: subject,
|
|
111
|
+
eventName: '',
|
|
112
|
+
isActive: false,
|
|
113
|
+
sender: { name: senderName || '', email: senderEmail },
|
|
114
|
+
htmlContent: htmlContent,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Update an existing template in Brevo and the local mapping DB
|
|
120
|
+
*/
|
|
121
|
+
async updateTemplate(id: number, input: UpdateTemplateInput): Promise<CombinedTemplate> {
|
|
122
|
+
// Constraint: Template name cannot be blank
|
|
123
|
+
if (!input.templateName || input.templateName.trim() === '') {
|
|
124
|
+
throw new Error('Template name cannot be blank');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Constraint: HTML content must exceed 10 characters
|
|
128
|
+
if (!input.htmlContent || input.htmlContent.length <= 10) {
|
|
129
|
+
throw new Error('HTML content must be greater than 10 characters');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const sender: BrevoSender = {
|
|
133
|
+
email: input.sender?.email || this.defaultSender.email,
|
|
134
|
+
name: input.sender?.name || this.defaultSender.name || '',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// 1. Update Brevo template configuration
|
|
138
|
+
await this.brevoClient.updateTemplate(id, {
|
|
139
|
+
templateName: input.templateName,
|
|
140
|
+
subject: input.subject,
|
|
141
|
+
sender: sender,
|
|
142
|
+
htmlContent: input.htmlContent,
|
|
143
|
+
isActive: input.isActive,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// 2. Update local mapping DB
|
|
147
|
+
await this.repository.upsertMapping(id, input.eventName || '', input.isActive);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
templateId: id,
|
|
151
|
+
templateName: input.templateName,
|
|
152
|
+
subject: input.subject,
|
|
153
|
+
eventName: input.eventName || '',
|
|
154
|
+
isActive: input.isActive,
|
|
155
|
+
sender: sender,
|
|
156
|
+
htmlContent: input.htmlContent,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Sync template active status across remote Brevo and local DB
|
|
162
|
+
*/
|
|
163
|
+
async toggleTemplateActive(id: number, isActive: boolean): Promise<void> {
|
|
164
|
+
// 1. Retrieve the existing Brevo template details so we can re-submit them securely
|
|
165
|
+
const bt = await this.brevoClient.getTemplate(id);
|
|
166
|
+
|
|
167
|
+
// 2. Flip remote status
|
|
168
|
+
await this.brevoClient.updateTemplate(id, {
|
|
169
|
+
templateName: bt.name,
|
|
170
|
+
subject: bt.subject,
|
|
171
|
+
sender: bt.sender || this.defaultSender,
|
|
172
|
+
htmlContent: bt.htmlContent || '',
|
|
173
|
+
isActive: isActive,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// 3. Update local DB mapping status
|
|
177
|
+
const mapping = await this.repository.getMappingByTemplateId(id);
|
|
178
|
+
const eventName = mapping ? mapping.event_name : '';
|
|
179
|
+
await this.repository.upsertMapping(id, eventName, isActive);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Retrieve list of verified sender profiles
|
|
184
|
+
*/
|
|
185
|
+
async getSenders(): Promise<BrevoSender[]> {
|
|
186
|
+
return this.brevoClient.getSenders();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Application-facing transactional sender logic:
|
|
191
|
+
* Event triggers query mapping database, checks status, and routes transactional send to Brevo
|
|
192
|
+
*/
|
|
193
|
+
async sendEventEmail(
|
|
194
|
+
eventName: string,
|
|
195
|
+
recipientEmail: string,
|
|
196
|
+
variables?: Record<string, any>
|
|
197
|
+
): Promise<{ sent: boolean; message: string }> {
|
|
198
|
+
// 1. Find mapped template configuration
|
|
199
|
+
const mapping = await this.repository.getActiveMappingByEvent(eventName);
|
|
200
|
+
|
|
201
|
+
// 2. Guardrail: If template is disabled (isActive = false), event engine completely ignores it
|
|
202
|
+
if (!mapping || !mapping.is_active) {
|
|
203
|
+
return {
|
|
204
|
+
sent: false,
|
|
205
|
+
message: `Event '${eventName}' skipped: No active template mapped to this event.`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 3. Push transactional template send to Brevo API
|
|
210
|
+
await this.brevoClient.sendEmail({
|
|
211
|
+
templateId: mapping.template_id,
|
|
212
|
+
to: recipientEmail,
|
|
213
|
+
variables: variables,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
sent: true,
|
|
218
|
+
message: `Successfully sent email for event '${eventName}' using template #${mapping.template_id}.`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|