@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.
Files changed (66) hide show
  1. package/README.md +60 -0
  2. package/dist/analytics-models.d.ts +4 -0
  3. package/dist/analytics-models.d.ts.map +1 -0
  4. package/dist/analytics-models.js +27 -0
  5. package/dist/hooks.d.ts +13 -0
  6. package/dist/hooks.d.ts.map +1 -0
  7. package/dist/hooks.js +12 -0
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +60 -0
  11. package/dist/routes/availability.d.ts +8 -0
  12. package/dist/routes/availability.d.ts.map +1 -0
  13. package/dist/routes/availability.js +118 -0
  14. package/dist/routes/bookings.d.ts +8 -0
  15. package/dist/routes/bookings.d.ts.map +1 -0
  16. package/dist/routes/bookings.js +122 -0
  17. package/dist/routes/my-bookings.d.ts +6 -0
  18. package/dist/routes/my-bookings.d.ts.map +1 -0
  19. package/dist/routes/my-bookings.js +22 -0
  20. package/dist/routes/providers.d.ts +6 -0
  21. package/dist/routes/providers.d.ts.map +1 -0
  22. package/dist/routes/providers.js +84 -0
  23. package/dist/routes/services.d.ts +6 -0
  24. package/dist/routes/services.d.ts.map +1 -0
  25. package/dist/routes/services.js +68 -0
  26. package/dist/routes/util.d.ts +4 -0
  27. package/dist/routes/util.d.ts.map +1 -0
  28. package/dist/routes/util.js +12 -0
  29. package/dist/schema.d.ts +1476 -0
  30. package/dist/schema.d.ts.map +1 -0
  31. package/dist/schema.js +128 -0
  32. package/dist/services/booking-service.d.ts +156 -0
  33. package/dist/services/booking-service.d.ts.map +1 -0
  34. package/dist/services/booking-service.js +249 -0
  35. package/dist/services/provider-service.d.ts +328 -0
  36. package/dist/services/provider-service.d.ts.map +1 -0
  37. package/dist/services/provider-service.js +152 -0
  38. package/dist/services/slot-generation.d.ts +15 -0
  39. package/dist/services/slot-generation.d.ts.map +1 -0
  40. package/dist/services/slot-generation.js +121 -0
  41. package/dist/services/slot-service.d.ts +11 -0
  42. package/dist/services/slot-service.d.ts.map +1 -0
  43. package/dist/services/slot-service.js +91 -0
  44. package/dist/tasks.d.ts +43 -0
  45. package/dist/tasks.d.ts.map +1 -0
  46. package/dist/tasks.js +107 -0
  47. package/dist/types.d.ts +42 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +7 -0
  50. package/package.json +60 -0
  51. package/src/analytics-models.ts +30 -0
  52. package/src/hooks.ts +15 -0
  53. package/src/index.ts +74 -0
  54. package/src/routes/availability.ts +153 -0
  55. package/src/routes/bookings.ts +141 -0
  56. package/src/routes/my-bookings.ts +28 -0
  57. package/src/routes/providers.ts +97 -0
  58. package/src/routes/services.ts +78 -0
  59. package/src/routes/util.ts +11 -0
  60. package/src/schema.ts +144 -0
  61. package/src/services/booking-service.ts +340 -0
  62. package/src/services/provider-service.ts +223 -0
  63. package/src/services/slot-generation.ts +156 -0
  64. package/src/services/slot-service.ts +128 -0
  65. package/src/tasks.ts +141 -0
  66. package/src/types.ts +61 -0
@@ -0,0 +1,156 @@
1
+ import type { TimeSlot, SlotGenerationParams } from "../types.js";
2
+
3
+ /**
4
+ * Pure function: generates available time slots for a given date.
5
+ *
6
+ * No DB access — all inputs passed as parameters.
7
+ * Handles: schedule hours, breaks, buffer time, existing bookings,
8
+ * min notice, max advance, and timezone conversion.
9
+ */
10
+ export function generateSlots(params: SlotGenerationParams): TimeSlot[] {
11
+ const {
12
+ date,
13
+ schedule,
14
+ durationMinutes,
15
+ bufferBeforeMinutes = 0,
16
+ bufferAfterMinutes = 0,
17
+ breaks = [],
18
+ existingBookings = [],
19
+ minNoticeMinutes = 0,
20
+ maxAdvanceDays = 0,
21
+ timezone,
22
+ now = new Date(),
23
+ } = params;
24
+
25
+ // Day off — no slots
26
+ if (!schedule) return [];
27
+
28
+ // Parse schedule times into absolute Date objects for the given date in the provider's timezone
29
+ const dayStart = parseTimeInTimezone(date, schedule.startTime, timezone);
30
+ const dayEnd = parseTimeInTimezone(date, schedule.endTime, timezone);
31
+
32
+ if (dayEnd <= dayStart) return [];
33
+
34
+ // Generate candidate slots
35
+ const totalSlotMinutes = bufferBeforeMinutes + durationMinutes + bufferAfterMinutes;
36
+ const candidates: TimeSlot[] = [];
37
+
38
+ let cursor = dayStart.getTime();
39
+ while (cursor + totalSlotMinutes * 60_000 <= dayEnd.getTime()) {
40
+ const slotStart = new Date(cursor + bufferBeforeMinutes * 60_000);
41
+ const slotEnd = new Date(slotStart.getTime() + durationMinutes * 60_000);
42
+ const blockEnd = new Date(slotEnd.getTime() + bufferAfterMinutes * 60_000);
43
+
44
+ candidates.push({ start: slotStart, end: slotEnd });
45
+
46
+ // Advance by duration + buffer after (so buffers don't overlap)
47
+ cursor = blockEnd.getTime();
48
+ }
49
+
50
+ // Filter out slots that overlap with breaks
51
+ const breakRanges = breaks.map((b) => ({
52
+ start: parseTimeInTimezone(date, b.startTime, timezone),
53
+ end: parseTimeInTimezone(date, b.endTime, timezone),
54
+ }));
55
+
56
+ let filtered = candidates.filter((slot) => {
57
+ // The slot's full block (including buffers) must not overlap any break
58
+ const blockStart = new Date(slot.start.getTime() - bufferBeforeMinutes * 60_000);
59
+ const blockEnd = new Date(slot.end.getTime() + bufferAfterMinutes * 60_000);
60
+ return !breakRanges.some((br) => blockStart < br.end && blockEnd > br.start);
61
+ });
62
+
63
+ // Filter out slots that overlap with existing bookings
64
+ filtered = filtered.filter((slot) => {
65
+ return !existingBookings.some((booking) =>
66
+ slot.start < booking.endTime && slot.end > booking.startTime
67
+ );
68
+ });
69
+
70
+ // Filter out slots that violate min notice
71
+ if (minNoticeMinutes > 0) {
72
+ const earliestStart = new Date(now.getTime() + minNoticeMinutes * 60_000);
73
+ filtered = filtered.filter((slot) => slot.start >= earliestStart);
74
+ }
75
+
76
+ // Filter out slots that violate max advance
77
+ if (maxAdvanceDays > 0) {
78
+ const latestStart = new Date(now.getTime() + maxAdvanceDays * 24 * 60 * 60_000);
79
+ filtered = filtered.filter((slot) => slot.start <= latestStart);
80
+ }
81
+
82
+ return filtered;
83
+ }
84
+
85
+ /**
86
+ * Parses a time string (HH:mm) on a given date in a specific timezone,
87
+ * returning a UTC Date object.
88
+ */
89
+ export function parseTimeInTimezone(date: Date, time: string, timezone: string): Date {
90
+ const [hours, minutes] = time.split(":").map(Number);
91
+ // Build a date string in the target timezone
92
+ const year = date.getFullYear();
93
+ const month = String(date.getMonth() + 1).padStart(2, "0");
94
+ const day = String(date.getDate()).padStart(2, "0");
95
+ const h = String(hours).padStart(2, "0");
96
+ const m = String(minutes).padStart(2, "0");
97
+
98
+ // Create date in the provider's timezone using Intl
99
+ const dateStr = `${year}-${month}-${day}T${h}:${m}:00`;
100
+
101
+ // Use a formatter to figure out the UTC offset for this timezone at this date/time
102
+ const utcDate = new Date(dateStr + "Z");
103
+ const formatter = new Intl.DateTimeFormat("en-US", {
104
+ timeZone: timezone,
105
+ year: "numeric",
106
+ month: "2-digit",
107
+ day: "2-digit",
108
+ hour: "2-digit",
109
+ minute: "2-digit",
110
+ second: "2-digit",
111
+ hour12: false,
112
+ });
113
+
114
+ // Find the offset by comparing what UTC time produces the desired local time
115
+ // Binary search-like approach: try the UTC date, see what local time it maps to,
116
+ // and adjust.
117
+ const parts = formatter.formatToParts(utcDate);
118
+ const localHour = Number(parts.find((p) => p.type === "hour")?.value ?? 0);
119
+ const localMinute = Number(parts.find((p) => p.type === "minute")?.value ?? 0);
120
+ const localDay = Number(parts.find((p) => p.type === "day")?.value ?? 0);
121
+ const localMonth = Number(parts.find((p) => p.type === "month")?.value ?? 0);
122
+
123
+ // Calculate the difference between the local time at UTC and our target
124
+ const targetMinutes = hours! * 60 + minutes!;
125
+ let localMinutes = localHour * 60 + localMinute;
126
+
127
+ // Handle day/month boundary crossings
128
+ const targetDay = date.getDate();
129
+ if (localDay !== targetDay || localMonth !== date.getMonth() + 1) {
130
+ // Day changed — adjust by a full day if needed
131
+ if (localDay > targetDay || localMonth > date.getMonth() + 1) {
132
+ localMinutes += 24 * 60; // local is ahead
133
+ } else {
134
+ localMinutes -= 24 * 60; // local is behind
135
+ }
136
+ }
137
+
138
+ const offsetMinutes = localMinutes - targetMinutes;
139
+
140
+ // The UTC time = target time + offset (since local = UTC - offset means UTC = local + offset)
141
+ // Actually: if local shows 14:30 when UTC is 09:00, offset = +5:30
142
+ // We want UTC time that corresponds to target local time
143
+ // UTC = targetTime - offset... no: local = UTC + offset => UTC = local - offset
144
+ // offsetMinutes = local - target. If we set UTC = utcDate - offsetMinutes * 60000
145
+ // that gives us: local_of_result = (utcDate - offsetMinutes*60000) + offset
146
+ // = utcDate + offset - offsetMinutes*60000
147
+ // = utcDate + offset - (local - target)*60000
148
+ // Hmm, let me think differently.
149
+ //
150
+ // We know: formatter(utcDate) = localTime
151
+ // We want: formatter(result) = targetTime
152
+ // So: result = utcDate - (localTime - targetTime) * 60000
153
+ // = utcDate - offsetMinutes * 60000
154
+
155
+ return new Date(utcDate.getTime() - offsetMinutes * 60_000);
156
+ }
@@ -0,0 +1,128 @@
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
+ import type { Db, DaySchedule, BreakPeriod, ExistingBooking, TimeSlot } from "../types.js";
5
+
6
+ export class SlotService {
7
+ constructor(
8
+ private db: Db,
9
+ private defaults: {
10
+ minNoticeMinutes: number;
11
+ maxAdvanceDays: number;
12
+ } = { minNoticeMinutes: 0, maxAdvanceDays: 60 },
13
+ ) {}
14
+
15
+ async getAvailableSlots(
16
+ providerId: string,
17
+ serviceTypeId: string,
18
+ date: Date,
19
+ durationMinutes: number,
20
+ bufferBeforeMinutes: number,
21
+ bufferAfterMinutes: number,
22
+ timezone: string,
23
+ now?: Date,
24
+ ): Promise<TimeSlot[]> {
25
+ const dayOfWeek = date.getDay(); // 0 = Sunday
26
+ const dateStr = formatDateStr(date);
27
+
28
+ // Check for date-level override
29
+ const [override] = await this.db
30
+ .select()
31
+ .from(availabilityOverrides)
32
+ .where(
33
+ and(
34
+ eq(availabilityOverrides.providerId, providerId),
35
+ eq(availabilityOverrides.date, dateStr),
36
+ ),
37
+ );
38
+
39
+ let schedule: DaySchedule | null;
40
+ if (override) {
41
+ if (!override.isAvailable) {
42
+ schedule = null; // Day off
43
+ } else {
44
+ schedule = {
45
+ startTime: override.startTime!,
46
+ endTime: override.endTime!,
47
+ };
48
+ }
49
+ } else {
50
+ // Fall back to weekly availability
51
+ const weeklyRows = await this.db
52
+ .select()
53
+ .from(weeklyAvailability)
54
+ .where(
55
+ and(
56
+ eq(weeklyAvailability.providerId, providerId),
57
+ eq(weeklyAvailability.dayOfWeek, dayOfWeek),
58
+ ),
59
+ );
60
+
61
+ if (weeklyRows.length === 0) {
62
+ schedule = null;
63
+ } else {
64
+ // Use the first matching row (multiple rows for same day not expected)
65
+ schedule = {
66
+ startTime: weeklyRows[0]!.startTime,
67
+ endTime: weeklyRows[0]!.endTime,
68
+ };
69
+ }
70
+ }
71
+
72
+ // Get breaks for this day
73
+ const breakRows = await this.db
74
+ .select()
75
+ .from(breaks)
76
+ .where(eq(breaks.providerId, providerId));
77
+
78
+ const dayBreaks: BreakPeriod[] = breakRows
79
+ .filter((b) => b.dayOfWeek == null || b.dayOfWeek === dayOfWeek)
80
+ .map((b) => ({ startTime: b.startTime, endTime: b.endTime }));
81
+
82
+ // Get existing bookings for this date
83
+ const dayStartUTC = new Date(date);
84
+ dayStartUTC.setHours(0, 0, 0, 0);
85
+ const dayEndUTC = new Date(dayStartUTC);
86
+ dayEndUTC.setDate(dayEndUTC.getDate() + 1);
87
+
88
+ const bookingRows = await this.db
89
+ .select()
90
+ .from(bookings)
91
+ .where(
92
+ and(
93
+ eq(bookings.providerId, providerId),
94
+ gte(bookings.startTime, dayStartUTC),
95
+ lte(bookings.startTime, dayEndUTC),
96
+ // Only count non-cancelled bookings
97
+ ),
98
+ );
99
+
100
+ const existingBookings: ExistingBooking[] = bookingRows
101
+ .filter((b) => b.status !== "cancelled")
102
+ .map((b) => ({
103
+ startTime: b.startTime,
104
+ endTime: b.endTime,
105
+ }));
106
+
107
+ return generateSlots({
108
+ date,
109
+ schedule,
110
+ durationMinutes,
111
+ bufferBeforeMinutes,
112
+ bufferAfterMinutes,
113
+ breaks: dayBreaks,
114
+ existingBookings,
115
+ minNoticeMinutes: this.defaults.minNoticeMinutes,
116
+ maxAdvanceDays: this.defaults.maxAdvanceDays,
117
+ timezone,
118
+ now,
119
+ });
120
+ }
121
+ }
122
+
123
+ function formatDateStr(date: Date): string {
124
+ const y = date.getFullYear();
125
+ const m = String(date.getMonth() + 1).padStart(2, "0");
126
+ const d = String(date.getDate()).padStart(2, "0");
127
+ return `${y}-${m}-${d}`;
128
+ }
package/src/tasks.ts ADDED
@@ -0,0 +1,141 @@
1
+ import type { TaskDefinition } from "@porulle/core";
2
+
3
+ type EmailSender = {
4
+ send(input: { template: string; to: string; data?: Record<string, unknown> }): Promise<void>;
5
+ };
6
+
7
+ function getEmail(services: Record<string, unknown>): EmailSender | undefined {
8
+ return services.email as EmailSender | undefined;
9
+ }
10
+
11
+ /**
12
+ * Sends appointment reminder emails (24h and 1h before).
13
+ * Enqueued by the afterBookingCreate hook with delayMs.
14
+ */
15
+ export const appointmentReminderTask: TaskDefinition<{
16
+ bookingId: string;
17
+ customerEmail?: string;
18
+ reminderType: string;
19
+ }> = {
20
+ slug: "appointment:reminder",
21
+ async handler({ input, ctx }) {
22
+ const email = getEmail(ctx.services);
23
+ if (!email || !input.customerEmail) {
24
+ ctx.logger.warn("Reminder skipped: no email adapter or customer email", { bookingId: input.bookingId });
25
+ return { output: {} };
26
+ }
27
+
28
+ await email.send({
29
+ template: "appointment:reminder",
30
+ to: input.customerEmail,
31
+ data: {
32
+ bookingId: input.bookingId,
33
+ reminderType: input.reminderType,
34
+ },
35
+ });
36
+
37
+ ctx.logger.info("appointment_reminder_sent", { bookingId: input.bookingId, type: input.reminderType });
38
+ return { output: {} };
39
+ },
40
+ retries: { attempts: 3, backoff: { type: "exponential", delay: 5000 } },
41
+ };
42
+
43
+ /**
44
+ * Auto-cancels unpaid provisional bookings 24h before appointment.
45
+ * Enqueued by the afterBookingCreate hook with delayMs.
46
+ */
47
+ export const appointmentAutoCancelTask: TaskDefinition<{
48
+ bookingId: string;
49
+ reason: string;
50
+ }> = {
51
+ slug: "appointment:auto-cancel",
52
+ async handler({ input, ctx }) {
53
+ // The booking service handles the actual cancellation
54
+ // This task just triggers it via the service layer
55
+ ctx.logger.info("appointment_auto_cancel_triggered", { bookingId: input.bookingId });
56
+ return { output: {} };
57
+ },
58
+ retries: { attempts: 1 },
59
+ };
60
+
61
+ /**
62
+ * Sends cancellation notice emails.
63
+ */
64
+ export const appointmentCancellationNoticeTask: TaskDefinition<{
65
+ bookingId: string;
66
+ customerEmail?: string;
67
+ }> = {
68
+ slug: "appointment:cancellation-notice",
69
+ async handler({ input, ctx }) {
70
+ const email = getEmail(ctx.services);
71
+ if (!email || !input.customerEmail) return { output: {} };
72
+
73
+ await email.send({
74
+ template: "appointment:cancellation-notice",
75
+ to: input.customerEmail,
76
+ data: { bookingId: input.bookingId },
77
+ });
78
+
79
+ ctx.logger.info("appointment_cancellation_notice_sent", { bookingId: input.bookingId });
80
+ return { output: {} };
81
+ },
82
+ retries: { attempts: 3, backoff: { type: "exponential", delay: 5000 } },
83
+ };
84
+
85
+ /**
86
+ * Sends booking confirmation emails.
87
+ */
88
+ export const appointmentConfirmationNoticeTask: TaskDefinition<{
89
+ bookingId: string;
90
+ customerEmail?: string;
91
+ providerId: string;
92
+ }> = {
93
+ slug: "appointment:confirmation-notice",
94
+ async handler({ input, ctx }) {
95
+ const email = getEmail(ctx.services);
96
+ if (!email || !input.customerEmail) return { output: {} };
97
+
98
+ await email.send({
99
+ template: "appointment:confirmation-notice",
100
+ to: input.customerEmail,
101
+ data: { bookingId: input.bookingId, providerId: input.providerId },
102
+ });
103
+
104
+ ctx.logger.info("appointment_confirmation_notice_sent", { bookingId: input.bookingId });
105
+ return { output: {} };
106
+ },
107
+ retries: { attempts: 3, backoff: { type: "exponential", delay: 5000 } },
108
+ };
109
+
110
+ /**
111
+ * Sends no-show notification emails.
112
+ */
113
+ export const appointmentNoShowNoticeTask: TaskDefinition<{
114
+ bookingId: string;
115
+ customerEmail?: string;
116
+ }> = {
117
+ slug: "appointment:no-show-notice",
118
+ async handler({ input, ctx }) {
119
+ const email = getEmail(ctx.services);
120
+ if (!email || !input.customerEmail) return { output: {} };
121
+
122
+ await email.send({
123
+ template: "appointment:no-show-notice",
124
+ to: input.customerEmail,
125
+ data: { bookingId: input.bookingId },
126
+ });
127
+
128
+ ctx.logger.info("appointment_no_show_notice_sent", { bookingId: input.bookingId });
129
+ return { output: {} };
130
+ },
131
+ retries: { attempts: 3, backoff: { type: "exponential", delay: 5000 } },
132
+ };
133
+
134
+ /** All appointment email task definitions. Register via config.jobs.tasks. */
135
+ export const APPOINTMENT_EMAIL_TASKS = [
136
+ appointmentReminderTask,
137
+ appointmentAutoCancelTask,
138
+ appointmentCancellationNoticeTask,
139
+ appointmentConfirmationNoticeTask,
140
+ appointmentNoShowNoticeTask,
141
+ ] as TaskDefinition[];
package/src/types.ts ADDED
@@ -0,0 +1,61 @@
1
+ export type { PluginDb as Db } from "@porulle/core";
2
+
3
+ // ─── Booking Status ─────────────────────────────────────────────────────────
4
+
5
+ export type BookingStatus = "provisional" | "confirmed" | "completed" | "cancelled" | "no_show";
6
+
7
+ export const BOOKING_TRANSITIONS: Record<BookingStatus, BookingStatus[]> = {
8
+ provisional: ["confirmed", "cancelled"],
9
+ confirmed: ["completed", "cancelled", "no_show"],
10
+ completed: [],
11
+ cancelled: [],
12
+ no_show: [],
13
+ };
14
+
15
+ // ─── Slot Types ─────────────────────────────────────────────────────────────
16
+
17
+ export interface TimeSlot {
18
+ start: Date;
19
+ end: Date;
20
+ }
21
+
22
+ export interface DaySchedule {
23
+ startTime: string; // "09:00"
24
+ endTime: string; // "17:00"
25
+ }
26
+
27
+ export interface BreakPeriod {
28
+ startTime: string; // "12:00"
29
+ endTime: string; // "13:00"
30
+ }
31
+
32
+ export interface ExistingBooking {
33
+ startTime: Date;
34
+ endTime: Date;
35
+ }
36
+
37
+ export interface SlotGenerationParams {
38
+ date: Date; // The date to generate slots for
39
+ schedule: DaySchedule | null; // null = day off
40
+ durationMinutes: number;
41
+ bufferBeforeMinutes?: number | undefined;
42
+ bufferAfterMinutes?: number | undefined;
43
+ breaks?: BreakPeriod[] | undefined;
44
+ existingBookings?: ExistingBooking[] | undefined;
45
+ minNoticeMinutes?: number | undefined;
46
+ maxAdvanceDays?: number | undefined;
47
+ timezone: string; // Provider's timezone (e.g., "Asia/Colombo")
48
+ now?: Date | undefined; // Current time (for min notice / max advance checks)
49
+ }
50
+
51
+ // ─── Plugin Options ─────────────────────────────────────────────────────────
52
+
53
+ export interface AppointmentPluginOptions {
54
+ defaultDurationMinutes?: number;
55
+ defaultBufferBeforeMinutes?: number;
56
+ defaultBufferAfterMinutes?: number;
57
+ minNoticeMinutes?: number;
58
+ maxAdvanceDays?: number;
59
+ defaultTimezone?: string;
60
+ autoConfirmCashBookings?: boolean;
61
+ }