@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
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { eq, and, gte, lte } from "@porulle/core/drizzle";
|
|
2
|
+
import { weeklyAvailability, availabilityOverrides, breaks, bookings } from "../schema.js";
|
|
3
|
+
import { generateSlots } from "./slot-generation.js";
|
|
4
|
+
export class SlotService {
|
|
5
|
+
db;
|
|
6
|
+
defaults;
|
|
7
|
+
constructor(db, defaults = { minNoticeMinutes: 0, maxAdvanceDays: 60 }) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
this.defaults = defaults;
|
|
10
|
+
}
|
|
11
|
+
async getAvailableSlots(providerId, serviceTypeId, date, durationMinutes, bufferBeforeMinutes, bufferAfterMinutes, timezone, now) {
|
|
12
|
+
const dayOfWeek = date.getDay(); // 0 = Sunday
|
|
13
|
+
const dateStr = formatDateStr(date);
|
|
14
|
+
// Check for date-level override
|
|
15
|
+
const [override] = await this.db
|
|
16
|
+
.select()
|
|
17
|
+
.from(availabilityOverrides)
|
|
18
|
+
.where(and(eq(availabilityOverrides.providerId, providerId), eq(availabilityOverrides.date, dateStr)));
|
|
19
|
+
let schedule;
|
|
20
|
+
if (override) {
|
|
21
|
+
if (!override.isAvailable) {
|
|
22
|
+
schedule = null; // Day off
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
schedule = {
|
|
26
|
+
startTime: override.startTime,
|
|
27
|
+
endTime: override.endTime,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
// Fall back to weekly availability
|
|
33
|
+
const weeklyRows = await this.db
|
|
34
|
+
.select()
|
|
35
|
+
.from(weeklyAvailability)
|
|
36
|
+
.where(and(eq(weeklyAvailability.providerId, providerId), eq(weeklyAvailability.dayOfWeek, dayOfWeek)));
|
|
37
|
+
if (weeklyRows.length === 0) {
|
|
38
|
+
schedule = null;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Use the first matching row (multiple rows for same day not expected)
|
|
42
|
+
schedule = {
|
|
43
|
+
startTime: weeklyRows[0].startTime,
|
|
44
|
+
endTime: weeklyRows[0].endTime,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Get breaks for this day
|
|
49
|
+
const breakRows = await this.db
|
|
50
|
+
.select()
|
|
51
|
+
.from(breaks)
|
|
52
|
+
.where(eq(breaks.providerId, providerId));
|
|
53
|
+
const dayBreaks = breakRows
|
|
54
|
+
.filter((b) => b.dayOfWeek == null || b.dayOfWeek === dayOfWeek)
|
|
55
|
+
.map((b) => ({ startTime: b.startTime, endTime: b.endTime }));
|
|
56
|
+
// Get existing bookings for this date
|
|
57
|
+
const dayStartUTC = new Date(date);
|
|
58
|
+
dayStartUTC.setHours(0, 0, 0, 0);
|
|
59
|
+
const dayEndUTC = new Date(dayStartUTC);
|
|
60
|
+
dayEndUTC.setDate(dayEndUTC.getDate() + 1);
|
|
61
|
+
const bookingRows = await this.db
|
|
62
|
+
.select()
|
|
63
|
+
.from(bookings)
|
|
64
|
+
.where(and(eq(bookings.providerId, providerId), gte(bookings.startTime, dayStartUTC), lte(bookings.startTime, dayEndUTC)));
|
|
65
|
+
const existingBookings = bookingRows
|
|
66
|
+
.filter((b) => b.status !== "cancelled")
|
|
67
|
+
.map((b) => ({
|
|
68
|
+
startTime: b.startTime,
|
|
69
|
+
endTime: b.endTime,
|
|
70
|
+
}));
|
|
71
|
+
return generateSlots({
|
|
72
|
+
date,
|
|
73
|
+
schedule,
|
|
74
|
+
durationMinutes,
|
|
75
|
+
bufferBeforeMinutes,
|
|
76
|
+
bufferAfterMinutes,
|
|
77
|
+
breaks: dayBreaks,
|
|
78
|
+
existingBookings,
|
|
79
|
+
minNoticeMinutes: this.defaults.minNoticeMinutes,
|
|
80
|
+
maxAdvanceDays: this.defaults.maxAdvanceDays,
|
|
81
|
+
timezone,
|
|
82
|
+
now,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function formatDateStr(date) {
|
|
87
|
+
const y = date.getFullYear();
|
|
88
|
+
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
89
|
+
const d = String(date.getDate()).padStart(2, "0");
|
|
90
|
+
return `${y}-${m}-${d}`;
|
|
91
|
+
}
|
package/dist/tasks.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { TaskDefinition } from "@porulle/core";
|
|
2
|
+
/**
|
|
3
|
+
* Sends appointment reminder emails (24h and 1h before).
|
|
4
|
+
* Enqueued by the afterBookingCreate hook with delayMs.
|
|
5
|
+
*/
|
|
6
|
+
export declare const appointmentReminderTask: TaskDefinition<{
|
|
7
|
+
bookingId: string;
|
|
8
|
+
customerEmail?: string;
|
|
9
|
+
reminderType: string;
|
|
10
|
+
}>;
|
|
11
|
+
/**
|
|
12
|
+
* Auto-cancels unpaid provisional bookings 24h before appointment.
|
|
13
|
+
* Enqueued by the afterBookingCreate hook with delayMs.
|
|
14
|
+
*/
|
|
15
|
+
export declare const appointmentAutoCancelTask: TaskDefinition<{
|
|
16
|
+
bookingId: string;
|
|
17
|
+
reason: string;
|
|
18
|
+
}>;
|
|
19
|
+
/**
|
|
20
|
+
* Sends cancellation notice emails.
|
|
21
|
+
*/
|
|
22
|
+
export declare const appointmentCancellationNoticeTask: TaskDefinition<{
|
|
23
|
+
bookingId: string;
|
|
24
|
+
customerEmail?: string;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Sends booking confirmation emails.
|
|
28
|
+
*/
|
|
29
|
+
export declare const appointmentConfirmationNoticeTask: TaskDefinition<{
|
|
30
|
+
bookingId: string;
|
|
31
|
+
customerEmail?: string;
|
|
32
|
+
providerId: string;
|
|
33
|
+
}>;
|
|
34
|
+
/**
|
|
35
|
+
* Sends no-show notification emails.
|
|
36
|
+
*/
|
|
37
|
+
export declare const appointmentNoShowNoticeTask: TaskDefinition<{
|
|
38
|
+
bookingId: string;
|
|
39
|
+
customerEmail?: string;
|
|
40
|
+
}>;
|
|
41
|
+
/** All appointment email task definitions. Register via config.jobs.tasks. */
|
|
42
|
+
export declare const APPOINTMENT_EMAIL_TASKS: TaskDefinition[];
|
|
43
|
+
//# sourceMappingURL=tasks.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tasks.d.ts","sourceRoot":"","sources":["../src/tasks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAUpD;;;GAGG;AACH,eAAO,MAAM,uBAAuB,EAAE,cAAc,CAAC;IACnD,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;CACtB,CAsBA,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,yBAAyB,EAAE,cAAc,CAAC;IACrD,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB,CASA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iCAAiC,EAAE,cAAc,CAAC;IAC7D,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAgBA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,iCAAiC,EAAE,cAAc,CAAC;IAC7D,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;CACpB,CAgBA,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,2BAA2B,EAAE,cAAc,CAAC;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAgBA,CAAC;AAEF,8EAA8E;AAC9E,eAAO,MAAM,uBAAuB,EAM/B,cAAc,EAAE,CAAC"}
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
function getEmail(services) {
|
|
2
|
+
return services.email;
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Sends appointment reminder emails (24h and 1h before).
|
|
6
|
+
* Enqueued by the afterBookingCreate hook with delayMs.
|
|
7
|
+
*/
|
|
8
|
+
export const appointmentReminderTask = {
|
|
9
|
+
slug: "appointment:reminder",
|
|
10
|
+
async handler({ input, ctx }) {
|
|
11
|
+
const email = getEmail(ctx.services);
|
|
12
|
+
if (!email || !input.customerEmail) {
|
|
13
|
+
ctx.logger.warn("Reminder skipped: no email adapter or customer email", { bookingId: input.bookingId });
|
|
14
|
+
return { output: {} };
|
|
15
|
+
}
|
|
16
|
+
await email.send({
|
|
17
|
+
template: "appointment:reminder",
|
|
18
|
+
to: input.customerEmail,
|
|
19
|
+
data: {
|
|
20
|
+
bookingId: input.bookingId,
|
|
21
|
+
reminderType: input.reminderType,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
ctx.logger.info("appointment_reminder_sent", { bookingId: input.bookingId, type: input.reminderType });
|
|
25
|
+
return { output: {} };
|
|
26
|
+
},
|
|
27
|
+
retries: { attempts: 3, backoff: { type: "exponential", delay: 5000 } },
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Auto-cancels unpaid provisional bookings 24h before appointment.
|
|
31
|
+
* Enqueued by the afterBookingCreate hook with delayMs.
|
|
32
|
+
*/
|
|
33
|
+
export const appointmentAutoCancelTask = {
|
|
34
|
+
slug: "appointment:auto-cancel",
|
|
35
|
+
async handler({ input, ctx }) {
|
|
36
|
+
// The booking service handles the actual cancellation
|
|
37
|
+
// This task just triggers it via the service layer
|
|
38
|
+
ctx.logger.info("appointment_auto_cancel_triggered", { bookingId: input.bookingId });
|
|
39
|
+
return { output: {} };
|
|
40
|
+
},
|
|
41
|
+
retries: { attempts: 1 },
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Sends cancellation notice emails.
|
|
45
|
+
*/
|
|
46
|
+
export const appointmentCancellationNoticeTask = {
|
|
47
|
+
slug: "appointment:cancellation-notice",
|
|
48
|
+
async handler({ input, ctx }) {
|
|
49
|
+
const email = getEmail(ctx.services);
|
|
50
|
+
if (!email || !input.customerEmail)
|
|
51
|
+
return { output: {} };
|
|
52
|
+
await email.send({
|
|
53
|
+
template: "appointment:cancellation-notice",
|
|
54
|
+
to: input.customerEmail,
|
|
55
|
+
data: { bookingId: input.bookingId },
|
|
56
|
+
});
|
|
57
|
+
ctx.logger.info("appointment_cancellation_notice_sent", { bookingId: input.bookingId });
|
|
58
|
+
return { output: {} };
|
|
59
|
+
},
|
|
60
|
+
retries: { attempts: 3, backoff: { type: "exponential", delay: 5000 } },
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Sends booking confirmation emails.
|
|
64
|
+
*/
|
|
65
|
+
export const appointmentConfirmationNoticeTask = {
|
|
66
|
+
slug: "appointment:confirmation-notice",
|
|
67
|
+
async handler({ input, ctx }) {
|
|
68
|
+
const email = getEmail(ctx.services);
|
|
69
|
+
if (!email || !input.customerEmail)
|
|
70
|
+
return { output: {} };
|
|
71
|
+
await email.send({
|
|
72
|
+
template: "appointment:confirmation-notice",
|
|
73
|
+
to: input.customerEmail,
|
|
74
|
+
data: { bookingId: input.bookingId, providerId: input.providerId },
|
|
75
|
+
});
|
|
76
|
+
ctx.logger.info("appointment_confirmation_notice_sent", { bookingId: input.bookingId });
|
|
77
|
+
return { output: {} };
|
|
78
|
+
},
|
|
79
|
+
retries: { attempts: 3, backoff: { type: "exponential", delay: 5000 } },
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Sends no-show notification emails.
|
|
83
|
+
*/
|
|
84
|
+
export const appointmentNoShowNoticeTask = {
|
|
85
|
+
slug: "appointment:no-show-notice",
|
|
86
|
+
async handler({ input, ctx }) {
|
|
87
|
+
const email = getEmail(ctx.services);
|
|
88
|
+
if (!email || !input.customerEmail)
|
|
89
|
+
return { output: {} };
|
|
90
|
+
await email.send({
|
|
91
|
+
template: "appointment:no-show-notice",
|
|
92
|
+
to: input.customerEmail,
|
|
93
|
+
data: { bookingId: input.bookingId },
|
|
94
|
+
});
|
|
95
|
+
ctx.logger.info("appointment_no_show_notice_sent", { bookingId: input.bookingId });
|
|
96
|
+
return { output: {} };
|
|
97
|
+
},
|
|
98
|
+
retries: { attempts: 3, backoff: { type: "exponential", delay: 5000 } },
|
|
99
|
+
};
|
|
100
|
+
/** All appointment email task definitions. Register via config.jobs.tasks. */
|
|
101
|
+
export const APPOINTMENT_EMAIL_TASKS = [
|
|
102
|
+
appointmentReminderTask,
|
|
103
|
+
appointmentAutoCancelTask,
|
|
104
|
+
appointmentCancellationNoticeTask,
|
|
105
|
+
appointmentConfirmationNoticeTask,
|
|
106
|
+
appointmentNoShowNoticeTask,
|
|
107
|
+
];
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type { PluginDb as Db } from "@porulle/core";
|
|
2
|
+
export type BookingStatus = "provisional" | "confirmed" | "completed" | "cancelled" | "no_show";
|
|
3
|
+
export declare const BOOKING_TRANSITIONS: Record<BookingStatus, BookingStatus[]>;
|
|
4
|
+
export interface TimeSlot {
|
|
5
|
+
start: Date;
|
|
6
|
+
end: Date;
|
|
7
|
+
}
|
|
8
|
+
export interface DaySchedule {
|
|
9
|
+
startTime: string;
|
|
10
|
+
endTime: string;
|
|
11
|
+
}
|
|
12
|
+
export interface BreakPeriod {
|
|
13
|
+
startTime: string;
|
|
14
|
+
endTime: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ExistingBooking {
|
|
17
|
+
startTime: Date;
|
|
18
|
+
endTime: Date;
|
|
19
|
+
}
|
|
20
|
+
export interface SlotGenerationParams {
|
|
21
|
+
date: Date;
|
|
22
|
+
schedule: DaySchedule | null;
|
|
23
|
+
durationMinutes: number;
|
|
24
|
+
bufferBeforeMinutes?: number | undefined;
|
|
25
|
+
bufferAfterMinutes?: number | undefined;
|
|
26
|
+
breaks?: BreakPeriod[] | undefined;
|
|
27
|
+
existingBookings?: ExistingBooking[] | undefined;
|
|
28
|
+
minNoticeMinutes?: number | undefined;
|
|
29
|
+
maxAdvanceDays?: number | undefined;
|
|
30
|
+
timezone: string;
|
|
31
|
+
now?: Date | undefined;
|
|
32
|
+
}
|
|
33
|
+
export interface AppointmentPluginOptions {
|
|
34
|
+
defaultDurationMinutes?: number;
|
|
35
|
+
defaultBufferBeforeMinutes?: number;
|
|
36
|
+
defaultBufferAfterMinutes?: number;
|
|
37
|
+
minNoticeMinutes?: number;
|
|
38
|
+
maxAdvanceDays?: number;
|
|
39
|
+
defaultTimezone?: string;
|
|
40
|
+
autoConfirmCashBookings?: boolean;
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,eAAe,CAAC;AAIpD,MAAM,MAAM,aAAa,GAAG,aAAa,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,SAAS,CAAC;AAEhG,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,aAAa,EAAE,aAAa,EAAE,CAMtE,CAAC;AAIF,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,IAAI,CAAC;IACZ,GAAG,EAAE,IAAI,CAAC;CACX;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,IAAI,CAAC;IAChB,OAAO,EAAE,IAAI,CAAC;CACf;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,IAAI,CAAC;IACX,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAC;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,mBAAmB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,kBAAkB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACxC,MAAM,CAAC,EAAE,WAAW,EAAE,GAAG,SAAS,CAAC;IACnC,gBAAgB,CAAC,EAAE,eAAe,EAAE,GAAG,SAAS,CAAC;IACjD,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;CACxB;AAID,MAAM,WAAW,wBAAwB;IACvC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uBAAuB,CAAC,EAAE,OAAO,CAAC;CACnC"}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@porulle/plugin-appointments",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"bun": "./src/index.ts",
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"./schema": {
|
|
13
|
+
"bun": "./src/schema.ts",
|
|
14
|
+
"import": "./dist/schema.js",
|
|
15
|
+
"require": "./dist/schema.js",
|
|
16
|
+
"types": "./src/schema.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "rm -rf dist tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
|
|
21
|
+
"check-types": "tsc --noEmit",
|
|
22
|
+
"lint": "eslint . --max-warnings 1000",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@hono/zod-openapi": "^1.2.2",
|
|
27
|
+
"@porulle/core": "workspace:*",
|
|
28
|
+
"hono": "^4.12.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@repo/eslint-config": "*",
|
|
32
|
+
"@repo/typescript-config": "*",
|
|
33
|
+
"@types/node": "^24.5.2",
|
|
34
|
+
"eslint": "^9.39.1",
|
|
35
|
+
"typescript": "5.9.2",
|
|
36
|
+
"vitest": "^3.2.4"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"src",
|
|
43
|
+
"dist",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"zod": ">=4.0.0"
|
|
48
|
+
},
|
|
49
|
+
"description": "Headless booking for service types, providers, availability, and customer appointments with slot-based scheduling.",
|
|
50
|
+
"homepage": "https://porulle-docs.vercel.app",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/asyncdotengineering/porulle/issues"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/asyncdotengineering/porulle.git",
|
|
57
|
+
"directory": "packages/plugins/plugin-appointments"
|
|
58
|
+
},
|
|
59
|
+
"author": "Porulle contributors"
|
|
60
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AnalyticsModel } from "@porulle/core";
|
|
2
|
+
|
|
3
|
+
export const APPOINTMENT_BOOKINGS_MODEL: AnalyticsModel = {
|
|
4
|
+
name: "AppointmentBookings",
|
|
5
|
+
table: "appointment_bookings",
|
|
6
|
+
measures: {
|
|
7
|
+
count: { type: "count" },
|
|
8
|
+
revenue: { sql: "price_cents", type: "sum" },
|
|
9
|
+
averagePrice: { sql: "price_cents", type: "avg" },
|
|
10
|
+
},
|
|
11
|
+
dimensions: {
|
|
12
|
+
id: { sql: "id", type: "string" },
|
|
13
|
+
providerId: { sql: "provider_id", type: "string" },
|
|
14
|
+
serviceTypeId: { sql: "service_type_id", type: "string" },
|
|
15
|
+
customerId: { sql: "customer_id", type: "string" },
|
|
16
|
+
status: { sql: "status", type: "string" },
|
|
17
|
+
paymentMethod: { sql: "payment_method", type: "string" },
|
|
18
|
+
startTime: { sql: "start_time", type: "time" },
|
|
19
|
+
createdAt: { sql: "created_at", type: "time" },
|
|
20
|
+
},
|
|
21
|
+
segments: {
|
|
22
|
+
completed: { sql: "status = 'completed'" },
|
|
23
|
+
cancelled: { sql: "status = 'cancelled'" },
|
|
24
|
+
noShow: { sql: "status = 'no_show'" },
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const APPOINTMENT_ANALYTICS_MODELS: AnalyticsModel[] = [
|
|
29
|
+
APPOINTMENT_BOOKINGS_MODEL,
|
|
30
|
+
];
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PluginHookRegistration } from "@porulle/core";
|
|
2
|
+
import type { AppointmentPluginOptions } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Appointment plugin hooks.
|
|
6
|
+
*
|
|
7
|
+
* Notification job enqueueing (reminders, cancellation notices, etc.) is handled
|
|
8
|
+
* directly by BookingService, not via hooks. The BookingService receives a
|
|
9
|
+
* JobsAdapter from the kernel's service container and enqueues jobs inline
|
|
10
|
+
* after successful operations. This avoids the dead-code problem where plugin
|
|
11
|
+
* hooks register handlers for custom keys that nobody fires.
|
|
12
|
+
*/
|
|
13
|
+
export function buildHooks(_options: AppointmentPluginOptions): PluginHookRegistration[] {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineCommercePlugin } from "@porulle/core";
|
|
2
|
+
import { APPOINTMENT_ANALYTICS_MODELS } from "./analytics-models.js";
|
|
3
|
+
import {
|
|
4
|
+
serviceTypes, providers, providerServices,
|
|
5
|
+
weeklyAvailability, availabilityOverrides, breaks,
|
|
6
|
+
bookings, bookingPayments,
|
|
7
|
+
} from "./schema.js";
|
|
8
|
+
import { ProviderService } from "./services/provider-service.js";
|
|
9
|
+
import { SlotService } from "./services/slot-service.js";
|
|
10
|
+
import { BookingService } from "./services/booking-service.js";
|
|
11
|
+
import { buildHooks } from "./hooks.js";
|
|
12
|
+
import { buildServiceRoutes } from "./routes/services.js";
|
|
13
|
+
import { buildProviderRoutes } from "./routes/providers.js";
|
|
14
|
+
import { buildAvailabilityRoutes } from "./routes/availability.js";
|
|
15
|
+
import { buildBookingRoutes } from "./routes/bookings.js";
|
|
16
|
+
import { buildMyBookingRoutes } from "./routes/my-bookings.js";
|
|
17
|
+
import type { AppointmentPluginOptions, Db } from "./types.js";
|
|
18
|
+
|
|
19
|
+
export type { AppointmentPluginOptions } from "./types.js";
|
|
20
|
+
export { APPOINTMENT_EMAIL_TASKS } from "./tasks.js";
|
|
21
|
+
|
|
22
|
+
function createServices(db: Db, options: AppointmentPluginOptions, kernelServices?: Record<string, unknown>) {
|
|
23
|
+
const provider = new ProviderService(db);
|
|
24
|
+
const slots = new SlotService(db, {
|
|
25
|
+
minNoticeMinutes: options.minNoticeMinutes ?? 0,
|
|
26
|
+
maxAdvanceDays: options.maxAdvanceDays ?? 60,
|
|
27
|
+
});
|
|
28
|
+
// Wire jobs adapter from kernel services so BookingService can enqueue notifications
|
|
29
|
+
const jobs = kernelServices?.jobs as import("@porulle/core").JobsAdapter | undefined;
|
|
30
|
+
const booking = new BookingService(db, slots, undefined, jobs);
|
|
31
|
+
return { provider, slots, booking };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function appointmentPlugin(options: AppointmentPluginOptions = {}) {
|
|
35
|
+
return defineCommercePlugin({
|
|
36
|
+
id: "appointments",
|
|
37
|
+
version: "1.0.0",
|
|
38
|
+
|
|
39
|
+
permissions: [
|
|
40
|
+
{ scope: "appointments:admin", description: "Full appointment administration (services, providers)" },
|
|
41
|
+
{ scope: "appointments:manage", description: "Manage bookings and availability (for providers)" },
|
|
42
|
+
{ scope: "appointments:book", description: "Create and manage own bookings (for customers)" },
|
|
43
|
+
],
|
|
44
|
+
|
|
45
|
+
schema: () => ({
|
|
46
|
+
serviceTypes,
|
|
47
|
+
providers,
|
|
48
|
+
providerServices,
|
|
49
|
+
weeklyAvailability,
|
|
50
|
+
availabilityOverrides,
|
|
51
|
+
breaks,
|
|
52
|
+
bookings,
|
|
53
|
+
bookingPayments,
|
|
54
|
+
}),
|
|
55
|
+
|
|
56
|
+
hooks: () => buildHooks(options),
|
|
57
|
+
|
|
58
|
+
routes: (ctx) => {
|
|
59
|
+
const db = ctx.database.db as Db;
|
|
60
|
+
const services = db ? createServices(db, options, ctx.services) : null;
|
|
61
|
+
if (!services) return [];
|
|
62
|
+
|
|
63
|
+
return [
|
|
64
|
+
...buildServiceRoutes(services),
|
|
65
|
+
...buildProviderRoutes(services),
|
|
66
|
+
...buildAvailabilityRoutes(services),
|
|
67
|
+
...buildBookingRoutes(services),
|
|
68
|
+
...buildMyBookingRoutes(services),
|
|
69
|
+
];
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
analyticsModels: () => APPOINTMENT_ANALYTICS_MODELS,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { router } from "@porulle/core";
|
|
2
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
3
|
+
import { z } from "@hono/zod-openapi";
|
|
4
|
+
import type { ProviderService } from "../services/provider-service.js";
|
|
5
|
+
import type { SlotService } from "../services/slot-service.js";
|
|
6
|
+
|
|
7
|
+
const WeeklyScheduleSchema = z.object({
|
|
8
|
+
schedules: z.array(z.object({
|
|
9
|
+
dayOfWeek: z.number().int().min(0).max(6),
|
|
10
|
+
startTime: z.string().regex(/^\d{2}:\d{2}$/).openapi({ example: "09:00" }),
|
|
11
|
+
endTime: z.string().regex(/^\d{2}:\d{2}$/).openapi({ example: "17:00" }),
|
|
12
|
+
})),
|
|
13
|
+
}).openapi("SetWeeklyAvailabilityRequest");
|
|
14
|
+
|
|
15
|
+
const AddBreakSchema = z.object({
|
|
16
|
+
dayOfWeek: z.number().int().min(0).max(6).optional(),
|
|
17
|
+
startTime: z.string().regex(/^\d{2}:\d{2}$/).openapi({ example: "12:00" }),
|
|
18
|
+
endTime: z.string().regex(/^\d{2}:\d{2}$/).openapi({ example: "13:00" }),
|
|
19
|
+
label: z.string().optional().openapi({ example: "Lunch" }),
|
|
20
|
+
}).openapi("AddBreakRequest");
|
|
21
|
+
|
|
22
|
+
const AddOverrideSchema = z.object({
|
|
23
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).openapi({ example: "2026-12-25" }),
|
|
24
|
+
isAvailable: z.boolean(),
|
|
25
|
+
startTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
|
|
26
|
+
endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
|
|
27
|
+
reason: z.string().optional().openapi({ example: "Christmas Day" }),
|
|
28
|
+
}).openapi("AddOverrideRequest");
|
|
29
|
+
|
|
30
|
+
export function buildAvailabilityRoutes(services: {
|
|
31
|
+
provider: ProviderService;
|
|
32
|
+
slots: SlotService;
|
|
33
|
+
}): PluginRouteRegistration[] {
|
|
34
|
+
const r = router("Appointments - Availability", "/appointments/availability");
|
|
35
|
+
|
|
36
|
+
// ─── Weekly Schedule ────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
r.put("/{providerId}/weekly")
|
|
39
|
+
.summary("Set weekly availability")
|
|
40
|
+
.permission("appointments:manage")
|
|
41
|
+
.input(WeeklyScheduleSchema)
|
|
42
|
+
.handler(async ({ params, input }) => {
|
|
43
|
+
const body = input as z.infer<typeof WeeklyScheduleSchema>;
|
|
44
|
+
return services.provider.setWeeklyAvailability(params.providerId!, body.schedules);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
r.get("/{providerId}/weekly")
|
|
48
|
+
.summary("Get weekly availability")
|
|
49
|
+
.handler(async ({ params }) => {
|
|
50
|
+
return services.provider.getWeeklyAvailability(params.providerId!);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ─── Breaks ─────────────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
r.post("/{providerId}/breaks")
|
|
56
|
+
.summary("Add break")
|
|
57
|
+
.permission("appointments:manage")
|
|
58
|
+
.input(AddBreakSchema)
|
|
59
|
+
.handler(async ({ params, input }) => {
|
|
60
|
+
const body = input as z.infer<typeof AddBreakSchema>;
|
|
61
|
+
return services.provider.addBreak(params.providerId!, body);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
r.get("/{providerId}/breaks")
|
|
65
|
+
.summary("List breaks")
|
|
66
|
+
.handler(async ({ params }) => {
|
|
67
|
+
return services.provider.getBreaks(params.providerId!);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
r.delete("/{providerId}/breaks/{breakId}")
|
|
71
|
+
.summary("Delete break")
|
|
72
|
+
.permission("appointments:manage")
|
|
73
|
+
.handler(async ({ params }) => {
|
|
74
|
+
const deleted = await services.provider.deleteBreak(params.breakId!);
|
|
75
|
+
if (!deleted) throw new Error("Break not found");
|
|
76
|
+
return deleted;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ─── Overrides ──────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
r.post("/{providerId}/overrides")
|
|
82
|
+
.summary("Add date override")
|
|
83
|
+
.permission("appointments:manage")
|
|
84
|
+
.input(AddOverrideSchema)
|
|
85
|
+
.handler(async ({ params, input }) => {
|
|
86
|
+
const body = input as z.infer<typeof AddOverrideSchema>;
|
|
87
|
+
return services.provider.addOverride(params.providerId!, body);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
r.get("/{providerId}/overrides")
|
|
91
|
+
.summary("List overrides")
|
|
92
|
+
.handler(async ({ params }) => {
|
|
93
|
+
return services.provider.getOverrides(params.providerId!);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
r.delete("/{providerId}/overrides/{overrideId}")
|
|
97
|
+
.summary("Delete override")
|
|
98
|
+
.permission("appointments:manage")
|
|
99
|
+
.handler(async ({ params }) => {
|
|
100
|
+
const deleted = await services.provider.deleteOverride(params.overrideId!);
|
|
101
|
+
if (!deleted) throw new Error("Override not found");
|
|
102
|
+
return deleted;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ─── Available Slots ────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
r.get("/{providerId}/slots")
|
|
108
|
+
.summary("Get available slots for a date")
|
|
109
|
+
.query(z.object({
|
|
110
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).openapi({ example: "2026-03-20" }),
|
|
111
|
+
serviceTypeId: z.string().uuid(),
|
|
112
|
+
}))
|
|
113
|
+
.handler(async ({ params, query }) => {
|
|
114
|
+
const providerId = params.providerId!;
|
|
115
|
+
const dateStr = query.date as string;
|
|
116
|
+
const serviceTypeId = query.serviceTypeId as string;
|
|
117
|
+
|
|
118
|
+
const provider = await services.provider.getProvider(providerId);
|
|
119
|
+
if (!provider) throw new Error("Provider not found");
|
|
120
|
+
|
|
121
|
+
const serviceType = await services.provider.getServiceType(serviceTypeId);
|
|
122
|
+
if (!serviceType) throw new Error("Service type not found");
|
|
123
|
+
|
|
124
|
+
// Check for provider-level custom duration/price
|
|
125
|
+
const providerSvcs = await services.provider.getProviderServices(providerId);
|
|
126
|
+
const link = providerSvcs.find((ps) => ps.serviceTypeId === serviceTypeId);
|
|
127
|
+
|
|
128
|
+
const durationMinutes = link?.customDurationMinutes ?? serviceType.durationMinutes;
|
|
129
|
+
const bufferBefore = serviceType.bufferBeforeMinutes;
|
|
130
|
+
const bufferAfter = serviceType.bufferAfterMinutes;
|
|
131
|
+
|
|
132
|
+
// Parse date (in provider's local context)
|
|
133
|
+
const [year, month, day] = dateStr.split("-").map(Number);
|
|
134
|
+
const date = new Date(year!, month! - 1, day!);
|
|
135
|
+
|
|
136
|
+
const slots = await services.slots.getAvailableSlots(
|
|
137
|
+
providerId,
|
|
138
|
+
serviceTypeId,
|
|
139
|
+
date,
|
|
140
|
+
durationMinutes,
|
|
141
|
+
bufferBefore,
|
|
142
|
+
bufferAfter,
|
|
143
|
+
provider.timezone,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return slots.map((s) => ({
|
|
147
|
+
start: s.start.toISOString(),
|
|
148
|
+
end: s.end.toISOString(),
|
|
149
|
+
}));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return r.routes();
|
|
153
|
+
}
|