@porulle/plugin-appointments 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/README.md +60 -0
- package/dist/analytics-models.d.ts +4 -0
- package/dist/analytics-models.d.ts.map +1 -0
- package/dist/analytics-models.js +27 -0
- package/dist/hooks.d.ts +13 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +12 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +60 -0
- package/dist/routes/availability.d.ts +8 -0
- package/dist/routes/availability.d.ts.map +1 -0
- package/dist/routes/availability.js +118 -0
- package/dist/routes/bookings.d.ts +8 -0
- package/dist/routes/bookings.d.ts.map +1 -0
- package/dist/routes/bookings.js +122 -0
- package/dist/routes/my-bookings.d.ts +6 -0
- package/dist/routes/my-bookings.d.ts.map +1 -0
- package/dist/routes/my-bookings.js +22 -0
- package/dist/routes/providers.d.ts +6 -0
- package/dist/routes/providers.d.ts.map +1 -0
- package/dist/routes/providers.js +84 -0
- package/dist/routes/services.d.ts +6 -0
- package/dist/routes/services.d.ts.map +1 -0
- package/dist/routes/services.js +68 -0
- package/dist/routes/util.d.ts +4 -0
- package/dist/routes/util.d.ts.map +1 -0
- package/dist/routes/util.js +12 -0
- package/dist/schema.d.ts +1476 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +128 -0
- package/dist/services/booking-service.d.ts +156 -0
- package/dist/services/booking-service.d.ts.map +1 -0
- package/dist/services/booking-service.js +249 -0
- package/dist/services/provider-service.d.ts +328 -0
- package/dist/services/provider-service.d.ts.map +1 -0
- package/dist/services/provider-service.js +152 -0
- package/dist/services/slot-generation.d.ts +15 -0
- package/dist/services/slot-generation.d.ts.map +1 -0
- package/dist/services/slot-generation.js +121 -0
- package/dist/services/slot-service.d.ts +11 -0
- package/dist/services/slot-service.d.ts.map +1 -0
- package/dist/services/slot-service.js +91 -0
- package/dist/tasks.d.ts +43 -0
- package/dist/tasks.d.ts.map +1 -0
- package/dist/tasks.js +107 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/package.json +60 -0
- package/src/analytics-models.ts +30 -0
- package/src/hooks.ts +15 -0
- package/src/index.ts +74 -0
- package/src/routes/availability.ts +153 -0
- package/src/routes/bookings.ts +141 -0
- package/src/routes/my-bookings.ts +28 -0
- package/src/routes/providers.ts +97 -0
- package/src/routes/services.ts +78 -0
- package/src/routes/util.ts +11 -0
- package/src/schema.ts +144 -0
- package/src/services/booking-service.ts +340 -0
- package/src/services/provider-service.ts +223 -0
- package/src/services/slot-generation.ts +156 -0
- package/src/services/slot-service.ts +128 -0
- package/src/tasks.ts +141 -0
- package/src/types.ts +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# @porulle/plugin-appointments
|
|
2
|
+
|
|
3
|
+
Headless booking for service types, providers, availability, and customer appointments with slot-based scheduling.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @porulle/plugin-appointments
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Add to `commerce.config.ts`:
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { appointmentPlugin } from "@porulle/plugin-appointments";
|
|
15
|
+
|
|
16
|
+
export default defineConfig({
|
|
17
|
+
// ...
|
|
18
|
+
plugins: [appointmentPlugin()],
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Add to `drizzle.config.ts`:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
schema: [
|
|
26
|
+
"./node_modules/@porulle/plugin-appointments/src/schema.ts",
|
|
27
|
+
// ...
|
|
28
|
+
],
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What it does
|
|
32
|
+
|
|
33
|
+
Models service types, staff/resources (providers), weekly templates, breaks, day overrides, and bookings with payments. `BookingService` can enqueue notification jobs when a `JobsAdapter` is available from the kernel.
|
|
34
|
+
|
|
35
|
+
## Routes exposed
|
|
36
|
+
|
|
37
|
+
- **`/appointments/services`** — `GET/POST /`, `GET/PATCH/DELETE /{id}` (service types)
|
|
38
|
+
- **`/appointments/providers`** — `GET/POST /`, `GET/PATCH/DELETE /{id}`, `POST/GET /{id}/services`
|
|
39
|
+
- **`/appointments/bookings`** — `GET/POST /`, `GET /{id}`, `POST /{id}/confirm|complete|no-show|cancel|reschedule`
|
|
40
|
+
- **`/appointments/availability`** — `PUT/GET /{providerId}/weekly`, `POST/GET/DELETE` breaks and overrides, `GET /{providerId}/slots`
|
|
41
|
+
- **`/appointments/my-bookings`** — `GET /`, `GET /{id}` (authenticated customer)
|
|
42
|
+
|
|
43
|
+
## Hooks
|
|
44
|
+
|
|
45
|
+
**Emitted:** none (booking notifications use `BookingService` + jobs adapter, not core hooks).
|
|
46
|
+
|
|
47
|
+
**Consumed:** none.
|
|
48
|
+
|
|
49
|
+
## MCP tools
|
|
50
|
+
|
|
51
|
+
- **`appointments_booking`** — `create`, `cancel`, `get`, `list_by_provider`
|
|
52
|
+
- **`appointments_catalog`** — `list_services`, `list_providers`, `check_availability`
|
|
53
|
+
|
|
54
|
+
## Configuration options
|
|
55
|
+
|
|
56
|
+
`AppointmentPluginOptions`: `defaultDurationMinutes`, `defaultBufferBeforeMinutes`, `defaultBufferAfterMinutes`, `minNoticeMinutes`, `maxAdvanceDays`, `defaultTimezone`, `autoConfirmCashBookings`.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"analytics-models.d.ts","sourceRoot":"","sources":["../src/analytics-models.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,eAAO,MAAM,0BAA0B,EAAE,cAuBxC,CAAC;AAEF,eAAO,MAAM,4BAA4B,EAAE,cAAc,EAExD,CAAC"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const APPOINTMENT_BOOKINGS_MODEL = {
|
|
2
|
+
name: "AppointmentBookings",
|
|
3
|
+
table: "appointment_bookings",
|
|
4
|
+
measures: {
|
|
5
|
+
count: { type: "count" },
|
|
6
|
+
revenue: { sql: "price_cents", type: "sum" },
|
|
7
|
+
averagePrice: { sql: "price_cents", type: "avg" },
|
|
8
|
+
},
|
|
9
|
+
dimensions: {
|
|
10
|
+
id: { sql: "id", type: "string" },
|
|
11
|
+
providerId: { sql: "provider_id", type: "string" },
|
|
12
|
+
serviceTypeId: { sql: "service_type_id", type: "string" },
|
|
13
|
+
customerId: { sql: "customer_id", type: "string" },
|
|
14
|
+
status: { sql: "status", type: "string" },
|
|
15
|
+
paymentMethod: { sql: "payment_method", type: "string" },
|
|
16
|
+
startTime: { sql: "start_time", type: "time" },
|
|
17
|
+
createdAt: { sql: "created_at", type: "time" },
|
|
18
|
+
},
|
|
19
|
+
segments: {
|
|
20
|
+
completed: { sql: "status = 'completed'" },
|
|
21
|
+
cancelled: { sql: "status = 'cancelled'" },
|
|
22
|
+
noShow: { sql: "status = 'no_show'" },
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
export const APPOINTMENT_ANALYTICS_MODELS = [
|
|
26
|
+
APPOINTMENT_BOOKINGS_MODEL,
|
|
27
|
+
];
|
package/dist/hooks.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { PluginHookRegistration } from "@porulle/core";
|
|
2
|
+
import type { AppointmentPluginOptions } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Appointment plugin hooks.
|
|
5
|
+
*
|
|
6
|
+
* Notification job enqueueing (reminders, cancellation notices, etc.) is handled
|
|
7
|
+
* directly by BookingService, not via hooks. The BookingService receives a
|
|
8
|
+
* JobsAdapter from the kernel's service container and enqueues jobs inline
|
|
9
|
+
* after successful operations. This avoids the dead-code problem where plugin
|
|
10
|
+
* hooks register handlers for custom keys that nobody fires.
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildHooks(_options: AppointmentPluginOptions): PluginHookRegistration[];
|
|
13
|
+
//# sourceMappingURL=hooks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hooks.d.ts","sourceRoot":"","sources":["../src/hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,eAAe,CAAC;AAC5D,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAE3D;;;;;;;;GAQG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,wBAAwB,GAAG,sBAAsB,EAAE,CAEvF"}
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Appointment plugin hooks.
|
|
3
|
+
*
|
|
4
|
+
* Notification job enqueueing (reminders, cancellation notices, etc.) is handled
|
|
5
|
+
* directly by BookingService, not via hooks. The BookingService receives a
|
|
6
|
+
* JobsAdapter from the kernel's service container and enqueues jobs inline
|
|
7
|
+
* after successful operations. This avoids the dead-code problem where plugin
|
|
8
|
+
* hooks register handlers for custom keys that nobody fires.
|
|
9
|
+
*/
|
|
10
|
+
export function buildHooks(_options) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AppointmentPluginOptions } from "./types.js";
|
|
2
|
+
export type { AppointmentPluginOptions } from "./types.js";
|
|
3
|
+
export { APPOINTMENT_EMAIL_TASKS } from "./tasks.js";
|
|
4
|
+
export declare function appointmentPlugin(options?: AppointmentPluginOptions): import("@porulle/core").CommercePlugin;
|
|
5
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,wBAAwB,EAAM,MAAM,YAAY,CAAC;AAE/D,YAAY,EAAE,wBAAwB,EAAE,MAAM,YAAY,CAAC;AAC3D,OAAO,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAcrD,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,wBAA6B,0CAwCvE"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { defineCommercePlugin } from "@porulle/core";
|
|
2
|
+
import { APPOINTMENT_ANALYTICS_MODELS } from "./analytics-models.js";
|
|
3
|
+
import { serviceTypes, providers, providerServices, weeklyAvailability, availabilityOverrides, breaks, bookings, bookingPayments, } from "./schema.js";
|
|
4
|
+
import { ProviderService } from "./services/provider-service.js";
|
|
5
|
+
import { SlotService } from "./services/slot-service.js";
|
|
6
|
+
import { BookingService } from "./services/booking-service.js";
|
|
7
|
+
import { buildHooks } from "./hooks.js";
|
|
8
|
+
import { buildServiceRoutes } from "./routes/services.js";
|
|
9
|
+
import { buildProviderRoutes } from "./routes/providers.js";
|
|
10
|
+
import { buildAvailabilityRoutes } from "./routes/availability.js";
|
|
11
|
+
import { buildBookingRoutes } from "./routes/bookings.js";
|
|
12
|
+
import { buildMyBookingRoutes } from "./routes/my-bookings.js";
|
|
13
|
+
export { APPOINTMENT_EMAIL_TASKS } from "./tasks.js";
|
|
14
|
+
function createServices(db, options, kernelServices) {
|
|
15
|
+
const provider = new ProviderService(db);
|
|
16
|
+
const slots = new SlotService(db, {
|
|
17
|
+
minNoticeMinutes: options.minNoticeMinutes ?? 0,
|
|
18
|
+
maxAdvanceDays: options.maxAdvanceDays ?? 60,
|
|
19
|
+
});
|
|
20
|
+
// Wire jobs adapter from kernel services so BookingService can enqueue notifications
|
|
21
|
+
const jobs = kernelServices?.jobs;
|
|
22
|
+
const booking = new BookingService(db, slots, undefined, jobs);
|
|
23
|
+
return { provider, slots, booking };
|
|
24
|
+
}
|
|
25
|
+
export function appointmentPlugin(options = {}) {
|
|
26
|
+
return defineCommercePlugin({
|
|
27
|
+
id: "appointments",
|
|
28
|
+
version: "1.0.0",
|
|
29
|
+
permissions: [
|
|
30
|
+
{ scope: "appointments:admin", description: "Full appointment administration (services, providers)" },
|
|
31
|
+
{ scope: "appointments:manage", description: "Manage bookings and availability (for providers)" },
|
|
32
|
+
{ scope: "appointments:book", description: "Create and manage own bookings (for customers)" },
|
|
33
|
+
],
|
|
34
|
+
schema: () => ({
|
|
35
|
+
serviceTypes,
|
|
36
|
+
providers,
|
|
37
|
+
providerServices,
|
|
38
|
+
weeklyAvailability,
|
|
39
|
+
availabilityOverrides,
|
|
40
|
+
breaks,
|
|
41
|
+
bookings,
|
|
42
|
+
bookingPayments,
|
|
43
|
+
}),
|
|
44
|
+
hooks: () => buildHooks(options),
|
|
45
|
+
routes: (ctx) => {
|
|
46
|
+
const db = ctx.database.db;
|
|
47
|
+
const services = db ? createServices(db, options, ctx.services) : null;
|
|
48
|
+
if (!services)
|
|
49
|
+
return [];
|
|
50
|
+
return [
|
|
51
|
+
...buildServiceRoutes(services),
|
|
52
|
+
...buildProviderRoutes(services),
|
|
53
|
+
...buildAvailabilityRoutes(services),
|
|
54
|
+
...buildBookingRoutes(services),
|
|
55
|
+
...buildMyBookingRoutes(services),
|
|
56
|
+
];
|
|
57
|
+
},
|
|
58
|
+
analyticsModels: () => APPOINTMENT_ANALYTICS_MODELS,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
2
|
+
import type { ProviderService } from "../services/provider-service.js";
|
|
3
|
+
import type { SlotService } from "../services/slot-service.js";
|
|
4
|
+
export declare function buildAvailabilityRoutes(services: {
|
|
5
|
+
provider: ProviderService;
|
|
6
|
+
slots: SlotService;
|
|
7
|
+
}): PluginRouteRegistration[];
|
|
8
|
+
//# sourceMappingURL=availability.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"availability.d.ts","sourceRoot":"","sources":["../../src/routes/availability.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AACvE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,6BAA6B,CAAC;AAyB/D,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE;IAChD,QAAQ,EAAE,eAAe,CAAC;IAC1B,KAAK,EAAE,WAAW,CAAC;CACpB,GAAG,uBAAuB,EAAE,CAwH5B"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { router } from "@porulle/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
const WeeklyScheduleSchema = z.object({
|
|
4
|
+
schedules: z.array(z.object({
|
|
5
|
+
dayOfWeek: z.number().int().min(0).max(6),
|
|
6
|
+
startTime: z.string().regex(/^\d{2}:\d{2}$/).openapi({ example: "09:00" }),
|
|
7
|
+
endTime: z.string().regex(/^\d{2}:\d{2}$/).openapi({ example: "17:00" }),
|
|
8
|
+
})),
|
|
9
|
+
}).openapi("SetWeeklyAvailabilityRequest");
|
|
10
|
+
const AddBreakSchema = z.object({
|
|
11
|
+
dayOfWeek: z.number().int().min(0).max(6).optional(),
|
|
12
|
+
startTime: z.string().regex(/^\d{2}:\d{2}$/).openapi({ example: "12:00" }),
|
|
13
|
+
endTime: z.string().regex(/^\d{2}:\d{2}$/).openapi({ example: "13:00" }),
|
|
14
|
+
label: z.string().optional().openapi({ example: "Lunch" }),
|
|
15
|
+
}).openapi("AddBreakRequest");
|
|
16
|
+
const AddOverrideSchema = z.object({
|
|
17
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).openapi({ example: "2026-12-25" }),
|
|
18
|
+
isAvailable: z.boolean(),
|
|
19
|
+
startTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
|
|
20
|
+
endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
|
|
21
|
+
reason: z.string().optional().openapi({ example: "Christmas Day" }),
|
|
22
|
+
}).openapi("AddOverrideRequest");
|
|
23
|
+
export function buildAvailabilityRoutes(services) {
|
|
24
|
+
const r = router("Appointments - Availability", "/appointments/availability");
|
|
25
|
+
// ─── Weekly Schedule ────────────────────────────────────────────────────────
|
|
26
|
+
r.put("/{providerId}/weekly")
|
|
27
|
+
.summary("Set weekly availability")
|
|
28
|
+
.permission("appointments:manage")
|
|
29
|
+
.input(WeeklyScheduleSchema)
|
|
30
|
+
.handler(async ({ params, input }) => {
|
|
31
|
+
const body = input;
|
|
32
|
+
return services.provider.setWeeklyAvailability(params.providerId, body.schedules);
|
|
33
|
+
});
|
|
34
|
+
r.get("/{providerId}/weekly")
|
|
35
|
+
.summary("Get weekly availability")
|
|
36
|
+
.handler(async ({ params }) => {
|
|
37
|
+
return services.provider.getWeeklyAvailability(params.providerId);
|
|
38
|
+
});
|
|
39
|
+
// ─── Breaks ─────────────────────────────────────────────────────────────────
|
|
40
|
+
r.post("/{providerId}/breaks")
|
|
41
|
+
.summary("Add break")
|
|
42
|
+
.permission("appointments:manage")
|
|
43
|
+
.input(AddBreakSchema)
|
|
44
|
+
.handler(async ({ params, input }) => {
|
|
45
|
+
const body = input;
|
|
46
|
+
return services.provider.addBreak(params.providerId, body);
|
|
47
|
+
});
|
|
48
|
+
r.get("/{providerId}/breaks")
|
|
49
|
+
.summary("List breaks")
|
|
50
|
+
.handler(async ({ params }) => {
|
|
51
|
+
return services.provider.getBreaks(params.providerId);
|
|
52
|
+
});
|
|
53
|
+
r.delete("/{providerId}/breaks/{breakId}")
|
|
54
|
+
.summary("Delete break")
|
|
55
|
+
.permission("appointments:manage")
|
|
56
|
+
.handler(async ({ params }) => {
|
|
57
|
+
const deleted = await services.provider.deleteBreak(params.breakId);
|
|
58
|
+
if (!deleted)
|
|
59
|
+
throw new Error("Break not found");
|
|
60
|
+
return deleted;
|
|
61
|
+
});
|
|
62
|
+
// ─── Overrides ──────────────────────────────────────────────────────────────
|
|
63
|
+
r.post("/{providerId}/overrides")
|
|
64
|
+
.summary("Add date override")
|
|
65
|
+
.permission("appointments:manage")
|
|
66
|
+
.input(AddOverrideSchema)
|
|
67
|
+
.handler(async ({ params, input }) => {
|
|
68
|
+
const body = input;
|
|
69
|
+
return services.provider.addOverride(params.providerId, body);
|
|
70
|
+
});
|
|
71
|
+
r.get("/{providerId}/overrides")
|
|
72
|
+
.summary("List overrides")
|
|
73
|
+
.handler(async ({ params }) => {
|
|
74
|
+
return services.provider.getOverrides(params.providerId);
|
|
75
|
+
});
|
|
76
|
+
r.delete("/{providerId}/overrides/{overrideId}")
|
|
77
|
+
.summary("Delete override")
|
|
78
|
+
.permission("appointments:manage")
|
|
79
|
+
.handler(async ({ params }) => {
|
|
80
|
+
const deleted = await services.provider.deleteOverride(params.overrideId);
|
|
81
|
+
if (!deleted)
|
|
82
|
+
throw new Error("Override not found");
|
|
83
|
+
return deleted;
|
|
84
|
+
});
|
|
85
|
+
// ─── Available Slots ────────────────────────────────────────────────────────
|
|
86
|
+
r.get("/{providerId}/slots")
|
|
87
|
+
.summary("Get available slots for a date")
|
|
88
|
+
.query(z.object({
|
|
89
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).openapi({ example: "2026-03-20" }),
|
|
90
|
+
serviceTypeId: z.string().uuid(),
|
|
91
|
+
}))
|
|
92
|
+
.handler(async ({ params, query }) => {
|
|
93
|
+
const providerId = params.providerId;
|
|
94
|
+
const dateStr = query.date;
|
|
95
|
+
const serviceTypeId = query.serviceTypeId;
|
|
96
|
+
const provider = await services.provider.getProvider(providerId);
|
|
97
|
+
if (!provider)
|
|
98
|
+
throw new Error("Provider not found");
|
|
99
|
+
const serviceType = await services.provider.getServiceType(serviceTypeId);
|
|
100
|
+
if (!serviceType)
|
|
101
|
+
throw new Error("Service type not found");
|
|
102
|
+
// Check for provider-level custom duration/price
|
|
103
|
+
const providerSvcs = await services.provider.getProviderServices(providerId);
|
|
104
|
+
const link = providerSvcs.find((ps) => ps.serviceTypeId === serviceTypeId);
|
|
105
|
+
const durationMinutes = link?.customDurationMinutes ?? serviceType.durationMinutes;
|
|
106
|
+
const bufferBefore = serviceType.bufferBeforeMinutes;
|
|
107
|
+
const bufferAfter = serviceType.bufferAfterMinutes;
|
|
108
|
+
// Parse date (in provider's local context)
|
|
109
|
+
const [year, month, day] = dateStr.split("-").map(Number);
|
|
110
|
+
const date = new Date(year, month - 1, day);
|
|
111
|
+
const slots = await services.slots.getAvailableSlots(providerId, serviceTypeId, date, durationMinutes, bufferBefore, bufferAfter, provider.timezone);
|
|
112
|
+
return slots.map((s) => ({
|
|
113
|
+
start: s.start.toISOString(),
|
|
114
|
+
end: s.end.toISOString(),
|
|
115
|
+
}));
|
|
116
|
+
});
|
|
117
|
+
return r.routes();
|
|
118
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
2
|
+
import type { BookingService } from "../services/booking-service.js";
|
|
3
|
+
import type { ProviderService } from "../services/provider-service.js";
|
|
4
|
+
export declare function buildBookingRoutes(services: {
|
|
5
|
+
booking: BookingService;
|
|
6
|
+
provider: ProviderService;
|
|
7
|
+
}): PluginRouteRegistration[];
|
|
8
|
+
//# sourceMappingURL=bookings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bookings.d.ts","sourceRoot":"","sources":["../../src/routes/bookings.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAuBvE,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE;IAC3C,OAAO,EAAE,cAAc,CAAC;IACxB,QAAQ,EAAE,eAAe,CAAC;CAC3B,GAAG,uBAAuB,EAAE,CA8G5B"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { router } from "@porulle/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
const CreateBookingSchema = z.object({
|
|
4
|
+
providerId: z.string().uuid(),
|
|
5
|
+
serviceTypeId: z.string().uuid(),
|
|
6
|
+
startTime: z.string().datetime().openapi({ example: "2026-03-20T09:00:00Z" }),
|
|
7
|
+
customerName: z.string().min(1).openapi({ example: "John Doe" }),
|
|
8
|
+
customerEmail: z.string().email().openapi({ example: "john@example.com" }),
|
|
9
|
+
customerPhone: z.string().optional(),
|
|
10
|
+
paymentMethod: z.enum(["card", "cash", "invoice"]).openapi({ example: "cash" }),
|
|
11
|
+
notes: z.string().optional(),
|
|
12
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
13
|
+
}).openapi("CreateBookingRequest");
|
|
14
|
+
const RescheduleBookingSchema = z.object({
|
|
15
|
+
newStartTime: z.string().datetime().openapi({ example: "2026-03-21T10:00:00Z" }),
|
|
16
|
+
}).openapi("RescheduleBookingRequest");
|
|
17
|
+
const CancelBookingSchema = z.object({
|
|
18
|
+
reason: z.string().optional(),
|
|
19
|
+
}).openapi("CancelBookingRequest");
|
|
20
|
+
export function buildBookingRoutes(services) {
|
|
21
|
+
const r = router("Appointments - Bookings", "/appointments/bookings");
|
|
22
|
+
r.get("/")
|
|
23
|
+
.summary("List bookings")
|
|
24
|
+
.permission("appointments:manage")
|
|
25
|
+
.query(z.object({
|
|
26
|
+
providerId: z.string().uuid().optional(),
|
|
27
|
+
from: z.string().datetime().optional(),
|
|
28
|
+
to: z.string().datetime().optional(),
|
|
29
|
+
}))
|
|
30
|
+
.handler(async ({ query }) => {
|
|
31
|
+
const providerId = query.providerId;
|
|
32
|
+
if (!providerId)
|
|
33
|
+
throw new Error("providerId is required");
|
|
34
|
+
return services.booking.listByProvider(providerId, query.from ? new Date(query.from) : undefined, query.to ? new Date(query.to) : undefined);
|
|
35
|
+
});
|
|
36
|
+
r.post("/")
|
|
37
|
+
.summary("Create booking")
|
|
38
|
+
.auth()
|
|
39
|
+
.input(CreateBookingSchema)
|
|
40
|
+
.handler(async ({ input, actor }) => {
|
|
41
|
+
const body = input;
|
|
42
|
+
const result = await services.booking.create({
|
|
43
|
+
providerId: body.providerId,
|
|
44
|
+
serviceTypeId: body.serviceTypeId,
|
|
45
|
+
customerId: actor.userId,
|
|
46
|
+
customerName: body.customerName,
|
|
47
|
+
customerEmail: body.customerEmail,
|
|
48
|
+
customerPhone: body.customerPhone,
|
|
49
|
+
startTime: new Date(body.startTime),
|
|
50
|
+
paymentMethod: body.paymentMethod,
|
|
51
|
+
notes: body.notes,
|
|
52
|
+
metadata: body.metadata,
|
|
53
|
+
});
|
|
54
|
+
if (!result.ok) {
|
|
55
|
+
const status = result.code === "CONFLICT" ? 409 : result.code === "NOT_FOUND" ? 404 : 400;
|
|
56
|
+
throw Object.assign(new Error(result.error), { statusCode: status });
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
booking: result.booking,
|
|
60
|
+
...(result.paymentIntent ? { paymentIntent: result.paymentIntent } : {}),
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
r.get("/{id}")
|
|
64
|
+
.summary("Get booking")
|
|
65
|
+
.permission("appointments:manage")
|
|
66
|
+
.handler(async ({ params }) => {
|
|
67
|
+
const booking = await services.booking.getById(params.id);
|
|
68
|
+
if (!booking)
|
|
69
|
+
throw new Error("Booking not found");
|
|
70
|
+
return booking;
|
|
71
|
+
});
|
|
72
|
+
r.post("/{id}/confirm")
|
|
73
|
+
.summary("Confirm booking")
|
|
74
|
+
.permission("appointments:manage")
|
|
75
|
+
.handler(async ({ params }) => {
|
|
76
|
+
const result = await services.booking.changeStatus(params.id, "confirmed");
|
|
77
|
+
if (!result.ok)
|
|
78
|
+
throw new Error(result.error);
|
|
79
|
+
return result.booking;
|
|
80
|
+
});
|
|
81
|
+
r.post("/{id}/complete")
|
|
82
|
+
.summary("Complete appointment")
|
|
83
|
+
.permission("appointments:manage")
|
|
84
|
+
.handler(async ({ params }) => {
|
|
85
|
+
const result = await services.booking.complete(params.id);
|
|
86
|
+
if (!result.ok)
|
|
87
|
+
throw new Error(result.error);
|
|
88
|
+
return result.booking;
|
|
89
|
+
});
|
|
90
|
+
r.post("/{id}/no-show")
|
|
91
|
+
.summary("Mark as no-show")
|
|
92
|
+
.permission("appointments:manage")
|
|
93
|
+
.handler(async ({ params }) => {
|
|
94
|
+
const result = await services.booking.markNoShow(params.id);
|
|
95
|
+
if (!result.ok)
|
|
96
|
+
throw new Error(result.error);
|
|
97
|
+
return result.booking;
|
|
98
|
+
});
|
|
99
|
+
r.post("/{id}/cancel")
|
|
100
|
+
.summary("Cancel booking")
|
|
101
|
+
.auth()
|
|
102
|
+
.input(CancelBookingSchema)
|
|
103
|
+
.handler(async ({ params, input }) => {
|
|
104
|
+
const body = input;
|
|
105
|
+
const result = await services.booking.cancel(params.id, body.reason);
|
|
106
|
+
if (!result.ok)
|
|
107
|
+
throw new Error(result.error);
|
|
108
|
+
return { booking: result.booking, refunded: result.refunded };
|
|
109
|
+
});
|
|
110
|
+
r.post("/{id}/reschedule")
|
|
111
|
+
.summary("Reschedule booking")
|
|
112
|
+
.auth()
|
|
113
|
+
.input(RescheduleBookingSchema)
|
|
114
|
+
.handler(async ({ params, input }) => {
|
|
115
|
+
const body = input;
|
|
116
|
+
const result = await services.booking.reschedule(params.id, new Date(body.newStartTime));
|
|
117
|
+
if (!result.ok)
|
|
118
|
+
throw new Error(result.error);
|
|
119
|
+
return { oldBooking: result.oldBooking, newBooking: result.newBooking };
|
|
120
|
+
});
|
|
121
|
+
return r.routes();
|
|
122
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
2
|
+
import type { BookingService } from "../services/booking-service.js";
|
|
3
|
+
export declare function buildMyBookingRoutes(services: {
|
|
4
|
+
booking: BookingService;
|
|
5
|
+
}): PluginRouteRegistration[];
|
|
6
|
+
//# sourceMappingURL=my-bookings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"my-bookings.d.ts","sourceRoot":"","sources":["../../src/routes/my-bookings.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAC7D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gCAAgC,CAAC;AAErE,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE;IAC7C,OAAO,EAAE,cAAc,CAAC;CACzB,GAAG,uBAAuB,EAAE,CAqB5B"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { router } from "@porulle/core";
|
|
2
|
+
export function buildMyBookingRoutes(services) {
|
|
3
|
+
const r = router("Appointments - My Bookings", "/appointments/my-bookings");
|
|
4
|
+
r.get("/")
|
|
5
|
+
.summary("List my bookings")
|
|
6
|
+
.auth()
|
|
7
|
+
.handler(async ({ actor }) => {
|
|
8
|
+
return services.booking.listByCustomer(actor.userId);
|
|
9
|
+
});
|
|
10
|
+
r.get("/{id}")
|
|
11
|
+
.summary("Get my booking")
|
|
12
|
+
.auth()
|
|
13
|
+
.handler(async ({ params, actor }) => {
|
|
14
|
+
const booking = await services.booking.getById(params.id);
|
|
15
|
+
if (!booking)
|
|
16
|
+
throw new Error("Booking not found");
|
|
17
|
+
if (booking.customerId !== actor.userId)
|
|
18
|
+
throw new Error("Booking not found");
|
|
19
|
+
return booking;
|
|
20
|
+
});
|
|
21
|
+
return r.routes();
|
|
22
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
2
|
+
import type { ProviderService } from "../services/provider-service.js";
|
|
3
|
+
export declare function buildProviderRoutes(services: {
|
|
4
|
+
provider: ProviderService;
|
|
5
|
+
}): PluginRouteRegistration[];
|
|
6
|
+
//# sourceMappingURL=providers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"providers.d.ts","sourceRoot":"","sources":["../../src/routes/providers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAoBvE,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE;IAC5C,QAAQ,EAAE,eAAe,CAAC;CAC3B,GAAG,uBAAuB,EAAE,CAuE5B"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { router, resolveOrgId } from "@porulle/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
import { stripUndefined } from "./util.js";
|
|
4
|
+
const CreateProviderSchema = z.object({
|
|
5
|
+
name: z.string().min(1).openapi({ example: "Jane Smith" }),
|
|
6
|
+
email: z.string().email().optional(),
|
|
7
|
+
phone: z.string().optional(),
|
|
8
|
+
timezone: z.string().optional().openapi({ example: "Asia/Colombo" }),
|
|
9
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
10
|
+
}).openapi("CreateProviderRequest");
|
|
11
|
+
const UpdateProviderSchema = z.object({
|
|
12
|
+
name: z.string().optional(),
|
|
13
|
+
email: z.string().email().optional(),
|
|
14
|
+
phone: z.string().optional(),
|
|
15
|
+
timezone: z.string().optional(),
|
|
16
|
+
isActive: z.boolean().optional(),
|
|
17
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
18
|
+
}).openapi("UpdateProviderRequest");
|
|
19
|
+
export function buildProviderRoutes(services) {
|
|
20
|
+
const r = router("Appointments - Providers", "/appointments/providers");
|
|
21
|
+
r.get("/")
|
|
22
|
+
.summary("List providers")
|
|
23
|
+
.handler(async () => {
|
|
24
|
+
return services.provider.listProviders();
|
|
25
|
+
});
|
|
26
|
+
r.post("/")
|
|
27
|
+
.summary("Create provider")
|
|
28
|
+
.permission("appointments:admin")
|
|
29
|
+
.input(CreateProviderSchema)
|
|
30
|
+
.handler(async ({ input, actor }) => {
|
|
31
|
+
const body = input;
|
|
32
|
+
return services.provider.createProvider(resolveOrgId(actor), stripUndefined(body));
|
|
33
|
+
});
|
|
34
|
+
r.get("/{id}")
|
|
35
|
+
.summary("Get provider")
|
|
36
|
+
.handler(async ({ params }) => {
|
|
37
|
+
const provider = await services.provider.getProvider(params.id);
|
|
38
|
+
if (!provider)
|
|
39
|
+
throw new Error("Provider not found");
|
|
40
|
+
return provider;
|
|
41
|
+
});
|
|
42
|
+
r.patch("/{id}")
|
|
43
|
+
.summary("Update provider")
|
|
44
|
+
.permission("appointments:admin")
|
|
45
|
+
.input(UpdateProviderSchema)
|
|
46
|
+
.handler(async ({ params, input }) => {
|
|
47
|
+
const body = input;
|
|
48
|
+
const updated = await services.provider.updateProvider(params.id, stripUndefined(body));
|
|
49
|
+
if (!updated)
|
|
50
|
+
throw new Error("Provider not found");
|
|
51
|
+
return updated;
|
|
52
|
+
});
|
|
53
|
+
r.delete("/{id}")
|
|
54
|
+
.summary("Delete provider")
|
|
55
|
+
.permission("appointments:admin")
|
|
56
|
+
.handler(async ({ params }) => {
|
|
57
|
+
const deleted = await services.provider.deleteProvider(params.id);
|
|
58
|
+
if (!deleted)
|
|
59
|
+
throw new Error("Provider not found");
|
|
60
|
+
return deleted;
|
|
61
|
+
});
|
|
62
|
+
// ─── Provider ↔ Service Links ───────────────────────────────────────────────
|
|
63
|
+
r.post("/{id}/services")
|
|
64
|
+
.summary("Link service type to provider")
|
|
65
|
+
.permission("appointments:admin")
|
|
66
|
+
.input(z.object({
|
|
67
|
+
serviceTypeId: z.string().uuid().openapi({ example: "uuid" }),
|
|
68
|
+
customDurationMinutes: z.number().int().min(5).optional(),
|
|
69
|
+
customPriceCents: z.number().int().min(0).optional(),
|
|
70
|
+
}).openapi("LinkProviderServiceRequest"))
|
|
71
|
+
.handler(async ({ params, input }) => {
|
|
72
|
+
const body = input;
|
|
73
|
+
return services.provider.linkServiceType(params.id, body.serviceTypeId, stripUndefined({
|
|
74
|
+
customDurationMinutes: body.customDurationMinutes,
|
|
75
|
+
customPriceCents: body.customPriceCents,
|
|
76
|
+
}));
|
|
77
|
+
});
|
|
78
|
+
r.get("/{id}/services")
|
|
79
|
+
.summary("List provider's service types")
|
|
80
|
+
.handler(async ({ params }) => {
|
|
81
|
+
return services.provider.getProviderServices(params.id);
|
|
82
|
+
});
|
|
83
|
+
return r.routes();
|
|
84
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
2
|
+
import type { ProviderService } from "../services/provider-service.js";
|
|
3
|
+
export declare function buildServiceRoutes(services: {
|
|
4
|
+
provider: ProviderService;
|
|
5
|
+
}): PluginRouteRegistration[];
|
|
6
|
+
//# sourceMappingURL=services.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../../src/routes/services.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,eAAe,CAAC;AAE7D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iCAAiC,CAAC;AAyBvE,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE;IAC3C,QAAQ,EAAE,eAAe,CAAC;CAC3B,GAAG,uBAAuB,EAAE,CA+C5B"}
|