@tummycrypt/acuity-middleware 0.1.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,405 @@
1
+ /**
2
+ * Wizard Step: Read Time Slots from Acuity Calendar
3
+ *
4
+ * Navigates to the service calendar via click-through,
5
+ * advances to the target date, clicks the day tile,
6
+ * and reads all available time slot buttons.
7
+ */
8
+
9
+ import { Effect } from 'effect';
10
+ import type { Page, ElementHandle } from 'playwright-core';
11
+ import { BrowserService } from '../browser-service.js';
12
+ import { WizardStepError } from '../errors.js';
13
+ import { resolveSelector, Selectors } from '../selectors.js';
14
+
15
+ // =============================================================================
16
+ // TYPES
17
+ // =============================================================================
18
+
19
+ export interface ReadSlotsParams {
20
+ /** Service name to match against the service list */
21
+ readonly serviceName: string;
22
+ /** Appointment type ID (used to verify correct service selected) */
23
+ readonly appointmentTypeId?: string;
24
+ /** Target date (YYYY-MM-DD) */
25
+ readonly date: string;
26
+ }
27
+
28
+ export interface SlotResult {
29
+ readonly datetime: string; // ISO 8601
30
+ readonly available: boolean;
31
+ }
32
+
33
+ // =============================================================================
34
+ // IMPLEMENTATION
35
+ // =============================================================================
36
+
37
+ /**
38
+ * Read time slots for a specific date by navigating the Acuity wizard.
39
+ *
40
+ * Flow:
41
+ * 1. Load service page → find service → click "Book"
42
+ * 2. Navigate calendar to target month
43
+ * 3. Click target day tile
44
+ * 4. Read all time slot buttons
45
+ */
46
+ export const readTimeSlots = (params: ReadSlotsParams) =>
47
+ Effect.gen(function* () {
48
+ const { acquirePage, config } = yield* BrowserService;
49
+ const page: Page = yield* acquirePage;
50
+
51
+ // Step 1: Load service page
52
+ yield* Effect.tryPromise({
53
+ try: () => page.goto(config.baseUrl, { waitUntil: 'networkidle', timeout: config.timeout }),
54
+ catch: (e) =>
55
+ new WizardStepError({
56
+ step: 'read-slots',
57
+ message: `Failed to load service page: ${e instanceof Error ? e.message : String(e)}`,
58
+ cause: e,
59
+ }),
60
+ });
61
+
62
+ // Step 2: Click the target service's "Book" button
63
+ yield* clickServiceBook(page, params.serviceName, params.appointmentTypeId);
64
+
65
+ // Step 3: Navigate to the target month
66
+ const targetDate = new Date(params.date + 'T12:00:00');
67
+ const targetMonth = `${targetDate.getFullYear()}-${String(targetDate.getMonth() + 1).padStart(2, '0')}`;
68
+ yield* navigateToTargetMonth(page, targetMonth);
69
+
70
+ // Step 4: Click the target day
71
+ yield* clickDay(page, targetDate.getDate());
72
+
73
+ // Step 5: Read time slots
74
+ const slots = yield* readSlotButtons(page, params.date);
75
+
76
+ return slots;
77
+ });
78
+
79
+ // =============================================================================
80
+ // HELPERS
81
+ // =============================================================================
82
+
83
+ const clickServiceBook = (
84
+ page: Page,
85
+ serviceName: string,
86
+ expectedId?: string,
87
+ ) =>
88
+ Effect.gen(function* () {
89
+ yield* resolveSelector(page, Selectors.serviceList, 10000).pipe(
90
+ Effect.catchTag('SelectorError', () =>
91
+ Effect.fail(
92
+ new WizardStepError({
93
+ step: 'read-slots',
94
+ message: 'Service list did not load',
95
+ }),
96
+ ),
97
+ ),
98
+ );
99
+
100
+ const serviceItem: ElementHandle | null = yield* Effect.tryPromise({
101
+ try: async () => {
102
+ const items = await page.$$(Selectors.serviceList[0]);
103
+ for (const item of items) {
104
+ const nameEl = await item.$(Selectors.serviceName[0]);
105
+ const name = await nameEl?.textContent();
106
+ if (name && name.trim().toLowerCase().includes(serviceName.toLowerCase())) {
107
+ return item;
108
+ }
109
+ }
110
+ return null;
111
+ },
112
+ catch: (e) =>
113
+ new WizardStepError({
114
+ step: 'read-slots',
115
+ message: `Error searching services: ${e instanceof Error ? e.message : String(e)}`,
116
+ cause: e,
117
+ }),
118
+ });
119
+
120
+ if (!serviceItem) {
121
+ return yield* Effect.fail(
122
+ new WizardStepError({
123
+ step: 'read-slots',
124
+ message: `Service "${serviceName}" not found`,
125
+ }),
126
+ );
127
+ }
128
+
129
+ const bookBtn = yield* Effect.tryPromise({
130
+ try: () => serviceItem.$(Selectors.serviceBookButton[0]),
131
+ catch: (e) =>
132
+ new WizardStepError({
133
+ step: 'read-slots',
134
+ message: `Book button error: ${e instanceof Error ? e.message : String(e)}`,
135
+ cause: e,
136
+ }),
137
+ });
138
+
139
+ if (!bookBtn) {
140
+ return yield* Effect.fail(
141
+ new WizardStepError({
142
+ step: 'read-slots',
143
+ message: `"Book" button not found for "${serviceName}"`,
144
+ }),
145
+ );
146
+ }
147
+
148
+ yield* Effect.tryPromise({
149
+ try: async () => {
150
+ await bookBtn.click();
151
+ await page.waitForURL(/\/appointment\/\d+\/calendar\/\d+/, { timeout: 10000 });
152
+ },
153
+ catch: (e) =>
154
+ new WizardStepError({
155
+ step: 'read-slots',
156
+ message: `Failed to navigate to calendar: ${e instanceof Error ? e.message : String(e)}`,
157
+ cause: e,
158
+ }),
159
+ });
160
+
161
+ if (expectedId) {
162
+ const url = page.url();
163
+ const match = url.match(/\/appointment\/(\d+)/);
164
+ if (match && match[1] !== expectedId) {
165
+ return yield* Effect.fail(
166
+ new WizardStepError({
167
+ step: 'read-slots',
168
+ message: `Expected appointment type ${expectedId} but got ${match[1]}`,
169
+ }),
170
+ );
171
+ }
172
+ }
173
+ });
174
+
175
+ const MONTH_NAMES = [
176
+ 'january', 'february', 'march', 'april', 'may', 'june',
177
+ 'july', 'august', 'september', 'october', 'november', 'december',
178
+ ];
179
+
180
+ const navigateToTargetMonth = (page: Page, targetMonth: string): Effect.Effect<void, WizardStepError> =>
181
+ Effect.gen(function* () {
182
+ yield* resolveSelector(page, Selectors.calendar, 10000).pipe(
183
+ Effect.catchTag('SelectorError', () =>
184
+ Effect.fail(
185
+ new WizardStepError({
186
+ step: 'read-slots',
187
+ message: 'Calendar did not load',
188
+ }),
189
+ ),
190
+ ),
191
+ );
192
+
193
+ const [yearStr, monthStr] = targetMonth.split('-');
194
+ const targetYear = parseInt(yearStr, 10);
195
+ const targetMonthIdx = parseInt(monthStr, 10) - 1;
196
+
197
+ for (let i = 0; i < 12; i++) {
198
+ const current = yield* getCalendarMonth(page);
199
+ if (!current) {
200
+ return yield* Effect.fail(
201
+ new WizardStepError({
202
+ step: 'read-slots',
203
+ message: 'Could not determine calendar month',
204
+ }),
205
+ );
206
+ }
207
+
208
+ if (current.month === targetMonthIdx && current.year === targetYear) return;
209
+
210
+ const currentFirst = new Date(current.year, current.month, 1);
211
+ const targetFirst = new Date(targetYear, targetMonthIdx, 1);
212
+ const direction = targetFirst > currentFirst ? 'next' : 'prev';
213
+ const selectors = direction === 'prev' ? Selectors.calendarPrev : Selectors.calendarNext;
214
+
215
+ const btn = yield* resolveSelector(page, selectors, 3000).pipe(
216
+ Effect.catchTag('SelectorError', () =>
217
+ Effect.fail(
218
+ new WizardStepError({
219
+ step: 'read-slots',
220
+ message: `Calendar ${direction} button not found`,
221
+ }),
222
+ ),
223
+ ),
224
+ );
225
+
226
+ yield* Effect.tryPromise({
227
+ try: async () => {
228
+ await btn.element.click();
229
+ await page.waitForTimeout(500);
230
+ },
231
+ catch: (e) =>
232
+ new WizardStepError({
233
+ step: 'read-slots',
234
+ message: `Calendar nav failed: ${e instanceof Error ? e.message : String(e)}`,
235
+ cause: e,
236
+ }),
237
+ });
238
+ }
239
+ });
240
+
241
+ const getCalendarMonth = (
242
+ page: Page,
243
+ ): Effect.Effect<{ month: number; year: number } | null, never> =>
244
+ Effect.gen(function* () {
245
+ // Wait for calendar month label to appear
246
+ yield* Effect.tryPromise({
247
+ try: () => page.waitForSelector(Selectors.calendarMonth[0], { timeout: 5000 }),
248
+ catch: () => null,
249
+ }).pipe(Effect.orElseSucceed(() => null));
250
+
251
+ // Retry up to 3 times — React may still be rendering
252
+ for (let retry = 0; retry < 3; retry++) {
253
+ const info = yield* Effect.tryPromise({
254
+ try: async () => {
255
+ for (const selector of Selectors.calendarMonth) {
256
+ const text = await page.$eval(selector, (el) => el.textContent?.trim() ?? null).catch(() => null);
257
+ if (text) {
258
+ const match = text.match(/([A-Za-z]+)\s*(\d{4})/);
259
+ if (match) {
260
+ const monthIndex = MONTH_NAMES.indexOf(match[1].toLowerCase());
261
+ if (monthIndex >= 0) {
262
+ return { month: monthIndex, year: parseInt(match[2], 10) };
263
+ }
264
+ }
265
+ }
266
+ }
267
+ // Also try innerText which resolves visibility better than textContent
268
+ for (const selector of Selectors.calendarMonth) {
269
+ const text = await page.$eval(selector, (el) => (el as HTMLElement).innerText?.trim() ?? null).catch(() => null);
270
+ if (text) {
271
+ const match = text.match(/([A-Za-z]+)\s*(\d{4})/);
272
+ if (match) {
273
+ const monthIndex = MONTH_NAMES.indexOf(match[1].toLowerCase());
274
+ if (monthIndex >= 0) {
275
+ return { month: monthIndex, year: parseInt(match[2], 10) };
276
+ }
277
+ }
278
+ }
279
+ }
280
+ return null;
281
+ },
282
+ catch: () => null,
283
+ }).pipe(Effect.orElseSucceed(() => null));
284
+
285
+ if (info) return info;
286
+
287
+ // Wait before retrying
288
+ yield* Effect.tryPromise({
289
+ try: () => page.waitForTimeout(1000),
290
+ catch: () => null,
291
+ }).pipe(Effect.orElseSucceed(() => null));
292
+ }
293
+
294
+ return null;
295
+ });
296
+
297
+ /**
298
+ * Click the calendar tile for a specific day number.
299
+ */
300
+ const clickDay = (page: Page, dayOfMonth: number): Effect.Effect<void, WizardStepError> =>
301
+ Effect.gen(function* () {
302
+ const clicked = yield* Effect.tryPromise({
303
+ try: async () => {
304
+ const tiles = await page.$$(Selectors.calendarDay[0]);
305
+ for (const tile of tiles) {
306
+ const isDisabled = await tile.evaluate((el) => (el as HTMLButtonElement).disabled);
307
+ if (isDisabled) continue;
308
+
309
+ const classes = (await tile.getAttribute('class')) ?? '';
310
+ if (classes.includes('neighboringMonth')) continue;
311
+
312
+ const text = await tile.textContent();
313
+ const num = parseInt(text?.trim() ?? '', 10);
314
+ if (num === dayOfMonth) {
315
+ await tile.click();
316
+ return true;
317
+ }
318
+ }
319
+ return false;
320
+ },
321
+ catch: (e) =>
322
+ new WizardStepError({
323
+ step: 'read-slots',
324
+ message: `Error clicking day ${dayOfMonth}: ${e instanceof Error ? e.message : String(e)}`,
325
+ cause: e,
326
+ }),
327
+ });
328
+
329
+ if (!clicked) {
330
+ return yield* Effect.fail(
331
+ new WizardStepError({
332
+ step: 'read-slots',
333
+ message: `Day ${dayOfMonth} not available on calendar`,
334
+ }),
335
+ );
336
+ }
337
+
338
+ // Wait for time slots to appear
339
+ yield* resolveSelector(page, Selectors.timeSlotContainer, 10000).pipe(
340
+ Effect.catchTag('SelectorError', () =>
341
+ Effect.fail(
342
+ new WizardStepError({
343
+ step: 'read-slots',
344
+ message: 'Time slots did not appear after clicking day',
345
+ }),
346
+ ),
347
+ ),
348
+ );
349
+ });
350
+
351
+ /**
352
+ * Read all time slot buttons and return structured data.
353
+ * Slot text format: "10:00 AM1 spot left" or "2:30 PM"
354
+ */
355
+ const readSlotButtons = (
356
+ page: Page,
357
+ dateStr: string,
358
+ ): Effect.Effect<SlotResult[], WizardStepError> =>
359
+ Effect.tryPromise({
360
+ try: async () => {
361
+ const results: SlotResult[] = [];
362
+ const slots = await page.$$(Selectors.timeSlot[0]);
363
+
364
+ for (const slot of slots) {
365
+ const text = await slot.textContent();
366
+ if (!text) continue;
367
+
368
+ // Extract time from slot text: "10:00 AM1 spot left" → "10:00 AM"
369
+ const timeMatch = text.trim().match(/^(\d{1,2}:\d{2}\s*[AP]M)/i);
370
+ if (!timeMatch) continue;
371
+
372
+ const timeStr = timeMatch[1].trim();
373
+
374
+ // Convert to ISO 8601 datetime
375
+ const datetime = buildIsoDatetime(dateStr, timeStr);
376
+ results.push({ datetime, available: true });
377
+ }
378
+
379
+ return results;
380
+ },
381
+ catch: (e) =>
382
+ new WizardStepError({
383
+ step: 'read-slots',
384
+ message: `Error reading slots: ${e instanceof Error ? e.message : String(e)}`,
385
+ cause: e,
386
+ }),
387
+ });
388
+
389
+ /**
390
+ * Build ISO datetime from date string and time string.
391
+ * "2026-03-15" + "10:00 AM" → "2026-03-15T10:00:00"
392
+ */
393
+ const buildIsoDatetime = (dateStr: string, timeStr: string): string => {
394
+ const match = timeStr.match(/(\d{1,2}):(\d{2})\s*(AM|PM)/i);
395
+ if (!match) return `${dateStr}T00:00:00`;
396
+
397
+ let hours = parseInt(match[1], 10);
398
+ const minutes = match[2];
399
+ const ampm = match[3].toUpperCase();
400
+
401
+ if (ampm === 'PM' && hours !== 12) hours += 12;
402
+ if (ampm === 'AM' && hours === 12) hours = 0;
403
+
404
+ return `${dateStr}T${String(hours).padStart(2, '0')}:${minutes}:00`;
405
+ };
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Wizard Step: Submit Booking
3
+ *
4
+ * Clicks the submit/complete button and waits for the confirmation page.
5
+ * Handles the race between successful navigation to confirmation
6
+ * and validation error display.
7
+ */
8
+
9
+ import { Effect, Schedule } from 'effect';
10
+ import type { Page } from 'playwright-core';
11
+ import { BrowserService } from '../browser-service.js';
12
+ import { WizardStepError } from '../errors.js';
13
+ import { resolveSelector, probe, Selectors } from '../selectors.js';
14
+
15
+ // =============================================================================
16
+ // TYPES
17
+ // =============================================================================
18
+
19
+ export interface SubmitResult {
20
+ readonly submitted: boolean;
21
+ readonly confirmationPageReached: boolean;
22
+ }
23
+
24
+ // =============================================================================
25
+ // IMPLEMENTATION
26
+ // =============================================================================
27
+
28
+ /**
29
+ * Submit the booking form and wait for confirmation.
30
+ */
31
+ export const submitBooking = () =>
32
+ Effect.gen(function* () {
33
+ const { acquirePage } = yield* BrowserService;
34
+ const page: Page = yield* acquirePage;
35
+
36
+ // Find submit button
37
+ const submitBtn = yield* resolveSelector(page, Selectors.submitButton, 10000).pipe(
38
+ Effect.catchTag('SelectorError', () =>
39
+ Effect.fail(
40
+ new WizardStepError({
41
+ step: 'submit',
42
+ message: 'Submit button not found. The wizard may not have advanced to the final step.',
43
+ }),
44
+ ),
45
+ ),
46
+ );
47
+
48
+ // Click submit
49
+ yield* Effect.tryPromise({
50
+ try: () => submitBtn.element.click(),
51
+ catch: (e) =>
52
+ new WizardStepError({
53
+ step: 'submit',
54
+ message: `Failed to click submit: ${e instanceof Error ? e.message : String(e)}`,
55
+ cause: e,
56
+ }),
57
+ });
58
+
59
+ // Wait for either confirmation page or error
60
+ const outcome = yield* waitForOutcome(page);
61
+
62
+ if (!outcome.confirmationPageReached) {
63
+ return yield* Effect.fail(
64
+ new WizardStepError({
65
+ step: 'submit',
66
+ message: outcome.errorMessage
67
+ ? `Booking submission failed: ${outcome.errorMessage}`
68
+ : 'Booking submission did not reach confirmation page',
69
+ }),
70
+ );
71
+ }
72
+
73
+ return {
74
+ submitted: true,
75
+ confirmationPageReached: true,
76
+ } satisfies SubmitResult;
77
+ }).pipe(
78
+ // Retry once on transient navigation failure
79
+ Effect.retry({
80
+ times: 1,
81
+ schedule: Schedule.spaced('2 seconds'),
82
+ while: (e) => e._tag === 'WizardStepError' && e.message.includes('did not reach'),
83
+ }),
84
+ );
85
+
86
+ // =============================================================================
87
+ // HELPERS
88
+ // =============================================================================
89
+
90
+ interface OutcomeCheck {
91
+ confirmationPageReached: boolean;
92
+ errorMessage: string | null;
93
+ }
94
+
95
+ /**
96
+ * Race between confirmation page appearing and error message appearing.
97
+ * Polls both conditions until one is met or timeout.
98
+ */
99
+ const waitForOutcome = (page: Page): Effect.Effect<OutcomeCheck, WizardStepError> =>
100
+ Effect.gen(function* () {
101
+ const maxWait = 60000;
102
+ const pollInterval = 1000;
103
+ const start = Date.now();
104
+
105
+ while (Date.now() - start < maxWait) {
106
+ // Check for confirmation page via selectors
107
+ const hasConfirmation = yield* probe(page, 'confirmationPage');
108
+ if (hasConfirmation) {
109
+ return { confirmationPageReached: true, errorMessage: null };
110
+ }
111
+
112
+ // Check for confirmation via URL pattern (Acuity redirects after booking)
113
+ const url = page.url();
114
+ if (/\/(confirmation|confirmed|thank-you|complete)/i.test(url)) {
115
+ return { confirmationPageReached: true, errorMessage: null };
116
+ }
117
+
118
+ // Check for confirmation text anywhere on page
119
+ const hasConfirmText = yield* Effect.tryPromise({
120
+ try: () =>
121
+ page
122
+ .$eval(
123
+ 'body',
124
+ (el) => {
125
+ const text = el.textContent?.toLowerCase() ?? '';
126
+ return text.includes('booking confirmed') ||
127
+ text.includes('appointment confirmed') ||
128
+ text.includes('successfully booked') ||
129
+ text.includes('your appointment is scheduled');
130
+ },
131
+ )
132
+ .catch(() => false),
133
+ catch: () => false,
134
+ }).pipe(Effect.orElseSucceed(() => false));
135
+
136
+ if (hasConfirmText) {
137
+ return { confirmationPageReached: true, errorMessage: null };
138
+ }
139
+
140
+ // Check for validation/submission errors
141
+ const errorText = yield* Effect.tryPromise({
142
+ try: () =>
143
+ page
144
+ .$eval(
145
+ '.error-message, .validation-error, .form-error, .alert-danger',
146
+ (el) => el.textContent?.trim() ?? null,
147
+ )
148
+ .catch(() => null),
149
+ catch: () => null,
150
+ }).pipe(Effect.orElseSucceed(() => null));
151
+
152
+ if (errorText) {
153
+ return { confirmationPageReached: false, errorMessage: errorText };
154
+ }
155
+
156
+ // Wait before next poll
157
+ yield* Effect.tryPromise({
158
+ try: () => page.waitForTimeout(pollInterval),
159
+ catch: () =>
160
+ new WizardStepError({ step: 'submit', message: 'Poll wait interrupted' }),
161
+ });
162
+ }
163
+
164
+ return {
165
+ confirmationPageReached: false,
166
+ errorMessage: 'Timed out waiting for confirmation page',
167
+ };
168
+ });
package/src/server.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Entry point - re-exports the middleware HTTP server.
3
+ * When run directly, the server auto-starts (see middleware/server.ts).
4
+ */
5
+ export { server } from './middleware/server.js';
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022", "DOM"],
7
+ "outDir": "dist",
8
+ "rootDir": "src",
9
+ "declaration": true,
10
+ "declarationMap": true,
11
+ "sourceMap": true,
12
+ "strict": true,
13
+ "esModuleInterop": true,
14
+ "skipLibCheck": true,
15
+ "forceConsistentCasingInFileNames": true,
16
+ "resolveJsonModule": true,
17
+ "isolatedModules": true,
18
+ "noUnusedLocals": false,
19
+ "noUnusedParameters": false,
20
+ "noImplicitReturns": true,
21
+ "noFallthroughCasesInSwitch": true
22
+ },
23
+ "include": ["src/**/*.ts"],
24
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
25
+ }