@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,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
|
+
}
|