Skip to content

Commit eb981d0

Browse files
Merge pull request #8 from foundersandcoders/feature/ap-16-event-service-module
feat: add Event service module (Airtable CRUD)
2 parents 63aadf2 + 4b54c94 commit eb981d0

File tree

6 files changed

+433
-3
lines changed

6 files changed

+433
-3
lines changed

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,18 @@ const records = await table
120120
const value = record.get('fldXXXXXXXXXXXXXX'); // field ID
121121
```
122122

123-
**Limitation:** `filterByFormula` still requires field **names**, not IDs. This is an Airtable API limitation. Document any field names used in formulas to track potential breaking changes.
123+
**Limitation:** `filterByFormula` still requires field **names**, not IDs. This is an Airtable API limitation.
124+
125+
### Field Names Used in Formulas
126+
127+
The following field names are used in `filterByFormula` queries. **Renaming these fields in Airtable will break the app:**
128+
129+
| Table | Field Name | Used In |
130+
|-------|------------|---------|
131+
| Apprentices | `Learner email` | `findApprenticeByEmail()` |
132+
| Cohorts | `FAC Cohort` | `getApprenticesByFacCohort()` |
133+
| Events | `FAC Cohort` | `listEvents()` cohort filter |
134+
| Events | `Date Time` | `listEvents()` date range filter |
124135

125136
### Fetching Schema IDs
126137

@@ -138,3 +149,13 @@ This script:
138149
**Always use this script** to get IDs rather than copying them manually from the Airtable UI. This ensures accuracy and provides documentation of the schema at that point in time.
139150

140151
The generated schema file can be used to update `src/lib/airtable/config.ts` with new field IDs.
152+
153+
### Event Types
154+
155+
Event types are defined in `src/lib/types/event.ts`. The `EventType` union currently includes:
156+
157+
- `Regular class`
158+
- `Workshop`
159+
- `Hackathon`
160+
161+
If new event types are added in Airtable's "Select" field, update this type definition to match.

scripts/test-events.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Manual test script for Events CRUD operations.
3+
* Run with: npx tsx scripts/test-events.ts
4+
*/
5+
6+
import { createEventsClient } from '../src/lib/airtable/events.ts';
7+
import { config } from 'dotenv';
8+
config({ path: '.env.local' });
9+
10+
const apiKey = process.env.AIRTABLE_API_KEY!;
11+
const baseId = process.env.AIRTABLE_BASE_ID_LEARNERS!;
12+
13+
if (!apiKey || !baseId) {
14+
console.error('Missing AIRTABLE_API_KEY or AIRTABLE_BASE_ID_LEARNERS');
15+
process.exit(1);
16+
}
17+
18+
const client = createEventsClient(apiKey, baseId);
19+
20+
async function main() {
21+
console.log('=== Testing Events CRUD ===\n');
22+
23+
// List events
24+
console.log('1. Listing events...');
25+
const events = await client.listEvents();
26+
console.log(` Found ${events.length} events`);
27+
if (events.length > 0) {
28+
console.log(' First event:', events[0]);
29+
}
30+
31+
// Get single event (if any exist)
32+
if (events.length > 0) {
33+
console.log('\n2. Getting single event...');
34+
const event = await client.getEvent(events[0].id);
35+
console.log(' Event:', event);
36+
}
37+
38+
// Uncomment below to test create/update/delete (will modify Airtable!)
39+
/*
40+
console.log('\n3. Creating event...');
41+
const newEvent = await client.createEvent({
42+
name: 'Test Event',
43+
dateTime: new Date().toISOString(),
44+
cohortId: 'YOUR_COHORT_RECORD_ID',
45+
eventType: 'Workshop',
46+
});
47+
console.log(' Created:', newEvent);
48+
49+
console.log('\n4. Updating event...');
50+
const updated = await client.updateEvent(newEvent.id, { name: 'Updated Test Event' });
51+
console.log(' Updated:', updated);
52+
53+
console.log('\n5. Deleting event...');
54+
await client.deleteEvent(newEvent.id);
55+
console.log(' Deleted');
56+
*/
57+
58+
console.log('\n=== Done ===');
59+
}
60+
61+
main().catch(console.error);

src/lib/airtable/events.spec.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { createEventsClient } from './events';
3+
4+
// Mock Airtable
5+
vi.mock('airtable', () => {
6+
const mockSelect = vi.fn();
7+
const mockFind = vi.fn();
8+
const mockCreate = vi.fn();
9+
const mockUpdate = vi.fn();
10+
const mockDestroy = vi.fn();
11+
12+
return {
13+
default: {
14+
configure: vi.fn(),
15+
base: vi.fn(() => () => ({
16+
select: mockSelect,
17+
find: mockFind,
18+
create: mockCreate,
19+
update: mockUpdate,
20+
destroy: mockDestroy,
21+
})),
22+
},
23+
};
24+
});
25+
26+
import Airtable from 'airtable';
27+
28+
describe('events', () => {
29+
let client: ReturnType<typeof createEventsClient>;
30+
let mockTable: {
31+
select: ReturnType<typeof vi.fn>;
32+
find: ReturnType<typeof vi.fn>;
33+
create: ReturnType<typeof vi.fn>;
34+
update: ReturnType<typeof vi.fn>;
35+
destroy: ReturnType<typeof vi.fn>;
36+
};
37+
38+
beforeEach(() => {
39+
vi.clearAllMocks();
40+
client = createEventsClient('test-api-key', 'test-base-id');
41+
// Get reference to mock table
42+
mockTable = (Airtable.base('test-base-id') as unknown as () => typeof mockTable)();
43+
});
44+
45+
describe('listEvents', () => {
46+
it('should return mapped events', async () => {
47+
const mockRecords = [
48+
{
49+
id: 'rec123',
50+
get: vi.fn((field: string) => {
51+
const data: Record<string, unknown> = {
52+
fldMCZijN6TJeUdFR: 'Week 1 Monday',
53+
fld8AkM3EanzZa5QX: '2025-01-06T10:00:00.000Z',
54+
fldcXDEDkeHvWTnxE: ['recCohort1'],
55+
fldo7fwAsFhkA1icC: 'Regular class',
56+
fld9XBHnCWBtZiZah: 'https://survey.example.com',
57+
};
58+
return data[field];
59+
}),
60+
},
61+
];
62+
63+
mockTable.select.mockReturnValue({ all: vi.fn().mockResolvedValue(mockRecords) });
64+
65+
const events = await client.listEvents();
66+
67+
expect(events).toHaveLength(1);
68+
expect(events[0]).toEqual({
69+
id: 'rec123',
70+
name: 'Week 1 Monday',
71+
dateTime: '2025-01-06T10:00:00.000Z',
72+
cohortId: 'recCohort1',
73+
eventType: 'Regular class',
74+
surveyUrl: 'https://survey.example.com',
75+
});
76+
});
77+
});
78+
79+
describe('getEvent', () => {
80+
it('should return event when found', async () => {
81+
const mockRecord = {
82+
id: 'rec123',
83+
get: vi.fn((field: string) => {
84+
const data: Record<string, unknown> = {
85+
fldMCZijN6TJeUdFR: 'Workshop',
86+
fld8AkM3EanzZa5QX: '2025-01-07T14:00:00.000Z',
87+
fldcXDEDkeHvWTnxE: ['recCohort2'],
88+
fldo7fwAsFhkA1icC: 'Workshop',
89+
fld9XBHnCWBtZiZah: undefined,
90+
};
91+
return data[field];
92+
}),
93+
};
94+
95+
mockTable.find.mockResolvedValue(mockRecord);
96+
97+
const event = await client.getEvent('rec123');
98+
99+
expect(event).toEqual({
100+
id: 'rec123',
101+
name: 'Workshop',
102+
dateTime: '2025-01-07T14:00:00.000Z',
103+
cohortId: 'recCohort2',
104+
eventType: 'Workshop',
105+
surveyUrl: undefined,
106+
});
107+
});
108+
109+
it('should return null when not found', async () => {
110+
mockTable.find.mockRejectedValue(new Error('Record not found'));
111+
112+
const event = await client.getEvent('nonexistent');
113+
114+
expect(event).toBeNull();
115+
});
116+
});
117+
118+
describe('createEvent', () => {
119+
it('should create and return new event', async () => {
120+
const mockRecord = { id: 'recNew123' };
121+
mockTable.create.mockResolvedValue(mockRecord);
122+
123+
const input = {
124+
name: 'New Event',
125+
dateTime: '2025-01-08T09:00:00.000Z',
126+
cohortId: 'recCohort1',
127+
eventType: 'Workshop' as const,
128+
surveyUrl: 'https://survey.example.com',
129+
};
130+
131+
const event = await client.createEvent(input);
132+
133+
expect(event).toEqual({
134+
id: 'recNew123',
135+
...input,
136+
});
137+
expect(mockTable.create).toHaveBeenCalled();
138+
});
139+
});
140+
141+
describe('updateEvent', () => {
142+
it('should update and return event', async () => {
143+
const mockRecord = {
144+
id: 'rec123',
145+
get: vi.fn((field: string) => {
146+
const data: Record<string, unknown> = {
147+
fldMCZijN6TJeUdFR: 'Updated Name',
148+
fld8AkM3EanzZa5QX: '2025-01-06T10:00:00.000Z',
149+
fldcXDEDkeHvWTnxE: ['recCohort1'],
150+
fldo7fwAsFhkA1icC: 'Regular class',
151+
fld9XBHnCWBtZiZah: undefined,
152+
};
153+
return data[field];
154+
}),
155+
};
156+
mockTable.update.mockResolvedValue(mockRecord);
157+
158+
const event = await client.updateEvent('rec123', { name: 'Updated Name' });
159+
160+
expect(event.name).toBe('Updated Name');
161+
expect(mockTable.update).toHaveBeenCalledWith('rec123', expect.any(Object));
162+
});
163+
});
164+
165+
describe('deleteEvent', () => {
166+
it('should delete event', async () => {
167+
mockTable.destroy.mockResolvedValue({});
168+
169+
await client.deleteEvent('rec123');
170+
171+
expect(mockTable.destroy).toHaveBeenCalledWith('rec123');
172+
});
173+
});
174+
});

0 commit comments

Comments
 (0)