-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathairtable.service.js
More file actions
250 lines (220 loc) · 9.06 KB
/
airtable.service.js
File metadata and controls
250 lines (220 loc) · 9.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
/**
* Airtable Service - Utility for fetching OTP codes from Airtable
*
* Usage:
* node airtable.service.js
*
* Set environment variables:
* AIRTABLE_API_TOKEN - Your Airtable Personal Access Token
* AIRTABLE_BASE_ID - The Base ID (starts with 'app')
* AIRTABLE_TABLE_NAME - The table name (e.g., 'OTP' or 'OTP Records')
*/
const https = require('https');
const { TIMEOUT, PAUSE, REGEX } = require('./constants');
// Configuration - use getter functions to read env vars lazily (after wdio.conf.js loads .env)
const getAirtableConfig = () => {
const config = {
apiToken: process.env.AIRTABLE_API_TOKEN || 'YOUR_TOKEN_HERE',
baseId: process.env.AIRTABLE_BASE_ID || 'YOUR_BASE_ID_HERE',
tableName: process.env.AIRTABLE_TABLE_NAME || 'YOUR_TABLE_NAME_HERE',
fromEmail: process.env.AIRTABLE_FROM_EMAIL || 'AIRTABLE_FROM_EMAIL_HERE'
};
return config;
};
/**
* Makes a GET request to Airtable API
* @param {string} endpoint - API endpoint path
* @returns {Promise<object>} - Parsed JSON response
*/
function airtableRequest(endpoint) {
const config = getAirtableConfig();
return new Promise((resolve, reject) => {
const options = {
hostname: 'api.airtable.com',
path: endpoint,
method: 'GET',
headers: {
'Authorization': `Bearer ${config.apiToken}`,
'Content-Type': 'application/json'
}
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(data));
} else {
reject(new Error(`Airtable API error: ${res.statusCode} - ${data}`));
}
});
});
req.on('error', reject);
req.end();
});
}
/**
* Makes a PATCH request to Airtable API to update a record
* @param {string} recordId - The Airtable record ID
* @param {object} fields - The fields to update
* @returns {Promise<object>} - Updated record
*/
function updateRecord(recordId, fields) {
const config = getAirtableConfig();
return new Promise((resolve, reject) => {
const encodedTableName = encodeURIComponent(config.tableName);
const postData = JSON.stringify({ fields });
const options = {
hostname: 'api.airtable.com',
path: `/v0/${config.baseId}/${encodedTableName}/${recordId}`,
method: 'PATCH',
headers: {
'Authorization': `Bearer ${config.apiToken}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
}
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(data));
} else {
reject(new Error(`Airtable API error: ${res.statusCode} - ${data}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
/**
* Updates the Status field of a record to "Processed"
* @param {string} recordId - The Airtable record ID
* @returns {Promise<object>} - Updated record
*/
async function markAsProcessed(recordId) {
console.info(` Marking record ${recordId} as Processed...`);
try {
const result = await updateRecord(recordId, { Status: 'Processed' });
console.info(` ✓ Record marked as Processed`);
return result;
} catch (error) {
console.error(` ⚠ Failed to mark as Processed: ${error.message}`);
throw error;
}
}
/**
* Extracts OTP code from email body text
* @param {string} bodyText - The plain text body of the email
* @returns {string|null} - The 6-digit OTP code or null if not found
*/
function extractOTPFromBody(bodyText) {
const match = bodyText.match(REGEX.OTP_FROM_EMAIL_BODY);
return match ? match[1] : null;
}
/**
* Fetches the latest OTP for a given email address
* @param {string} email - The recipient email address to search for
* @returns {Promise<{otp: string, record: object}|null>} - OTP and record, or null if not found
*/
async function fetchOTPByEmail(email) {
const config = getAirtableConfig();
console.info(`\n=== Fetching OTP for: ${email} ===`);
console.info(` From Email filter: ${config.fromEmail}\n`);
try {
const encodedTableName = encodeURIComponent(config.tableName);
// Filter by "To Emails", "From Email" fields, exclude Processed, sort by "Created At" descending to get latest
const filterFormula = encodeURIComponent(
`AND({To Emails}='${email}', {From Email}='${config.fromEmail}', {Status}!='Processed')`
);
const sortField = encodeURIComponent('Created At');
const endpoint = `/v0/${config.baseId}/${encodedTableName}?filterByFormula=${filterFormula}&sort[0][field]=${sortField}&sort[0][direction]=desc&maxRecords=1`;
const response = await airtableRequest(endpoint);
if (response.records && response.records.length > 0) {
const record = response.records[0];
const bodyPlain = record.fields['Body Plain'] || '';
const otp = extractOTPFromBody(bodyPlain);
if (otp) {
console.info(`✓ Found OTP: ${otp}`);
console.info(` Created At: ${record.fields['Created At']}`);
console.info(` Subject: ${record.fields['Subject']}`);
// Mark record as Processed
await markAsProcessed(record.id);
return { otp, record };
} else {
console.info('⚠ Record found but no OTP code in body');
return null;
}
} else {
console.info(`⚠ No email records found for: ${email}`);
return null;
}
} catch (error) {
console.error('✗ Error fetching OTP:', error.message);
throw error;
}
}
/**
* Waits for OTP to appear in Airtable for a given email
* Polls at regular intervals until OTP is found or timeout
* @param {string} email - The recipient email address to search for
* @param {number} timeoutMs - Maximum time to wait in milliseconds (default: 60000 = 1 minute)
* @param {number} pollIntervalMs - Time between polls in milliseconds (default: 5000 = 5 seconds)
* @param {Date} afterTime - Only consider records created after this time (default: now)
* @returns {Promise<{otp: string, record: object}>} - OTP and record
* @throws {Error} - If timeout is reached without finding OTP
*/
async function waitForOTP(
email,
timeoutMs = TIMEOUT.OTP_WAIT_MAX,
pollIntervalMs = PAUSE.OTP_POLL_INTERVAL,
afterTime = new Date(),
) {
const config = getAirtableConfig();
console.info(`\n=== Waiting for OTP for: ${email} ===`);
console.info(` From Email filter: ${config.fromEmail}`);
console.info(` Timeout: ${timeoutMs / 1000}s, Poll interval: ${pollIntervalMs / 1000}s`);
console.info(` Looking for records after: ${afterTime.toISOString()}\n`);
const startTime = Date.now();
let attempts = 0;
while (Date.now() - startTime < timeoutMs) {
attempts++;
console.info(` Attempt ${attempts}...`);
try {
const encodedTableName = encodeURIComponent(config.tableName);
// Filter by email, from email, exclude Processed, AND created after the specified time
const filterFormula = encodeURIComponent(
`AND({To Emails}='${email}', {From Email}='${config.fromEmail}', {Status}!='Processed', IS_AFTER({Created At}, '${afterTime.toISOString()}'))`
);
const sortField = encodeURIComponent('Created At');
const endpoint = `/v0/${config.baseId}/${encodedTableName}?filterByFormula=${filterFormula}&sort[0][field]=${sortField}&sort[0][direction]=desc&maxRecords=1`;
const response = await airtableRequest(endpoint);
if (response.records && response.records.length > 0) {
const record = response.records[0];
const bodyPlain = record.fields['Body Plain'] || '';
const otp = extractOTPFromBody(bodyPlain);
if (otp) {
console.info(`\n✓ OTP found after ${attempts} attempt(s): ${otp}`);
// Mark record as Processed
await markAsProcessed(record.id);
return { otp, record };
}
}
} catch (error) {
console.info(` Error on attempt ${attempts}: ${error.message}`);
}
// Wait before next poll
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
}
throw new Error(`Timeout: No OTP found for ${email} after ${timeoutMs / 1000} seconds`);
}
module.exports = {
fetchOTPByEmail,
waitForOTP,
extractOTPFromBody,
markAsProcessed,
updateRecord,
};