Tristan Yu commited on
Commit
b09004c
·
1 Parent(s): 45fbaf2

✨ Add Airtable integration for permanent student data storage

Browse files

- Add AirtableService module for database operations
- Update all API endpoints to use Airtable when configured
- Maintain backward compatibility with file-based storage
- Auto-import existing 38 examples to Airtable on first run
- Support all CRUD operations with Airtable backend
- Perfect for classroom use with persistent student submissions

server/airtable-service.js ADDED
@@ -0,0 +1,281 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const Airtable = require('airtable');
2
+
3
+ class AirtableService {
4
+ constructor() {
5
+ // Configure Airtable
6
+ this.apiKey = process.env.AIRTABLE_API_KEY;
7
+ this.baseId = process.env.AIRTABLE_BASE_ID;
8
+ this.tableName = process.env.AIRTABLE_TABLE_NAME || 'Examples';
9
+
10
+ if (!this.apiKey || !this.baseId) {
11
+ console.warn('⚠️ Airtable not configured. Using fallback storage.');
12
+ this.isConfigured = false;
13
+ return;
14
+ }
15
+
16
+ this.base = new Airtable({ apiKey: this.apiKey }).base(this.baseId);
17
+ this.table = this.base(this.tableName);
18
+ this.isConfigured = true;
19
+
20
+ console.log('✅ Airtable service initialized');
21
+ }
22
+
23
+ // Convert Airtable record to our app format
24
+ formatRecord(record) {
25
+ const fields = record.fields;
26
+ return {
27
+ id: record.id,
28
+ english: fields.English || '',
29
+ mainland: fields.Mainland || '',
30
+ taiwan: fields.Taiwan || '',
31
+ brand: fields.Brand || '',
32
+ category: fields.Category || '',
33
+ description: fields.Description || '',
34
+ type: fields.Type || 'slogan',
35
+ culturalNote: fields.CulturalNote || '',
36
+ dateAdded: fields.DateAdded || new Date().toISOString(),
37
+ source: fields.Source || 'Student Submission',
38
+ status: fields.Status || 'pending',
39
+ contributor: fields.Contributor || '',
40
+ lastModified: fields.LastModified || fields.DateAdded || new Date().toISOString()
41
+ };
42
+ }
43
+
44
+ // Convert our app format to Airtable format
45
+ formatForAirtable(example) {
46
+ return {
47
+ English: example.english,
48
+ Mainland: example.mainland,
49
+ Taiwan: example.taiwan,
50
+ Brand: example.brand,
51
+ Category: example.category,
52
+ Description: example.description || '',
53
+ Type: example.type || 'slogan',
54
+ CulturalNote: example.culturalNote || '',
55
+ DateAdded: example.dateAdded || new Date().toISOString(),
56
+ Source: example.source || 'Student Submission',
57
+ Status: example.status || 'pending',
58
+ Contributor: example.contributor || '',
59
+ LastModified: new Date().toISOString()
60
+ };
61
+ }
62
+
63
+ // Get all examples
64
+ async getAllExamples() {
65
+ if (!this.isConfigured) {
66
+ throw new Error('Airtable not configured');
67
+ }
68
+
69
+ try {
70
+ const records = await this.table.select({
71
+ view: 'Grid view',
72
+ sort: [{ field: 'DateAdded', direction: 'asc' }]
73
+ }).all();
74
+
75
+ return records.map(record => this.formatRecord(record));
76
+ } catch (error) {
77
+ console.error('❌ Failed to get examples from Airtable:', error);
78
+ throw error;
79
+ }
80
+ }
81
+
82
+ // Get examples with filters
83
+ async getExamples(filters = {}) {
84
+ if (!this.isConfigured) {
85
+ throw new Error('Airtable not configured');
86
+ }
87
+
88
+ try {
89
+ let filterFormula = '';
90
+ const filterConditions = [];
91
+
92
+ if (filters.category) {
93
+ filterConditions.push(`{Category} = "${filters.category}"`);
94
+ }
95
+ if (filters.type) {
96
+ filterConditions.push(`{Type} = "${filters.type}"`);
97
+ }
98
+ if (filters.status) {
99
+ filterConditions.push(`{Status} = "${filters.status}"`);
100
+ }
101
+
102
+ if (filterConditions.length > 0) {
103
+ filterFormula = filterConditions.length === 1
104
+ ? filterConditions[0]
105
+ : `AND(${filterConditions.join(', ')})`;
106
+ }
107
+
108
+ const selectOptions = {
109
+ view: 'Grid view',
110
+ sort: [{ field: 'DateAdded', direction: 'asc' }]
111
+ };
112
+
113
+ if (filterFormula) {
114
+ selectOptions.filterByFormula = filterFormula;
115
+ }
116
+
117
+ const records = await this.table.select(selectOptions).all();
118
+ return records.map(record => this.formatRecord(record));
119
+ } catch (error) {
120
+ console.error('❌ Failed to get filtered examples:', error);
121
+ throw error;
122
+ }
123
+ }
124
+
125
+ // Add new example
126
+ async addExample(example) {
127
+ if (!this.isConfigured) {
128
+ throw new Error('Airtable not configured');
129
+ }
130
+
131
+ try {
132
+ const airtableData = this.formatForAirtable(example);
133
+ const record = await this.table.create(airtableData);
134
+
135
+ console.log(`✅ Added example to Airtable: ${example.brand} - ${example.english}`);
136
+ return this.formatRecord(record);
137
+ } catch (error) {
138
+ console.error('❌ Failed to add example to Airtable:', error);
139
+ throw error;
140
+ }
141
+ }
142
+
143
+ // Update example
144
+ async updateExample(id, updates) {
145
+ if (!this.isConfigured) {
146
+ throw new Error('Airtable not configured');
147
+ }
148
+
149
+ try {
150
+ const airtableData = this.formatForAirtable({
151
+ ...updates,
152
+ lastModified: new Date().toISOString()
153
+ });
154
+
155
+ const record = await this.table.update(id, airtableData);
156
+ console.log(`✅ Updated example in Airtable: ${id}`);
157
+ return this.formatRecord(record);
158
+ } catch (error) {
159
+ console.error('❌ Failed to update example in Airtable:', error);
160
+ throw error;
161
+ }
162
+ }
163
+
164
+ // Delete example
165
+ async deleteExample(id) {
166
+ if (!this.isConfigured) {
167
+ throw new Error('Airtable not configured');
168
+ }
169
+
170
+ try {
171
+ await this.table.destroy(id);
172
+ console.log(`✅ Deleted example from Airtable: ${id}`);
173
+ return { success: true };
174
+ } catch (error) {
175
+ console.error('❌ Failed to delete example from Airtable:', error);
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ // Search examples
181
+ async searchExamples(searchTerm) {
182
+ if (!this.isConfigured) {
183
+ throw new Error('Airtable not configured');
184
+ }
185
+
186
+ try {
187
+ const searchTermLower = searchTerm.toLowerCase();
188
+ const filterFormula = `OR(
189
+ SEARCH("${searchTerm}", {English}),
190
+ SEARCH("${searchTerm}", {Mainland}),
191
+ SEARCH("${searchTerm}", {Taiwan}),
192
+ SEARCH("${searchTerm}", {Brand}),
193
+ SEARCH("${searchTerm}", {Category}),
194
+ SEARCH("${searchTerm}", {Description})
195
+ )`;
196
+
197
+ const records = await this.table.select({
198
+ filterByFormula: filterFormula,
199
+ sort: [{ field: 'DateAdded', direction: 'desc' }]
200
+ }).all();
201
+
202
+ return records.map(record => this.formatRecord(record));
203
+ } catch (error) {
204
+ console.error('❌ Failed to search examples in Airtable:', error);
205
+ throw error;
206
+ }
207
+ }
208
+
209
+ // Bulk import examples (for initial setup)
210
+ async bulkImport(examples) {
211
+ if (!this.isConfigured) {
212
+ throw new Error('Airtable not configured');
213
+ }
214
+
215
+ try {
216
+ console.log(`📦 Starting bulk import of ${examples.length} examples...`);
217
+
218
+ // Airtable API allows max 10 records per batch
219
+ const batchSize = 10;
220
+ const results = [];
221
+
222
+ for (let i = 0; i < examples.length; i += batchSize) {
223
+ const batch = examples.slice(i, i + batchSize);
224
+ const airtableRecords = batch.map(example => ({
225
+ fields: this.formatForAirtable(example)
226
+ }));
227
+
228
+ const batchResults = await this.table.create(airtableRecords);
229
+ results.push(...batchResults);
230
+
231
+ console.log(`📦 Imported batch ${Math.floor(i/batchSize) + 1}/${Math.ceil(examples.length/batchSize)}`);
232
+
233
+ // Small delay between batches to avoid rate limits
234
+ if (i + batchSize < examples.length) {
235
+ await new Promise(resolve => setTimeout(resolve, 200));
236
+ }
237
+ }
238
+
239
+ console.log(`✅ Bulk import completed: ${results.length} examples imported`);
240
+ return results.map(record => this.formatRecord(record));
241
+ } catch (error) {
242
+ console.error('❌ Bulk import failed:', error);
243
+ throw error;
244
+ }
245
+ }
246
+
247
+ // Get statistics
248
+ async getStats() {
249
+ if (!this.isConfigured) {
250
+ throw new Error('Airtable not configured');
251
+ }
252
+
253
+ try {
254
+ const examples = await this.getAllExamples();
255
+
256
+ const stats = {
257
+ total: examples.length,
258
+ byCategory: {},
259
+ byType: {},
260
+ byStatus: {},
261
+ recent: examples.filter(ex => {
262
+ const daysSinceAdded = (new Date() - new Date(ex.dateAdded)) / (1000 * 60 * 60 * 24);
263
+ return daysSinceAdded <= 7;
264
+ }).length
265
+ };
266
+
267
+ examples.forEach(example => {
268
+ stats.byCategory[example.category] = (stats.byCategory[example.category] || 0) + 1;
269
+ stats.byType[example.type] = (stats.byType[example.type] || 0) + 1;
270
+ stats.byStatus[example.status] = (stats.byStatus[example.status] || 0) + 1;
271
+ });
272
+
273
+ return stats;
274
+ } catch (error) {
275
+ console.error('❌ Failed to get stats from Airtable:', error);
276
+ throw error;
277
+ }
278
+ }
279
+ }
280
+
281
+ module.exports = AirtableService;
server/index.js CHANGED
@@ -6,6 +6,7 @@ const path = require('path');
6
  const fs = require('fs').promises;
7
  const cron = require('node-cron');
8
  const { v4: uuidv4 } = require('uuid');
 
9
  require('dotenv').config();
10
 
11
  const app = express();
@@ -19,7 +20,10 @@ if (process.env.NODE_ENV === 'production') {
19
  app.use(express.static(path.join(__dirname, '../client/build')));
20
  }
21
 
22
- // In-memory storage for examples (in production, you'd use a database)
 
 
 
23
  let transcreationExamples = [];
24
 
25
  // Load cached examples from file on startup
@@ -567,12 +571,40 @@ const simulateOnlineSearch = async (category = '', maxResults = 5) => {
567
 
568
  // Initialize cache on startup and load all examples if none exist
569
  const initializeServer = async () => {
570
- await loadCachedExamples();
571
-
572
- // If no examples are cached, automatically load all 38 examples
573
- if (transcreationExamples.length === 0) {
574
- console.log('🔍 No cached examples found, loading all 38 examples...');
575
- await searchTranscreationExamples('', 38);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
  }
577
  };
578
 
@@ -581,44 +613,73 @@ initializeServer();
581
  // API Routes
582
 
583
  // Get all examples
584
- app.get('/api/examples', (req, res) => {
585
- const { category, type, random } = req.query;
586
- let examples = [...transcreationExamples];
 
587
 
588
- // Filter by category
589
- if (category) {
590
- examples = examples.filter(ex =>
591
- ex.category.toLowerCase().includes(category.toLowerCase())
592
- );
593
- }
 
 
 
 
594
 
595
- // Filter by type
596
- if (type) {
597
- examples = examples.filter(ex => ex.type === type);
598
- }
 
 
599
 
600
- // Randomize if requested
601
- if (random === 'true') {
602
- examples = examples.sort(() => Math.random() - 0.5);
603
- }
 
604
 
605
- res.json({
606
- success: true,
607
- data: examples,
608
- total: examples.length
609
- });
 
 
 
 
 
 
 
 
 
 
 
 
610
  });
611
 
612
  // Get random example (or search for new one if cache is low)
613
  app.get('/api/examples/random', async (req, res) => {
614
  try {
615
- // If we have few examples, try to find more
616
- if (transcreationExamples.length < 5) {
617
- console.log('🔍 Cache low, searching for new examples...');
618
- await searchTranscreationExamples('', 10);
 
 
 
 
 
 
 
 
 
619
  }
620
 
621
- if (transcreationExamples.length === 0) {
622
  return res.json({
623
  success: true,
624
  data: null,
@@ -626,14 +687,15 @@ app.get('/api/examples/random', async (req, res) => {
626
  });
627
  }
628
 
629
- const randomIndex = Math.floor(Math.random() * transcreationExamples.length);
630
- const example = transcreationExamples[randomIndex];
631
 
632
  res.json({
633
  success: true,
634
  data: example
635
  });
636
  } catch (error) {
 
637
  res.status(500).json({
638
  success: false,
639
  error: 'Failed to get random example'
@@ -665,85 +727,160 @@ app.post('/api/examples/search-online', async (req, res) => {
665
  });
666
 
667
  // Get example by ID
668
- app.get('/api/examples/:id', (req, res) => {
669
- const example = transcreationExamples.find(ex => ex.id === req.params.id);
670
-
671
- if (!example) {
672
- return res.status(404).json({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  success: false,
674
- error: 'Example not found'
675
  });
676
  }
677
-
678
- res.json({
679
- success: true,
680
- data: example
681
- });
682
  });
683
 
684
  // Get categories
685
- app.get('/api/categories', (req, res) => {
686
- const categories = [...new Set(transcreationExamples.map(ex => ex.category))];
687
-
688
- res.json({
689
- success: true,
690
- data: categories
691
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
692
  });
693
 
694
  // Get types
695
- app.get('/api/types', (req, res) => {
696
- const types = [...new Set(transcreationExamples.map(ex => ex.type))];
697
-
698
- res.json({
699
- success: true,
700
- data: types
701
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  });
703
 
704
  // Search examples
705
- app.get('/api/search', (req, res) => {
706
- const { q } = req.query;
707
-
708
- if (!q) {
709
- return res.json({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
  success: true,
711
- data: [],
712
- total: 0
 
 
 
 
 
 
713
  });
714
  }
715
-
716
- const searchTerm = q.toLowerCase();
717
- const results = transcreationExamples.filter(ex =>
718
- ex.english.toLowerCase().includes(searchTerm) ||
719
- ex.mainland.toLowerCase().includes(searchTerm) ||
720
- ex.taiwan?.toLowerCase().includes(searchTerm) ||
721
- ex.brand.toLowerCase().includes(searchTerm) ||
722
- ex.category.toLowerCase().includes(searchTerm) ||
723
- ex.description?.toLowerCase().includes(searchTerm)
724
- );
725
-
726
- res.json({
727
- success: true,
728
- data: results,
729
- total: results.length
730
- });
731
  });
732
 
733
  // Get database stats
734
- app.get('/api/stats', (req, res) => {
735
- const stats = {
736
- totalExamples: transcreationExamples.length,
737
- categories: [...new Set(transcreationExamples.map(ex => ex.category))].length,
738
- types: [...new Set(transcreationExamples.map(ex => ex.type))].length,
739
- lastUpdated: transcreationExamples.length > 0 ?
740
- Math.max(...transcreationExamples.map(ex => new Date(ex.dateAdded || Date.now()).getTime())) : null
741
- };
 
 
 
 
 
 
 
742
 
743
- res.json({
744
- success: true,
745
- data: stats
746
- });
 
 
 
 
 
 
 
747
  });
748
 
749
  // Health check
@@ -807,13 +944,22 @@ app.post('/api/examples/add', async (req, res) => {
807
  };
808
 
809
  console.log('✨ Created new example:', JSON.stringify(newExample, null, 2));
810
- transcreationExamples.push(newExample);
811
- await saveCachedExamples();
 
 
 
 
 
 
 
 
 
812
 
813
  res.json({
814
  success: true,
815
  message: 'Example added successfully',
816
- example: newExample
817
  });
818
  } catch (error) {
819
  console.error('❌ Error adding example:', error);
@@ -832,9 +978,18 @@ app.put('/api/examples/:id', async (req, res) => {
832
  console.log(`📝 UPDATE REQUEST for ID: ${id}`);
833
  console.log(`📝 REQUEST BODY:`, JSON.stringify(req.body, null, 2));
834
 
835
- const index = transcreationExamples.findIndex(ex => ex.id === id);
 
 
 
 
 
 
 
 
 
836
 
837
- if (index === -1) {
838
  console.log(`❌ Example with ID ${id} not found`);
839
  return res.status(404).json({
840
  success: false,
@@ -842,7 +997,7 @@ app.put('/api/examples/:id', async (req, res) => {
842
  });
843
  }
844
 
845
- console.log(`📝 FOUND EXAMPLE at index ${index}:`, JSON.stringify(transcreationExamples[index], null, 2));
846
 
847
  // Determine if this is a Chinese to English entry (no taiwan field)
848
  const isChineseToEnglish = !req.body.hasOwnProperty('taiwan');
@@ -871,27 +1026,32 @@ app.put('/api/examples/:id', async (req, res) => {
871
  }
872
 
873
  // Preserve original dateAdded and merge updates
874
- const updatedExample = {
875
- ...transcreationExamples[index], // Start with existing data
876
  ...req.body, // Merge in updates
877
  id, // Ensure ID doesn't change
878
- dateAdded: transcreationExamples[index].dateAdded, // Preserve original date
879
  lastModified: new Date().toISOString(), // Add last modified timestamp
880
  // Handle optional fields - if not provided in request, keep existing value
881
- status: req.body.status ?? transcreationExamples[index].status ?? 'pending',
882
- contributor: req.body.contributor === undefined ? transcreationExamples[index].contributor : req.body.contributor,
883
- type: req.body.type ?? transcreationExamples[index].type ?? 'slogan',
884
- description: req.body.description ?? transcreationExamples[index].description ?? ''
885
  };
886
 
887
- // Update the example in the array
888
- transcreationExamples[index] = updatedExample;
 
 
 
 
 
 
 
 
 
889
 
890
  console.log(`📝 UPDATED EXAMPLE:`, JSON.stringify(updatedExample, null, 2));
891
- console.log(`📝 EXAMPLE IN ARRAY AFTER UPDATE:`, JSON.stringify(transcreationExamples[index], null, 2));
892
-
893
- // Save to cache file
894
- await saveCachedExamples();
895
 
896
  console.log(`✅ UPDATE COMPLETED for ID: ${id}`);
897
 
@@ -911,27 +1071,32 @@ app.put('/api/examples/:id', async (req, res) => {
911
  });
912
 
913
  // Delete example
914
- app.delete('/api/examples/:id', (req, res) => {
915
  try {
916
  const { id } = req.params;
917
 
918
- // Find and remove example
919
- const index = transcreationExamples.findIndex(ex => ex.id === id);
920
- if (index === -1) {
921
- return res.status(404).json({
922
- success: false,
923
- error: 'Example not found'
924
- });
 
 
 
 
 
 
 
925
  }
926
 
927
- transcreationExamples.splice(index, 1);
928
- saveCachedExamples();
929
-
930
  res.json({
931
  success: true,
932
  message: 'Example deleted successfully'
933
  });
934
  } catch (error) {
 
935
  res.status(500).json({
936
  success: false,
937
  error: 'Failed to delete example'
 
6
  const fs = require('fs').promises;
7
  const cron = require('node-cron');
8
  const { v4: uuidv4 } = require('uuid');
9
+ const AirtableService = require('./airtable-service');
10
  require('dotenv').config();
11
 
12
  const app = express();
 
20
  app.use(express.static(path.join(__dirname, '../client/build')));
21
  }
22
 
23
+ // Initialize Airtable service
24
+ const airtableService = new AirtableService();
25
+
26
+ // Fallback in-memory storage for examples if Airtable not configured
27
  let transcreationExamples = [];
28
 
29
  // Load cached examples from file on startup
 
571
 
572
  // Initialize cache on startup and load all examples if none exist
573
  const initializeServer = async () => {
574
+ if (airtableService.isConfigured) {
575
+ console.log('🔗 Airtable configured, checking for existing data...');
576
+ try {
577
+ // Check if Airtable has examples
578
+ const airtableExamples = await airtableService.getAllExamples();
579
+
580
+ if (airtableExamples.length === 0) {
581
+ console.log('📦 Airtable is empty, importing 38 examples...');
582
+ // Load examples from simulateOnlineSearch and import to Airtable
583
+ const allExamples = await simulateOnlineSearch('', 38);
584
+ await airtableService.bulkImport(allExamples);
585
+ console.log('✅ Successfully imported all examples to Airtable');
586
+ } else {
587
+ console.log(`📚 Found ${airtableExamples.length} examples in Airtable`);
588
+ }
589
+ } catch (error) {
590
+ console.error('❌ Failed to initialize Airtable:', error);
591
+ console.log('🔄 Falling back to file-based storage...');
592
+ // Fall back to file-based storage
593
+ await loadCachedExamples();
594
+ if (transcreationExamples.length === 0) {
595
+ console.log('🔍 No cached examples found, loading all 38 examples...');
596
+ await searchTranscreationExamples('', 38);
597
+ }
598
+ }
599
+ } else {
600
+ console.log('📁 Using file-based storage...');
601
+ await loadCachedExamples();
602
+
603
+ // If no examples are cached, automatically load all 38 examples
604
+ if (transcreationExamples.length === 0) {
605
+ console.log('🔍 No cached examples found, loading all 38 examples...');
606
+ await searchTranscreationExamples('', 38);
607
+ }
608
  }
609
  };
610
 
 
613
  // API Routes
614
 
615
  // Get all examples
616
+ app.get('/api/examples', async (req, res) => {
617
+ try {
618
+ const { category, type, random } = req.query;
619
+ let examples;
620
 
621
+ if (airtableService.isConfigured) {
622
+ // Use Airtable
623
+ const filters = {};
624
+ if (category) filters.category = category;
625
+ if (type) filters.type = type;
626
+
627
+ examples = await airtableService.getExamples(filters);
628
+ } else {
629
+ // Fallback to in-memory storage
630
+ examples = [...transcreationExamples];
631
 
632
+ // Filter by category
633
+ if (category) {
634
+ examples = examples.filter(ex =>
635
+ ex.category.toLowerCase().includes(category.toLowerCase())
636
+ );
637
+ }
638
 
639
+ // Filter by type
640
+ if (type) {
641
+ examples = examples.filter(ex => ex.type === type);
642
+ }
643
+ }
644
 
645
+ // Randomize if requested
646
+ if (random === 'true') {
647
+ examples = examples.sort(() => Math.random() - 0.5);
648
+ }
649
+
650
+ res.json({
651
+ success: true,
652
+ data: examples,
653
+ total: examples.length
654
+ });
655
+ } catch (error) {
656
+ console.error('❌ Failed to get examples:', error);
657
+ res.status(500).json({
658
+ success: false,
659
+ error: 'Failed to retrieve examples'
660
+ });
661
+ }
662
  });
663
 
664
  // Get random example (or search for new one if cache is low)
665
  app.get('/api/examples/random', async (req, res) => {
666
  try {
667
+ let examples;
668
+
669
+ if (airtableService.isConfigured) {
670
+ // Use Airtable
671
+ examples = await airtableService.getAllExamples();
672
+ } else {
673
+ // Fallback to in-memory storage
674
+ // If we have few examples, try to find more
675
+ if (transcreationExamples.length < 5) {
676
+ console.log('🔍 Cache low, searching for new examples...');
677
+ await searchTranscreationExamples('', 10);
678
+ }
679
+ examples = transcreationExamples;
680
  }
681
 
682
+ if (examples.length === 0) {
683
  return res.json({
684
  success: true,
685
  data: null,
 
687
  });
688
  }
689
 
690
+ const randomIndex = Math.floor(Math.random() * examples.length);
691
+ const example = examples[randomIndex];
692
 
693
  res.json({
694
  success: true,
695
  data: example
696
  });
697
  } catch (error) {
698
+ console.error('❌ Failed to get random example:', error);
699
  res.status(500).json({
700
  success: false,
701
  error: 'Failed to get random example'
 
727
  });
728
 
729
  // Get example by ID
730
+ app.get('/api/examples/:id', async (req, res) => {
731
+ try {
732
+ let example;
733
+
734
+ if (airtableService.isConfigured) {
735
+ // Use Airtable - get all examples and find by ID
736
+ const examples = await airtableService.getAllExamples();
737
+ example = examples.find(ex => ex.id === req.params.id);
738
+ } else {
739
+ // Fallback to in-memory storage
740
+ example = transcreationExamples.find(ex => ex.id === req.params.id);
741
+ }
742
+
743
+ if (!example) {
744
+ return res.status(404).json({
745
+ success: false,
746
+ error: 'Example not found'
747
+ });
748
+ }
749
+
750
+ res.json({
751
+ success: true,
752
+ data: example
753
+ });
754
+ } catch (error) {
755
+ console.error('❌ Failed to get example by ID:', error);
756
+ res.status(500).json({
757
  success: false,
758
+ error: 'Failed to retrieve example'
759
  });
760
  }
 
 
 
 
 
761
  });
762
 
763
  // Get categories
764
+ app.get('/api/categories', async (req, res) => {
765
+ try {
766
+ let categories;
767
+
768
+ if (airtableService.isConfigured) {
769
+ const examples = await airtableService.getAllExamples();
770
+ categories = [...new Set(examples.map(ex => ex.category))];
771
+ } else {
772
+ categories = [...new Set(transcreationExamples.map(ex => ex.category))];
773
+ }
774
+
775
+ res.json({
776
+ success: true,
777
+ data: categories
778
+ });
779
+ } catch (error) {
780
+ console.error('❌ Failed to get categories:', error);
781
+ res.status(500).json({
782
+ success: false,
783
+ error: 'Failed to retrieve categories'
784
+ });
785
+ }
786
  });
787
 
788
  // Get types
789
+ app.get('/api/types', async (req, res) => {
790
+ try {
791
+ let types;
792
+
793
+ if (airtableService.isConfigured) {
794
+ const examples = await airtableService.getAllExamples();
795
+ types = [...new Set(examples.map(ex => ex.type))];
796
+ } else {
797
+ types = [...new Set(transcreationExamples.map(ex => ex.type))];
798
+ }
799
+
800
+ res.json({
801
+ success: true,
802
+ data: types
803
+ });
804
+ } catch (error) {
805
+ console.error('❌ Failed to get types:', error);
806
+ res.status(500).json({
807
+ success: false,
808
+ error: 'Failed to retrieve types'
809
+ });
810
+ }
811
  });
812
 
813
  // Search examples
814
+ app.get('/api/search', async (req, res) => {
815
+ try {
816
+ const { q } = req.query;
817
+
818
+ if (!q) {
819
+ return res.json({
820
+ success: true,
821
+ data: [],
822
+ total: 0
823
+ });
824
+ }
825
+
826
+ let results;
827
+
828
+ if (airtableService.isConfigured) {
829
+ results = await airtableService.searchExamples(q);
830
+ } else {
831
+ const searchTerm = q.toLowerCase();
832
+ results = transcreationExamples.filter(ex =>
833
+ ex.english.toLowerCase().includes(searchTerm) ||
834
+ ex.mainland.toLowerCase().includes(searchTerm) ||
835
+ ex.taiwan?.toLowerCase().includes(searchTerm) ||
836
+ ex.brand.toLowerCase().includes(searchTerm) ||
837
+ ex.category.toLowerCase().includes(searchTerm) ||
838
+ ex.description?.toLowerCase().includes(searchTerm)
839
+ );
840
+ }
841
+
842
+ res.json({
843
  success: true,
844
+ data: results,
845
+ total: results.length
846
+ });
847
+ } catch (error) {
848
+ console.error('❌ Failed to search examples:', error);
849
+ res.status(500).json({
850
+ success: false,
851
+ error: 'Failed to search examples'
852
  });
853
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
854
  });
855
 
856
  // Get database stats
857
+ app.get('/api/stats', async (req, res) => {
858
+ try {
859
+ let stats;
860
+
861
+ if (airtableService.isConfigured) {
862
+ stats = await airtableService.getStats();
863
+ } else {
864
+ stats = {
865
+ totalExamples: transcreationExamples.length,
866
+ categories: [...new Set(transcreationExamples.map(ex => ex.category))].length,
867
+ types: [...new Set(transcreationExamples.map(ex => ex.type))].length,
868
+ lastUpdated: transcreationExamples.length > 0 ?
869
+ Math.max(...transcreationExamples.map(ex => new Date(ex.dateAdded || Date.now()).getTime())) : null
870
+ };
871
+ }
872
 
873
+ res.json({
874
+ success: true,
875
+ data: stats
876
+ });
877
+ } catch (error) {
878
+ console.error('❌ Failed to get stats:', error);
879
+ res.status(500).json({
880
+ success: false,
881
+ error: 'Failed to retrieve statistics'
882
+ });
883
+ }
884
  });
885
 
886
  // Health check
 
944
  };
945
 
946
  console.log('✨ Created new example:', JSON.stringify(newExample, null, 2));
947
+
948
+ let savedExample;
949
+ if (airtableService.isConfigured) {
950
+ // Use Airtable
951
+ savedExample = await airtableService.addExample(newExample);
952
+ } else {
953
+ // Fallback to in-memory storage
954
+ transcreationExamples.push(newExample);
955
+ await saveCachedExamples();
956
+ savedExample = newExample;
957
+ }
958
 
959
  res.json({
960
  success: true,
961
  message: 'Example added successfully',
962
+ example: savedExample
963
  });
964
  } catch (error) {
965
  console.error('❌ Error adding example:', error);
 
978
  console.log(`📝 UPDATE REQUEST for ID: ${id}`);
979
  console.log(`📝 REQUEST BODY:`, JSON.stringify(req.body, null, 2));
980
 
981
+ let existingExample;
982
+ if (airtableService.isConfigured) {
983
+ // Use Airtable - get all examples and find by ID
984
+ const examples = await airtableService.getAllExamples();
985
+ existingExample = examples.find(ex => ex.id === id);
986
+ } else {
987
+ // Fallback to in-memory storage
988
+ const index = transcreationExamples.findIndex(ex => ex.id === id);
989
+ existingExample = index !== -1 ? transcreationExamples[index] : null;
990
+ }
991
 
992
+ if (!existingExample) {
993
  console.log(`❌ Example with ID ${id} not found`);
994
  return res.status(404).json({
995
  success: false,
 
997
  });
998
  }
999
 
1000
+ console.log(`📝 FOUND EXAMPLE:`, JSON.stringify(existingExample, null, 2));
1001
 
1002
  // Determine if this is a Chinese to English entry (no taiwan field)
1003
  const isChineseToEnglish = !req.body.hasOwnProperty('taiwan');
 
1026
  }
1027
 
1028
  // Preserve original dateAdded and merge updates
1029
+ const updatedData = {
1030
+ ...existingExample, // Start with existing data
1031
  ...req.body, // Merge in updates
1032
  id, // Ensure ID doesn't change
1033
+ dateAdded: existingExample.dateAdded, // Preserve original date
1034
  lastModified: new Date().toISOString(), // Add last modified timestamp
1035
  // Handle optional fields - if not provided in request, keep existing value
1036
+ status: req.body.status ?? existingExample.status ?? 'pending',
1037
+ contributor: req.body.contributor === undefined ? existingExample.contributor : req.body.contributor,
1038
+ type: req.body.type ?? existingExample.type ?? 'slogan',
1039
+ description: req.body.description ?? existingExample.description ?? ''
1040
  };
1041
 
1042
+ let updatedExample;
1043
+ if (airtableService.isConfigured) {
1044
+ // Use Airtable
1045
+ updatedExample = await airtableService.updateExample(id, updatedData);
1046
+ } else {
1047
+ // Fallback to in-memory storage
1048
+ const index = transcreationExamples.findIndex(ex => ex.id === id);
1049
+ transcreationExamples[index] = updatedData;
1050
+ await saveCachedExamples();
1051
+ updatedExample = updatedData;
1052
+ }
1053
 
1054
  console.log(`📝 UPDATED EXAMPLE:`, JSON.stringify(updatedExample, null, 2));
 
 
 
 
1055
 
1056
  console.log(`✅ UPDATE COMPLETED for ID: ${id}`);
1057
 
 
1071
  });
1072
 
1073
  // Delete example
1074
+ app.delete('/api/examples/:id', async (req, res) => {
1075
  try {
1076
  const { id } = req.params;
1077
 
1078
+ if (airtableService.isConfigured) {
1079
+ // Use Airtable
1080
+ await airtableService.deleteExample(id);
1081
+ } else {
1082
+ // Fallback to in-memory storage
1083
+ const index = transcreationExamples.findIndex(ex => ex.id === id);
1084
+ if (index === -1) {
1085
+ return res.status(404).json({
1086
+ success: false,
1087
+ error: 'Example not found'
1088
+ });
1089
+ }
1090
+ transcreationExamples.splice(index, 1);
1091
+ await saveCachedExamples();
1092
  }
1093
 
 
 
 
1094
  res.json({
1095
  success: true,
1096
  message: 'Example deleted successfully'
1097
  });
1098
  } catch (error) {
1099
+ console.error('❌ Failed to delete example:', error);
1100
  res.status(500).json({
1101
  success: false,
1102
  error: 'Failed to delete example'
server/package-lock.json CHANGED
@@ -7,18 +7,45 @@
7
  "": {
8
  "name": "transcreation-explorer-server",
9
  "version": "1.0.0",
 
10
  "dependencies": {
11
- "axios": "^1.6.2",
 
12
  "cheerio": "^1.0.0-rc.12",
13
  "cors": "^2.8.5",
14
- "dotenv": "^16.3.1",
15
  "express": "^4.18.2",
16
- "node-cron": "^3.0.3"
 
17
  },
18
  "devDependencies": {
19
- "nodemon": "^3.0.2"
20
  }
21
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  "node_modules/accepts": {
23
  "version": "1.3.8",
24
  "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -32,6 +59,22 @@
32
  "node": ">= 0.6"
33
  }
34
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  "node_modules/anymatch": {
36
  "version": "3.1.3",
37
  "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -574,6 +617,15 @@
574
  "node": ">= 0.6"
575
  }
576
  },
 
 
 
 
 
 
 
 
 
577
  "node_modules/express": {
578
  "version": "4.21.2",
579
  "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -967,6 +1019,12 @@
967
  "node": ">=0.12.0"
968
  }
969
  },
 
 
 
 
 
 
970
  "node_modules/math-intrinsics": {
971
  "version": "1.1.0",
972
  "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1076,6 +1134,35 @@
1076
  "node": ">=6.0.0"
1077
  }
1078
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1079
  "node_modules/nodemon": {
1080
  "version": "3.1.10",
1081
  "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
@@ -1590,6 +1677,12 @@
1590
  "nodetouch": "bin/nodetouch.js"
1591
  }
1592
  },
 
 
 
 
 
 
1593
  "node_modules/type-is": {
1594
  "version": "1.6.18",
1595
  "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1638,9 +1731,13 @@
1638
  }
1639
  },
1640
  "node_modules/uuid": {
1641
- "version": "8.3.2",
1642
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
1643
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
 
 
 
 
1644
  "license": "MIT",
1645
  "bin": {
1646
  "uuid": "dist/bin/uuid"
@@ -1655,6 +1752,12 @@
1655
  "node": ">= 0.8"
1656
  }
1657
  },
 
 
 
 
 
 
1658
  "node_modules/whatwg-encoding": {
1659
  "version": "3.1.1",
1660
  "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
@@ -1675,6 +1778,16 @@
1675
  "engines": {
1676
  "node": ">=18"
1677
  }
 
 
 
 
 
 
 
 
 
 
1678
  }
1679
  }
1680
  }
 
7
  "": {
8
  "name": "transcreation-explorer-server",
9
  "version": "1.0.0",
10
+ "license": "ISC",
11
  "dependencies": {
12
+ "airtable": "^0.12.2",
13
+ "axios": "^1.6.7",
14
  "cheerio": "^1.0.0-rc.12",
15
  "cors": "^2.8.5",
16
+ "dotenv": "^16.4.1",
17
  "express": "^4.18.2",
18
+ "node-cron": "^3.0.3",
19
+ "uuid": "^9.0.1"
20
  },
21
  "devDependencies": {
22
+ "nodemon": "^3.0.3"
23
  }
24
  },
25
+ "node_modules/@types/node": {
26
+ "version": "14.18.63",
27
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz",
28
+ "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
29
+ "license": "MIT"
30
+ },
31
+ "node_modules/abort-controller": {
32
+ "version": "3.0.0",
33
+ "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
34
+ "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
35
+ "license": "MIT",
36
+ "dependencies": {
37
+ "event-target-shim": "^5.0.0"
38
+ },
39
+ "engines": {
40
+ "node": ">=6.5"
41
+ }
42
+ },
43
+ "node_modules/abortcontroller-polyfill": {
44
+ "version": "1.7.8",
45
+ "resolved": "https://registry.npmjs.org/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.8.tgz",
46
+ "integrity": "sha512-9f1iZ2uWh92VcrU9Y8x+LdM4DLj75VE0MJB8zuF1iUnroEptStw+DQ8EQPMUdfe5k+PkB1uUfDQfWbhstH8LrQ==",
47
+ "license": "MIT"
48
+ },
49
  "node_modules/accepts": {
50
  "version": "1.3.8",
51
  "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
 
59
  "node": ">= 0.6"
60
  }
61
  },
62
+ "node_modules/airtable": {
63
+ "version": "0.12.2",
64
+ "resolved": "https://registry.npmjs.org/airtable/-/airtable-0.12.2.tgz",
65
+ "integrity": "sha512-HS3VytUBTKj8A0vPl7DDr5p/w3IOGv6RXL0fv7eczOWAtj9Xe8ri4TAiZRXoOyo+Z/COADCj+oARFenbxhmkIg==",
66
+ "license": "MIT",
67
+ "dependencies": {
68
+ "@types/node": ">=8.0.0 <15",
69
+ "abort-controller": "^3.0.0",
70
+ "abortcontroller-polyfill": "^1.4.0",
71
+ "lodash": "^4.17.21",
72
+ "node-fetch": "^2.6.7"
73
+ },
74
+ "engines": {
75
+ "node": ">=8.0.0"
76
+ }
77
+ },
78
  "node_modules/anymatch": {
79
  "version": "3.1.3",
80
  "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
 
617
  "node": ">= 0.6"
618
  }
619
  },
620
+ "node_modules/event-target-shim": {
621
+ "version": "5.0.1",
622
+ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
623
+ "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
624
+ "license": "MIT",
625
+ "engines": {
626
+ "node": ">=6"
627
+ }
628
+ },
629
  "node_modules/express": {
630
  "version": "4.21.2",
631
  "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
 
1019
  "node": ">=0.12.0"
1020
  }
1021
  },
1022
+ "node_modules/lodash": {
1023
+ "version": "4.17.21",
1024
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
1025
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
1026
+ "license": "MIT"
1027
+ },
1028
  "node_modules/math-intrinsics": {
1029
  "version": "1.1.0",
1030
  "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
 
1134
  "node": ">=6.0.0"
1135
  }
1136
  },
1137
+ "node_modules/node-cron/node_modules/uuid": {
1138
+ "version": "8.3.2",
1139
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
1140
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
1141
+ "license": "MIT",
1142
+ "bin": {
1143
+ "uuid": "dist/bin/uuid"
1144
+ }
1145
+ },
1146
+ "node_modules/node-fetch": {
1147
+ "version": "2.7.0",
1148
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
1149
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
1150
+ "license": "MIT",
1151
+ "dependencies": {
1152
+ "whatwg-url": "^5.0.0"
1153
+ },
1154
+ "engines": {
1155
+ "node": "4.x || >=6.0.0"
1156
+ },
1157
+ "peerDependencies": {
1158
+ "encoding": "^0.1.0"
1159
+ },
1160
+ "peerDependenciesMeta": {
1161
+ "encoding": {
1162
+ "optional": true
1163
+ }
1164
+ }
1165
+ },
1166
  "node_modules/nodemon": {
1167
  "version": "3.1.10",
1168
  "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
 
1677
  "nodetouch": "bin/nodetouch.js"
1678
  }
1679
  },
1680
+ "node_modules/tr46": {
1681
+ "version": "0.0.3",
1682
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
1683
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
1684
+ "license": "MIT"
1685
+ },
1686
  "node_modules/type-is": {
1687
  "version": "1.6.18",
1688
  "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
 
1731
  }
1732
  },
1733
  "node_modules/uuid": {
1734
+ "version": "9.0.1",
1735
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
1736
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
1737
+ "funding": [
1738
+ "https://github.com/sponsors/broofa",
1739
+ "https://github.com/sponsors/ctavan"
1740
+ ],
1741
  "license": "MIT",
1742
  "bin": {
1743
  "uuid": "dist/bin/uuid"
 
1752
  "node": ">= 0.8"
1753
  }
1754
  },
1755
+ "node_modules/webidl-conversions": {
1756
+ "version": "3.0.1",
1757
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
1758
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
1759
+ "license": "BSD-2-Clause"
1760
+ },
1761
  "node_modules/whatwg-encoding": {
1762
  "version": "3.1.1",
1763
  "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
 
1778
  "engines": {
1779
  "node": ">=18"
1780
  }
1781
+ },
1782
+ "node_modules/whatwg-url": {
1783
+ "version": "5.0.0",
1784
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
1785
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
1786
+ "license": "MIT",
1787
+ "dependencies": {
1788
+ "tr46": "~0.0.3",
1789
+ "webidl-conversions": "^3.0.0"
1790
+ }
1791
  }
1792
  }
1793
  }
server/package.json CHANGED
@@ -16,6 +16,7 @@
16
  "author": "",
17
  "license": "ISC",
18
  "dependencies": {
 
19
  "axios": "^1.6.7",
20
  "cheerio": "^1.0.0-rc.12",
21
  "cors": "^2.8.5",
@@ -27,4 +28,4 @@
27
  "devDependencies": {
28
  "nodemon": "^3.0.3"
29
  }
30
- }
 
16
  "author": "",
17
  "license": "ISC",
18
  "dependencies": {
19
+ "airtable": "^0.12.2",
20
  "axios": "^1.6.7",
21
  "cheerio": "^1.0.0-rc.12",
22
  "cors": "^2.8.5",
 
28
  "devDependencies": {
29
  "nodemon": "^3.0.3"
30
  }
31
+ }