@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.
- package/.github/workflows/build-paper.yml +39 -0
- package/.github/workflows/ci.yml +37 -0
- package/Dockerfile +53 -0
- package/README.md +103 -0
- package/docs/blog-post.mdx +240 -0
- package/docs/paper/IEEEtran.bst +2409 -0
- package/docs/paper/IEEEtran.cls +6347 -0
- package/docs/paper/acuity-middleware-paper.tex +375 -0
- package/docs/paper/balance.sty +87 -0
- package/docs/paper/references.bib +231 -0
- package/docs/paper.md +400 -0
- package/flake.nix +32 -0
- package/modal-app.py +82 -0
- package/package.json +48 -0
- package/src/adapters/acuity-scraper.ts +543 -0
- package/src/adapters/types.ts +193 -0
- package/src/core/types.ts +325 -0
- package/src/index.ts +75 -0
- package/src/middleware/acuity-wizard.ts +456 -0
- package/src/middleware/browser-service.ts +183 -0
- package/src/middleware/errors.ts +70 -0
- package/src/middleware/index.ts +80 -0
- package/src/middleware/remote-adapter.ts +246 -0
- package/src/middleware/selectors.ts +308 -0
- package/src/middleware/server.ts +372 -0
- package/src/middleware/steps/bypass-payment.ts +226 -0
- package/src/middleware/steps/extract.ts +174 -0
- package/src/middleware/steps/fill-form.ts +359 -0
- package/src/middleware/steps/index.ts +27 -0
- package/src/middleware/steps/navigate.ts +537 -0
- package/src/middleware/steps/read-availability.ts +399 -0
- package/src/middleware/steps/read-slots.ts +405 -0
- package/src/middleware/steps/submit.ts +168 -0
- package/src/server.ts +5 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acuity Wizard Adapter
|
|
3
|
+
*
|
|
4
|
+
* Full SchedulingAdapter implementation that uses Effect TS middleware
|
|
5
|
+
* to puppeteer the Acuity booking wizard.
|
|
6
|
+
*
|
|
7
|
+
* Read operations:
|
|
8
|
+
* - getServices: returns static service catalog (if provided) or falls back to scraper
|
|
9
|
+
* - getAvailableDates: Effect program navigates wizard calendar (verified selectors)
|
|
10
|
+
* - getAvailableSlots: Effect program clicks day tile and reads time buttons
|
|
11
|
+
*
|
|
12
|
+
* Write operations:
|
|
13
|
+
* - createBooking/createBookingWithPaymentRef: Effect TS middleware via Playwright
|
|
14
|
+
*
|
|
15
|
+
* This is the bridge between Effect TS (middleware layer) and
|
|
16
|
+
* fp-ts (pipeline/adapter layer).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Effect, Scope, Exit, Cause } from 'effect';
|
|
20
|
+
import * as E from 'fp-ts/Either';
|
|
21
|
+
import * as TE from 'fp-ts/TaskEither';
|
|
22
|
+
import { pipe } from 'fp-ts/function';
|
|
23
|
+
|
|
24
|
+
import type { SchedulingAdapter } from '../adapters/types.js';
|
|
25
|
+
import { createScraperAdapter, type ScraperConfig } from '../adapters/acuity-scraper.js';
|
|
26
|
+
import type {
|
|
27
|
+
Booking,
|
|
28
|
+
BookingRequest,
|
|
29
|
+
Service,
|
|
30
|
+
SchedulingResult,
|
|
31
|
+
} from '../core/types.js';
|
|
32
|
+
import { Errors } from '../core/types.js';
|
|
33
|
+
import { BrowserServiceLive, type BrowserConfig, defaultBrowserConfig } from './browser-service.js';
|
|
34
|
+
import { toSchedulingError, type MiddlewareError } from './errors.js';
|
|
35
|
+
import { createRemoteWizardAdapter, type RemoteAdapterConfig } from './remote-adapter.js';
|
|
36
|
+
import {
|
|
37
|
+
navigateToBooking,
|
|
38
|
+
fillFormFields,
|
|
39
|
+
bypassPayment,
|
|
40
|
+
generateCouponCode,
|
|
41
|
+
submitBooking,
|
|
42
|
+
extractConfirmation,
|
|
43
|
+
toBooking,
|
|
44
|
+
readAvailableDates,
|
|
45
|
+
readTimeSlots,
|
|
46
|
+
type NavigateResult,
|
|
47
|
+
type ConfirmationData,
|
|
48
|
+
} from './steps/index.js';
|
|
49
|
+
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// CONFIGURATION
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
export interface WizardAdapterConfig extends Partial<BrowserConfig> {
|
|
55
|
+
/** Base URL for the Acuity scheduling page */
|
|
56
|
+
baseUrl: string;
|
|
57
|
+
/** Pre-configured coupon code for payment bypass (from ACUITY_BYPASS_COUPON env) */
|
|
58
|
+
couponCode?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Execution mode:
|
|
61
|
+
* - 'local': Run Playwright in-process (default, requires Chromium installed)
|
|
62
|
+
* - 'remote': Proxy all operations to a remote middleware server via HTTP
|
|
63
|
+
*/
|
|
64
|
+
mode?: 'local' | 'remote';
|
|
65
|
+
/** Remote adapter config (required when mode is 'remote') */
|
|
66
|
+
remote?: RemoteAdapterConfig;
|
|
67
|
+
/**
|
|
68
|
+
* Static service catalog. If provided, getServices() returns this list
|
|
69
|
+
* without launching a browser. Recommended for known service sets to avoid
|
|
70
|
+
* expensive browser launches for read-only operations.
|
|
71
|
+
*/
|
|
72
|
+
services?: readonly Service[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// =============================================================================
|
|
76
|
+
// EFFECT PROGRAMS
|
|
77
|
+
// =============================================================================
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Full booking creation via the Acuity wizard.
|
|
81
|
+
*
|
|
82
|
+
* Flow:
|
|
83
|
+
* 1. Click through wizard: service → Book → calendar → time → continue
|
|
84
|
+
* 2. Fill client form (standard + intake fields) → "Continue to Payment" → /payment page
|
|
85
|
+
* 3. Apply 100% gift certificate on payment page (coupon toggle → enter code → Apply)
|
|
86
|
+
* 4. Click "PAY & CONFIRM" at $0 total
|
|
87
|
+
* 5. Extract confirmation data
|
|
88
|
+
*/
|
|
89
|
+
const createBookingWithPaymentRefProgram = (
|
|
90
|
+
request: BookingRequest,
|
|
91
|
+
paymentRef: string,
|
|
92
|
+
paymentProcessor: string,
|
|
93
|
+
couponCode: string,
|
|
94
|
+
service?: Service,
|
|
95
|
+
) =>
|
|
96
|
+
Effect.scoped(
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
// Step 1: Navigate through wizard to client form
|
|
99
|
+
const serviceName = service?.name ?? request.serviceId;
|
|
100
|
+
const nav: NavigateResult = yield* navigateToBooking({
|
|
101
|
+
serviceName,
|
|
102
|
+
datetime: request.datetime,
|
|
103
|
+
client: request.client,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (nav.landingStep !== 'client-form') {
|
|
107
|
+
return yield* Effect.fail({
|
|
108
|
+
_tag: 'WizardStepError' as const,
|
|
109
|
+
step: 'navigate' as const,
|
|
110
|
+
message: `Wizard landed on '${nav.landingStep}' instead of client form. Service or datetime may be unavailable.`,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Step 2: Fill form fields (standard + intake) and advance to /payment page
|
|
115
|
+
yield* fillFormFields({
|
|
116
|
+
client: request.client,
|
|
117
|
+
customFields: request.client.customFields,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Step 3: Apply gift certificate on the payment page (total → $0)
|
|
121
|
+
yield* bypassPayment(couponCode);
|
|
122
|
+
|
|
123
|
+
// Step 4: Click "PAY & CONFIRM" at $0 total
|
|
124
|
+
yield* submitBooking();
|
|
125
|
+
|
|
126
|
+
// Step 5: Extract confirmation
|
|
127
|
+
const confirmation: ConfirmationData = yield* extractConfirmation();
|
|
128
|
+
|
|
129
|
+
return toBooking(
|
|
130
|
+
confirmation,
|
|
131
|
+
request,
|
|
132
|
+
paymentRef,
|
|
133
|
+
paymentProcessor,
|
|
134
|
+
service
|
|
135
|
+
? {
|
|
136
|
+
name: service.name,
|
|
137
|
+
duration: service.duration,
|
|
138
|
+
price: service.price,
|
|
139
|
+
currency: service.currency,
|
|
140
|
+
}
|
|
141
|
+
: undefined,
|
|
142
|
+
);
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Simple booking creation (no payment bypass needed).
|
|
148
|
+
* For card payments that go through Acuity's normal flow.
|
|
149
|
+
*/
|
|
150
|
+
const createBookingProgram = (request: BookingRequest, serviceName?: string) =>
|
|
151
|
+
Effect.scoped(
|
|
152
|
+
Effect.gen(function* () {
|
|
153
|
+
const nav: NavigateResult = yield* navigateToBooking({
|
|
154
|
+
serviceName: serviceName ?? request.serviceId,
|
|
155
|
+
datetime: request.datetime,
|
|
156
|
+
client: request.client,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (nav.landingStep !== 'client-form') {
|
|
160
|
+
return yield* Effect.fail({
|
|
161
|
+
_tag: 'WizardStepError' as const,
|
|
162
|
+
step: 'navigate' as const,
|
|
163
|
+
message: `Wizard landed on '${nav.landingStep}' instead of client form.`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
yield* fillFormFields({
|
|
168
|
+
client: request.client,
|
|
169
|
+
customFields: request.client.customFields,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// No payment bypass - form fills and advances to payment page
|
|
173
|
+
// where Acuity handles card payment normally
|
|
174
|
+
yield* submitBooking();
|
|
175
|
+
const confirmation: ConfirmationData = yield* extractConfirmation();
|
|
176
|
+
|
|
177
|
+
return toBooking(confirmation, request, '', 'acuity');
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// =============================================================================
|
|
182
|
+
// ADAPTER FACTORY
|
|
183
|
+
// =============================================================================
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Create a full SchedulingAdapter that puppeteers the Acuity wizard.
|
|
187
|
+
*
|
|
188
|
+
* Read operations delegate to the existing scraper.
|
|
189
|
+
* Write operations use Effect TS middleware via Playwright.
|
|
190
|
+
*/
|
|
191
|
+
export const createWizardAdapter = (config: WizardAdapterConfig): SchedulingAdapter => {
|
|
192
|
+
// Remote mode: delegate everything to the middleware server
|
|
193
|
+
if (config.mode === 'remote') {
|
|
194
|
+
if (!config.remote) {
|
|
195
|
+
throw new Error('WizardAdapterConfig.remote is required when mode is "remote"');
|
|
196
|
+
}
|
|
197
|
+
return createRemoteWizardAdapter({
|
|
198
|
+
...config.remote,
|
|
199
|
+
couponCode: config.remote.couponCode ?? config.couponCode,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Local mode: run Playwright in-process
|
|
204
|
+
const browserConfig: BrowserConfig = {
|
|
205
|
+
...defaultBrowserConfig,
|
|
206
|
+
...config,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const layer = BrowserServiceLive(browserConfig);
|
|
210
|
+
|
|
211
|
+
// Helper: run an Effect program and convert to fp-ts TaskEither.
|
|
212
|
+
// Uses runPromiseExit to properly access the typed error E without
|
|
213
|
+
// dealing with FiberFailure wrappers from runPromise.
|
|
214
|
+
const runEffect = <A>(
|
|
215
|
+
effect: Effect.Effect<A, MiddlewareError>,
|
|
216
|
+
): SchedulingResult<A> =>
|
|
217
|
+
() =>
|
|
218
|
+
Effect.runPromiseExit(effect.pipe(Effect.provide(layer))).then(
|
|
219
|
+
(exit) => {
|
|
220
|
+
if (Exit.isSuccess(exit)) {
|
|
221
|
+
return E.right(exit.value) as E.Either<never, A>;
|
|
222
|
+
}
|
|
223
|
+
// Extract the first failure from the cause
|
|
224
|
+
const failure = Cause.failureOption(exit.cause);
|
|
225
|
+
if (failure._tag === 'Some') {
|
|
226
|
+
return E.left(toSchedulingError(failure.value));
|
|
227
|
+
}
|
|
228
|
+
// Defect (unexpected error) — convert cause to string
|
|
229
|
+
const pretty = Cause.pretty(exit.cause);
|
|
230
|
+
console.error('[runEffect] Defect:', pretty);
|
|
231
|
+
return E.left(
|
|
232
|
+
Errors.infrastructure(
|
|
233
|
+
'UNKNOWN',
|
|
234
|
+
`Effect defect: ${pretty}`,
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Static services from config (avoids browser launch for service listing)
|
|
241
|
+
const staticServices: Service[] | null = config.services ? [...config.services] : null;
|
|
242
|
+
|
|
243
|
+
// Scraper fallback for services if no static list provided
|
|
244
|
+
const scraperConfig: ScraperConfig = {
|
|
245
|
+
baseUrl: config.baseUrl,
|
|
246
|
+
headless: browserConfig.headless,
|
|
247
|
+
timeout: browserConfig.timeout,
|
|
248
|
+
userAgent: browserConfig.userAgent,
|
|
249
|
+
executablePath: browserConfig.executablePath,
|
|
250
|
+
launchArgs: browserConfig.launchArgs ? [...browserConfig.launchArgs] : undefined,
|
|
251
|
+
};
|
|
252
|
+
const scraper = createScraperAdapter(scraperConfig);
|
|
253
|
+
|
|
254
|
+
// Cache services for getService lookups
|
|
255
|
+
let cachedServices: Service[] | null = staticServices;
|
|
256
|
+
|
|
257
|
+
// Helper: resolve service name from ID using cached services
|
|
258
|
+
const resolveServiceName = (serviceId: string): string | undefined =>
|
|
259
|
+
cachedServices?.find((s) => s.id === serviceId)?.name;
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
name: 'acuity-wizard',
|
|
263
|
+
|
|
264
|
+
// -----------------------------------------------------------------------
|
|
265
|
+
// Read operations
|
|
266
|
+
// -----------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
getServices: () => {
|
|
269
|
+
if (staticServices) {
|
|
270
|
+
return TE.right(staticServices);
|
|
271
|
+
}
|
|
272
|
+
// Fallback to scraper (may fail with broken selectors)
|
|
273
|
+
return pipe(
|
|
274
|
+
scraper.getServices(),
|
|
275
|
+
TE.tap((services) =>
|
|
276
|
+
TE.fromIO(() => {
|
|
277
|
+
cachedServices = services;
|
|
278
|
+
}),
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
getService: (serviceId: string) => {
|
|
284
|
+
if (cachedServices) {
|
|
285
|
+
const found = cachedServices.find((s) => s.id === serviceId);
|
|
286
|
+
return found
|
|
287
|
+
? TE.right(found)
|
|
288
|
+
: TE.left(Errors.acuity('NOT_FOUND', `Service ${serviceId} not found`));
|
|
289
|
+
}
|
|
290
|
+
return pipe(
|
|
291
|
+
scraper.getServices(),
|
|
292
|
+
TE.chain((services) => {
|
|
293
|
+
cachedServices = services;
|
|
294
|
+
const found = services.find((s) => s.id === serviceId);
|
|
295
|
+
return found
|
|
296
|
+
? TE.right(found)
|
|
297
|
+
: TE.left(Errors.acuity('NOT_FOUND', `Service ${serviceId} not found`));
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
},
|
|
301
|
+
|
|
302
|
+
getProviders: () =>
|
|
303
|
+
TE.right([
|
|
304
|
+
{
|
|
305
|
+
id: '1',
|
|
306
|
+
name: 'Default Provider',
|
|
307
|
+
email: 'provider@example.com',
|
|
308
|
+
description: 'Primary provider',
|
|
309
|
+
timezone: 'America/New_York',
|
|
310
|
+
},
|
|
311
|
+
]),
|
|
312
|
+
|
|
313
|
+
getProvider: () =>
|
|
314
|
+
TE.right({
|
|
315
|
+
id: '1',
|
|
316
|
+
name: 'Default Provider',
|
|
317
|
+
email: 'provider@example.com',
|
|
318
|
+
description: 'Primary provider',
|
|
319
|
+
timezone: 'America/New_York',
|
|
320
|
+
}),
|
|
321
|
+
|
|
322
|
+
getProvidersForService: () =>
|
|
323
|
+
TE.right([
|
|
324
|
+
{
|
|
325
|
+
id: '1',
|
|
326
|
+
name: 'Default Provider',
|
|
327
|
+
email: 'provider@example.com',
|
|
328
|
+
description: 'Primary provider',
|
|
329
|
+
timezone: 'America/New_York',
|
|
330
|
+
},
|
|
331
|
+
]),
|
|
332
|
+
|
|
333
|
+
getAvailableDates: (params) => {
|
|
334
|
+
const serviceName = resolveServiceName(params.serviceId);
|
|
335
|
+
if (!serviceName) {
|
|
336
|
+
return TE.left(
|
|
337
|
+
Errors.acuity('NOT_FOUND', `Cannot resolve service name for ID ${params.serviceId}. Provide static services in config.`),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return runEffect(
|
|
341
|
+
Effect.scoped(
|
|
342
|
+
readAvailableDates({
|
|
343
|
+
serviceName,
|
|
344
|
+
// Don't pass appointmentTypeId — our internal IDs don't match Acuity URL IDs
|
|
345
|
+
targetMonth: params.startDate?.slice(0, 7),
|
|
346
|
+
monthsToScan: 2,
|
|
347
|
+
}),
|
|
348
|
+
) as Effect.Effect<Array<{ date: string; slots: number }>, MiddlewareError>,
|
|
349
|
+
);
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
getAvailableSlots: (params) => {
|
|
353
|
+
const serviceName = resolveServiceName(params.serviceId);
|
|
354
|
+
if (!serviceName) {
|
|
355
|
+
return TE.left(
|
|
356
|
+
Errors.acuity('NOT_FOUND', `Cannot resolve service name for ID ${params.serviceId}. Provide static services in config.`),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
return runEffect(
|
|
360
|
+
Effect.scoped(
|
|
361
|
+
readTimeSlots({
|
|
362
|
+
serviceName,
|
|
363
|
+
date: params.date,
|
|
364
|
+
}),
|
|
365
|
+
) as Effect.Effect<Array<{ datetime: string; available: boolean }>, MiddlewareError>,
|
|
366
|
+
);
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
checkSlotAvailability: (params) => {
|
|
370
|
+
const serviceName = resolveServiceName(params.serviceId);
|
|
371
|
+
if (!serviceName) {
|
|
372
|
+
return TE.left(
|
|
373
|
+
Errors.acuity('NOT_FOUND', `Cannot resolve service name for ID ${params.serviceId}`),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
return pipe(
|
|
377
|
+
runEffect(
|
|
378
|
+
Effect.scoped(
|
|
379
|
+
readTimeSlots({
|
|
380
|
+
serviceName,
|
|
381
|
+
date: params.datetime.split('T')[0],
|
|
382
|
+
}),
|
|
383
|
+
) as Effect.Effect<Array<{ datetime: string; available: boolean }>, MiddlewareError>,
|
|
384
|
+
),
|
|
385
|
+
TE.map((slots) => {
|
|
386
|
+
// Slots return local time (no TZ suffix: "2026-03-07T14:00:00").
|
|
387
|
+
// Request datetime should also be local, but normalize both by
|
|
388
|
+
// stripping any trailing Z or offset for comparison.
|
|
389
|
+
const normalize = (dt: string) => dt.replace(/([+-]\d{2}:\d{2}|Z)$/, '');
|
|
390
|
+
const requestNorm = normalize(params.datetime);
|
|
391
|
+
return slots.some((s) => s.available && normalize(s.datetime) === requestNorm);
|
|
392
|
+
}),
|
|
393
|
+
);
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
// -----------------------------------------------------------------------
|
|
397
|
+
// Reservation - not supported (pipeline has graceful fallback)
|
|
398
|
+
// -----------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
createReservation: () =>
|
|
401
|
+
TE.left(
|
|
402
|
+
Errors.reservation(
|
|
403
|
+
'BLOCK_FAILED',
|
|
404
|
+
'Reservations not supported by wizard adapter',
|
|
405
|
+
),
|
|
406
|
+
),
|
|
407
|
+
|
|
408
|
+
releaseReservation: () => TE.right(undefined),
|
|
409
|
+
|
|
410
|
+
// -----------------------------------------------------------------------
|
|
411
|
+
// Write operations - Effect TS middleware
|
|
412
|
+
// -----------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
createBooking: (request) => {
|
|
415
|
+
const serviceName = cachedServices?.find((s) => s.id === request.serviceId)?.name;
|
|
416
|
+
return runEffect(
|
|
417
|
+
createBookingProgram(request, serviceName) as Effect.Effect<Booking, MiddlewareError>,
|
|
418
|
+
);
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
createBookingWithPaymentRef: (request, paymentRef, paymentProcessor) => {
|
|
422
|
+
const coupon = config.couponCode ?? generateCouponCode(paymentRef, paymentProcessor);
|
|
423
|
+
const service = cachedServices?.find((s) => s.id === request.serviceId);
|
|
424
|
+
|
|
425
|
+
return runEffect(
|
|
426
|
+
createBookingWithPaymentRefProgram(
|
|
427
|
+
request,
|
|
428
|
+
paymentRef,
|
|
429
|
+
paymentProcessor,
|
|
430
|
+
coupon,
|
|
431
|
+
service,
|
|
432
|
+
) as Effect.Effect<Booking, MiddlewareError>,
|
|
433
|
+
);
|
|
434
|
+
},
|
|
435
|
+
|
|
436
|
+
getBooking: () =>
|
|
437
|
+
TE.left(Errors.acuity('NOT_IMPLEMENTED', 'Get booking not yet supported via wizard')),
|
|
438
|
+
|
|
439
|
+
cancelBooking: () =>
|
|
440
|
+
TE.left(Errors.acuity('NOT_IMPLEMENTED', 'Cancel not yet supported via wizard')),
|
|
441
|
+
|
|
442
|
+
rescheduleBooking: () =>
|
|
443
|
+
TE.left(
|
|
444
|
+
Errors.acuity('NOT_IMPLEMENTED', 'Reschedule not yet supported via wizard'),
|
|
445
|
+
),
|
|
446
|
+
|
|
447
|
+
// -----------------------------------------------------------------------
|
|
448
|
+
// Client - pass-through (client data comes from our form, not Acuity)
|
|
449
|
+
// -----------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
findOrCreateClient: (client) =>
|
|
452
|
+
TE.right({ id: `local-${client.email}`, isNew: true }),
|
|
453
|
+
|
|
454
|
+
getClientByEmail: () => TE.right(null),
|
|
455
|
+
};
|
|
456
|
+
};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserService Layer
|
|
3
|
+
*
|
|
4
|
+
* Effect TS Layer providing managed Playwright browser lifecycle.
|
|
5
|
+
* The browser and pages are acquired/released via Effect's Scope,
|
|
6
|
+
* ensuring proper cleanup even on errors or interruptions.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Context, Effect, Layer, Scope } from 'effect';
|
|
10
|
+
import type { Browser, Page } from 'playwright-core';
|
|
11
|
+
import { BrowserError } from './errors.js';
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// CONFIGURATION
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
export interface BrowserConfig {
|
|
18
|
+
/** Base URL for the Acuity scheduling page */
|
|
19
|
+
readonly baseUrl: string;
|
|
20
|
+
/** Run browser in headless mode (default: true) */
|
|
21
|
+
readonly headless: boolean;
|
|
22
|
+
/** Default timeout for page operations in ms (default: 30000) */
|
|
23
|
+
readonly timeout: number;
|
|
24
|
+
/** User agent string */
|
|
25
|
+
readonly userAgent: string;
|
|
26
|
+
/** Take screenshot on failure (default: true) */
|
|
27
|
+
readonly screenshotOnFailure: boolean;
|
|
28
|
+
/** Directory for failure screenshots */
|
|
29
|
+
readonly screenshotDir: string;
|
|
30
|
+
/** Path to Chromium executable (for Lambda/serverless) */
|
|
31
|
+
readonly executablePath?: string;
|
|
32
|
+
/** Additional chromium.launch() args (e.g., Lambda sandbox flags) */
|
|
33
|
+
readonly launchArgs?: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const defaultBrowserConfig: BrowserConfig = {
|
|
37
|
+
baseUrl: 'https://MassageIthaca.as.me',
|
|
38
|
+
headless: true,
|
|
39
|
+
timeout: 30000,
|
|
40
|
+
userAgent:
|
|
41
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
42
|
+
screenshotOnFailure: true,
|
|
43
|
+
screenshotDir: '/tmp/scheduling-kit-screenshots',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// SERVICE DEFINITION
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
export interface BrowserServiceShape {
|
|
51
|
+
/** Get a managed Page (scoped - auto-closed when scope ends) */
|
|
52
|
+
readonly acquirePage: Effect.Effect<Page, BrowserError, Scope.Scope>;
|
|
53
|
+
/** Take a screenshot of the most recently created page */
|
|
54
|
+
readonly screenshot: (label: string) => Effect.Effect<Buffer, BrowserError>;
|
|
55
|
+
/** The browser configuration */
|
|
56
|
+
readonly config: BrowserConfig;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class BrowserService extends Context.Tag('scheduling-kit/BrowserService')<
|
|
60
|
+
BrowserService,
|
|
61
|
+
BrowserServiceShape
|
|
62
|
+
>() {}
|
|
63
|
+
|
|
64
|
+
// =============================================================================
|
|
65
|
+
// LIVE IMPLEMENTATION
|
|
66
|
+
// =============================================================================
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create a live BrowserService layer backed by Playwright Chromium.
|
|
70
|
+
* The browser is launched once when the layer is created and closed
|
|
71
|
+
* when the layer scope ends.
|
|
72
|
+
*/
|
|
73
|
+
export const BrowserServiceLive = (
|
|
74
|
+
config: Partial<BrowserConfig> = {},
|
|
75
|
+
): Layer.Layer<BrowserService, BrowserError> => {
|
|
76
|
+
const cfg: BrowserConfig = { ...defaultBrowserConfig, ...config };
|
|
77
|
+
|
|
78
|
+
return Layer.scoped(
|
|
79
|
+
BrowserService,
|
|
80
|
+
Effect.gen(function* () {
|
|
81
|
+
// Dynamic import - try playwright-core first (lighter, no bundled browsers),
|
|
82
|
+
// fall back to full playwright which includes its own Chromium
|
|
83
|
+
const chromium = yield* Effect.tryPromise({
|
|
84
|
+
try: async (): Promise<typeof import('playwright-core').chromium> => {
|
|
85
|
+
try {
|
|
86
|
+
const pw = await import('playwright-core');
|
|
87
|
+
return pw.chromium;
|
|
88
|
+
} catch {
|
|
89
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
90
|
+
const pw = await (import('playwright-core') as any);
|
|
91
|
+
return pw.chromium;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
catch: () =>
|
|
95
|
+
new BrowserError({
|
|
96
|
+
reason: 'PLAYWRIGHT_MISSING',
|
|
97
|
+
cause: new Error(
|
|
98
|
+
'playwright-core or playwright is required for the wizard middleware. ' +
|
|
99
|
+
'Install with: pnpm add playwright-core',
|
|
100
|
+
),
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Launch browser (released when scope ends)
|
|
105
|
+
const browser: Browser = yield* Effect.acquireRelease(
|
|
106
|
+
Effect.tryPromise({
|
|
107
|
+
try: () =>
|
|
108
|
+
chromium.launch({
|
|
109
|
+
headless: cfg.headless,
|
|
110
|
+
executablePath: cfg.executablePath,
|
|
111
|
+
args: cfg.launchArgs ? [...cfg.launchArgs] : undefined,
|
|
112
|
+
}),
|
|
113
|
+
catch: (e) => new BrowserError({ reason: 'LAUNCH_FAILED', cause: e }),
|
|
114
|
+
}),
|
|
115
|
+
(browser) =>
|
|
116
|
+
Effect.promise(() => browser.close()).pipe(Effect.ignoreLogged),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Create a single managed page for the layer's lifetime.
|
|
120
|
+
// All step programs share this page within a scope.
|
|
121
|
+
const page: Page = yield* Effect.acquireRelease(
|
|
122
|
+
Effect.tryPromise({
|
|
123
|
+
try: async () => {
|
|
124
|
+
const p = await browser.newPage({ userAgent: cfg.userAgent });
|
|
125
|
+
p.setDefaultTimeout(cfg.timeout);
|
|
126
|
+
return p;
|
|
127
|
+
},
|
|
128
|
+
catch: (e) => new BrowserError({ reason: 'PAGE_FAILED', cause: e }),
|
|
129
|
+
}),
|
|
130
|
+
(p) =>
|
|
131
|
+
Effect.promise(() => p.close()).pipe(Effect.ignoreLogged),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// acquirePage returns the singleton page — cleanup is handled by the
|
|
135
|
+
// layer scope above. The no-op release keeps the Scope type requirement
|
|
136
|
+
// so callers still need Effect.scoped.
|
|
137
|
+
const acquirePage = Effect.acquireRelease(
|
|
138
|
+
Effect.succeed(page),
|
|
139
|
+
() => Effect.void,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const screenshot = (label: string) =>
|
|
143
|
+
Effect.tryPromise({
|
|
144
|
+
try: async () => {
|
|
145
|
+
if (page.isClosed()) {
|
|
146
|
+
throw new Error('No active page for screenshot');
|
|
147
|
+
}
|
|
148
|
+
const buffer = await page.screenshot({
|
|
149
|
+
path: `${cfg.screenshotDir}/${label}-${Date.now()}.png`,
|
|
150
|
+
fullPage: true,
|
|
151
|
+
});
|
|
152
|
+
return buffer;
|
|
153
|
+
},
|
|
154
|
+
catch: (e) => new BrowserError({ reason: 'SCREENSHOT_FAILED', cause: e }),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return { acquirePage, screenshot, config: cfg };
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// =============================================================================
|
|
163
|
+
// TEST IMPLEMENTATION
|
|
164
|
+
// =============================================================================
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A mock BrowserService for unit tests.
|
|
168
|
+
* Does not launch a real browser.
|
|
169
|
+
*/
|
|
170
|
+
export const BrowserServiceTest = (
|
|
171
|
+
config: Partial<BrowserConfig> = {},
|
|
172
|
+
): Layer.Layer<BrowserService> => {
|
|
173
|
+
const cfg: BrowserConfig = { ...defaultBrowserConfig, ...config };
|
|
174
|
+
|
|
175
|
+
return Layer.succeed(BrowserService, {
|
|
176
|
+
acquirePage: Effect.die(
|
|
177
|
+
new Error('BrowserServiceTest: acquirePage called - use a real browser for integration tests'),
|
|
178
|
+
) as unknown as Effect.Effect<Page, BrowserError, Scope.Scope>,
|
|
179
|
+
screenshot: () =>
|
|
180
|
+
Effect.succeed(Buffer.from('mock-screenshot')),
|
|
181
|
+
config: cfg,
|
|
182
|
+
});
|
|
183
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware Error Types
|
|
3
|
+
*
|
|
4
|
+
* Effect TS error types for the Acuity wizard middleware.
|
|
5
|
+
* Bridges to fp-ts SchedulingError at the adapter boundary.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Data } from 'effect';
|
|
9
|
+
import { Errors, type SchedulingError } from '../core/types.js';
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// ERROR CLASSES
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
export class BrowserError extends Data.TaggedError('BrowserError')<{
|
|
16
|
+
readonly reason:
|
|
17
|
+
| 'PLAYWRIGHT_MISSING'
|
|
18
|
+
| 'LAUNCH_FAILED'
|
|
19
|
+
| 'PAGE_FAILED'
|
|
20
|
+
| 'SCREENSHOT_FAILED'
|
|
21
|
+
| 'NAVIGATION_FAILED';
|
|
22
|
+
readonly cause?: unknown;
|
|
23
|
+
}> {}
|
|
24
|
+
|
|
25
|
+
export class SelectorError extends Data.TaggedError('SelectorError')<{
|
|
26
|
+
readonly candidates: readonly string[];
|
|
27
|
+
readonly message: string;
|
|
28
|
+
}> {}
|
|
29
|
+
|
|
30
|
+
export class WizardStepError extends Data.TaggedError('WizardStepError')<{
|
|
31
|
+
readonly step: 'navigate' | 'fill-form' | 'bypass-payment' | 'submit' | 'extract' | 'read-availability' | 'read-slots';
|
|
32
|
+
readonly message: string;
|
|
33
|
+
readonly screenshot?: Buffer;
|
|
34
|
+
readonly cause?: unknown;
|
|
35
|
+
}> {}
|
|
36
|
+
|
|
37
|
+
export class CouponError extends Data.TaggedError('CouponError')<{
|
|
38
|
+
readonly code: string;
|
|
39
|
+
readonly message: string;
|
|
40
|
+
}> {}
|
|
41
|
+
|
|
42
|
+
export type MiddlewareError = BrowserError | SelectorError | WizardStepError | CouponError;
|
|
43
|
+
|
|
44
|
+
// =============================================================================
|
|
45
|
+
// BRIDGE: Effect errors -> fp-ts SchedulingError
|
|
46
|
+
// =============================================================================
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert Effect middleware errors to fp-ts SchedulingError
|
|
50
|
+
* for compatibility with the existing booking pipeline.
|
|
51
|
+
*/
|
|
52
|
+
export const toSchedulingError = (error: MiddlewareError): SchedulingError => {
|
|
53
|
+
switch (error._tag) {
|
|
54
|
+
case 'BrowserError':
|
|
55
|
+
return Errors.infrastructure(
|
|
56
|
+
error.reason === 'PLAYWRIGHT_MISSING' ? 'UNKNOWN' : 'NETWORK',
|
|
57
|
+
`Browser error: ${error.reason}`,
|
|
58
|
+
error.cause instanceof Error ? error.cause : undefined,
|
|
59
|
+
);
|
|
60
|
+
case 'SelectorError':
|
|
61
|
+
return Errors.acuity('SCRAPE_FAILED', error.message);
|
|
62
|
+
case 'WizardStepError':
|
|
63
|
+
return Errors.acuity(
|
|
64
|
+
'SCRAPE_FAILED',
|
|
65
|
+
`Wizard step '${error.step}' failed: ${error.message}`,
|
|
66
|
+
);
|
|
67
|
+
case 'CouponError':
|
|
68
|
+
return Errors.acuity('BOOKING_FAILED', `Coupon error: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
};
|