@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.
@@ -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,5 @@
1
+ export { BrevoClient } from './brevoClient.js';
2
+ export { EmailTemplateRepository } from './repository.js';
3
+ export { EmailTemplateService } from './service.js';
4
+ export { EmailTemplateController } from './controller.js';
5
+ export * from './types.js';
@@ -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
+ }