@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,340 @@
1
+ import { eq, and, gte, lte } from "@porulle/core/drizzle";
2
+ import { bookings, bookingPayments, serviceTypes, providers } from "../schema.js";
3
+ import { SlotService } from "./slot-service.js";
4
+ import type { Db, BookingStatus } from "../types.js";
5
+ import type { JobsAdapter } from "@porulle/core";
6
+
7
+ interface BookingInput {
8
+ providerId: string;
9
+ serviceTypeId: string;
10
+ customerId: string;
11
+ customerName: string;
12
+ customerEmail: string;
13
+ customerPhone?: string | undefined;
14
+ startTime: Date;
15
+ paymentMethod: "card" | "cash" | "invoice";
16
+ notes?: string | undefined;
17
+ metadata?: Record<string, unknown> | undefined;
18
+ }
19
+
20
+ interface PaymentAdapterLike {
21
+ createPaymentIntent(params: {
22
+ amount: number;
23
+ currency: string;
24
+ orderId: string;
25
+ customerId?: string | undefined;
26
+ metadata?: Record<string, string> | undefined;
27
+ }): Promise<{ ok: true; value: { id: string; clientSecret?: string | null } } | { ok: false; error: { message: string } }>;
28
+ refundPayment(paymentId: string, amount: number, reason?: string): Promise<{ ok: boolean }>;
29
+ }
30
+
31
+ export class BookingService {
32
+ private transitions: Record<string, string[]> = {
33
+ provisional: ["confirmed", "cancelled"],
34
+ confirmed: ["completed", "cancelled", "no_show"],
35
+ completed: [],
36
+ cancelled: [],
37
+ no_show: [],
38
+ };
39
+
40
+ constructor(
41
+ private db: Db,
42
+ private slotService: SlotService,
43
+ private paymentAdapter?: PaymentAdapterLike,
44
+ private jobs?: JobsAdapter,
45
+ ) {}
46
+
47
+ private async providerOrganizationId(providerId: string): Promise<string> {
48
+ const [row] = await this.db
49
+ .select({ organizationId: providers.organizationId })
50
+ .from(providers)
51
+ .where(eq(providers.id, providerId));
52
+ if (!row) {
53
+ throw new Error(`Appointment provider not found: ${providerId}`);
54
+ }
55
+ return row.organizationId;
56
+ }
57
+
58
+ async create(input: BookingInput, txDb?: Db): Promise<{
59
+ ok: true;
60
+ booking: typeof bookings.$inferSelect;
61
+ paymentIntent?: { id: string; clientSecret?: string | null } | undefined;
62
+ } | { ok: false; error: string; code: string }> {
63
+ // Validate service type exists (outside transaction -- read-only, no lock needed)
64
+ const [service] = await this.db.select().from(serviceTypes).where(eq(serviceTypes.id, input.serviceTypeId));
65
+ if (!service) return { ok: false, error: "Service type not found", code: "NOT_FOUND" };
66
+
67
+ // Validate provider exists
68
+ const [provider] = await this.db.select().from(providers).where(eq(providers.id, input.providerId));
69
+ if (!provider) return { ok: false, error: "Provider not found", code: "NOT_FOUND" };
70
+
71
+ const durationMinutes = service.durationMinutes;
72
+ const endTime = new Date(input.startTime.getTime() + durationMinutes * 60_000);
73
+ const priceCents = service.priceCents;
74
+
75
+ // Create payment intent if card payment (outside transaction -- external API call)
76
+ let paymentIntentId: string | undefined;
77
+ let paymentIntent: { id: string; clientSecret?: string | null } | undefined;
78
+
79
+ if (input.paymentMethod === "card" && this.paymentAdapter) {
80
+ const result = await this.paymentAdapter.createPaymentIntent({
81
+ amount: priceCents,
82
+ currency: service.currency,
83
+ orderId: `appointment-${Date.now()}`,
84
+ customerId: input.customerId,
85
+ metadata: { serviceTypeId: input.serviceTypeId, providerId: input.providerId },
86
+ });
87
+
88
+ if (!result.ok) {
89
+ return { ok: false, error: "Payment creation failed", code: "PAYMENT_ERROR" };
90
+ }
91
+
92
+ paymentIntentId = result.value.id;
93
+ paymentIntent = result.value;
94
+ }
95
+
96
+ // Conflict check + insert MUST be in a transaction for SELECT FOR UPDATE to hold the lock.
97
+ // Without a transaction, the row lock is released immediately after the SELECT.
98
+ const db = txDb ?? this.db;
99
+
100
+ const bookingOrError = await db.transaction(async (tx) => {
101
+ // Check for conflicts (SELECT FOR UPDATE holds lock until COMMIT)
102
+ const conflicting = await tx
103
+ .select()
104
+ .from(bookings)
105
+ .where(
106
+ and(
107
+ eq(bookings.providerId, input.providerId),
108
+ lte(bookings.startTime, endTime),
109
+ gte(bookings.endTime, input.startTime),
110
+ ),
111
+ )
112
+ .for("update");
113
+
114
+ const activeConflicts = conflicting.filter(
115
+ (b) => b.status !== "cancelled",
116
+ );
117
+
118
+ if (activeConflicts.length > 0) {
119
+ return { ok: false as const, error: "Time slot is already booked", code: "CONFLICT" };
120
+ }
121
+
122
+ const [booking] = await tx.insert(bookings).values({
123
+ providerId: input.providerId,
124
+ serviceTypeId: input.serviceTypeId,
125
+ customerId: input.customerId,
126
+ customerName: input.customerName,
127
+ customerEmail: input.customerEmail,
128
+ customerPhone: input.customerPhone,
129
+ startTime: input.startTime,
130
+ endTime,
131
+ status: "provisional",
132
+ paymentMethod: input.paymentMethod,
133
+ notes: input.notes,
134
+ metadata: input.metadata ?? {},
135
+ }).returning();
136
+
137
+ // Create payment record if card payment
138
+ if (paymentIntentId && booking) {
139
+ await tx.insert(bookingPayments).values({
140
+ bookingId: booking.id,
141
+ amountCents: priceCents,
142
+ currency: service.currency,
143
+ status: "pending",
144
+ paymentIntentId,
145
+ });
146
+ }
147
+
148
+ return { ok: true as const, booking: booking! };
149
+ });
150
+
151
+ if (!bookingOrError.ok) {
152
+ return bookingOrError;
153
+ }
154
+
155
+ // Enqueue notification jobs (fire-and-forget, must not block the response)
156
+ if (this.jobs) {
157
+ const bk = bookingOrError.booking;
158
+ const organizationId = provider.organizationId;
159
+ const startMs = bk.startTime.getTime();
160
+
161
+ // 24h reminder
162
+ const r24 = startMs - 24 * 60 * 60_000 - Date.now();
163
+ if (r24 > 0) {
164
+ this.jobs.enqueue("appointment:reminder", { bookingId: bk.id, customerEmail: bk.customerEmail, reminderType: "24h" }, { organizationId, delayMs: r24 }).catch(() => {});
165
+ }
166
+ // 1h reminder
167
+ const r1 = startMs - 60 * 60_000 - Date.now();
168
+ if (r1 > 0) {
169
+ this.jobs.enqueue("appointment:reminder", { bookingId: bk.id, customerEmail: bk.customerEmail, reminderType: "1h" }, { organizationId, delayMs: r1 }).catch(() => {});
170
+ }
171
+ // Auto-cancel unpaid provisionals
172
+ if (bk.paymentMethod !== "card") {
173
+ const ac = startMs - 24 * 60 * 60_000 - Date.now();
174
+ if (ac > 0) {
175
+ this.jobs.enqueue("appointment:auto-cancel", { bookingId: bk.id, reason: "Unpaid provisional auto-cancelled" }, { organizationId, delayMs: ac }).catch(() => {});
176
+ }
177
+ }
178
+ }
179
+
180
+ const result: {
181
+ ok: true;
182
+ booking: typeof bookings.$inferSelect;
183
+ paymentIntent?: { id: string; clientSecret?: string | null } | undefined;
184
+ } = { ok: true, booking: bookingOrError.booking };
185
+ if (paymentIntent) result.paymentIntent = paymentIntent;
186
+ return result;
187
+ }
188
+
189
+ async getById(id: string) {
190
+ const [row] = await this.db.select().from(bookings).where(eq(bookings.id, id));
191
+ return row ?? null;
192
+ }
193
+
194
+ async listByCustomer(customerId: string) {
195
+ return this.db.select().from(bookings)
196
+ .where(eq(bookings.customerId, customerId))
197
+ .orderBy(bookings.startTime);
198
+ }
199
+
200
+ async listByProvider(providerId: string, from?: Date, to?: Date) {
201
+ const conditions = [eq(bookings.providerId, providerId)];
202
+ if (from) conditions.push(gte(bookings.startTime, from));
203
+ if (to) conditions.push(lte(bookings.startTime, to));
204
+
205
+ return this.db.select().from(bookings)
206
+ .where(and(...conditions))
207
+ .orderBy(bookings.startTime);
208
+ }
209
+
210
+ async changeStatus(
211
+ id: string,
212
+ newStatus: BookingStatus,
213
+ reason?: string,
214
+ ): Promise<{ ok: true; booking: typeof bookings.$inferSelect } | { ok: false; error: string }> {
215
+ const booking = await this.getById(id);
216
+ if (!booking) return { ok: false, error: "Booking not found" };
217
+
218
+ const allowed = this.transitions[booking.status];
219
+ if (!allowed || !allowed.includes(newStatus)) {
220
+ return { ok: false, error: `Cannot transition from ${booking.status} to ${newStatus}` };
221
+ }
222
+
223
+ const updates: Record<string, unknown> = {
224
+ status: newStatus,
225
+ updatedAt: new Date(),
226
+ };
227
+
228
+ if (newStatus === "cancelled" && reason) {
229
+ updates.cancellationReason = reason;
230
+ }
231
+
232
+ const [updated] = await this.db.update(bookings)
233
+ .set(updates)
234
+ .where(eq(bookings.id, id))
235
+ .returning();
236
+
237
+ // Enqueue status-specific notifications
238
+ if (this.jobs && updated) {
239
+ const organizationId = await this.providerOrganizationId(updated.providerId);
240
+ if (newStatus === "confirmed") {
241
+ this.jobs.enqueue("appointment:confirmation-notice", { bookingId: id, customerEmail: updated.customerEmail, providerId: updated.providerId }, { organizationId }).catch(() => {});
242
+ } else if (newStatus === "no_show") {
243
+ this.jobs.enqueue("appointment:no-show-notice", { bookingId: id, customerEmail: updated.customerEmail }, { organizationId }).catch(() => {});
244
+ }
245
+ }
246
+
247
+ return { ok: true, booking: updated! };
248
+ }
249
+
250
+ async cancel(
251
+ id: string,
252
+ reason?: string,
253
+ ): Promise<{ ok: true; booking: typeof bookings.$inferSelect; refunded: boolean } | { ok: false; error: string }> {
254
+ const result = await this.changeStatus(id, "cancelled", reason);
255
+ if (!result.ok) return result;
256
+
257
+ let refunded = false;
258
+
259
+ // Check for payment record and refund if paid
260
+ const [payment] = await this.db.select().from(bookingPayments)
261
+ .where(eq(bookingPayments.bookingId, id));
262
+
263
+ if (payment?.paymentIntentId && this.paymentAdapter) {
264
+ await this.paymentAdapter.refundPayment(
265
+ payment.paymentIntentId,
266
+ payment.amountCents,
267
+ reason ?? "Booking cancelled",
268
+ );
269
+ await this.db.update(bookingPayments)
270
+ .set({ status: "refunded", refundedAt: new Date() })
271
+ .where(eq(bookingPayments.id, payment.id));
272
+ refunded = true;
273
+ }
274
+
275
+ // Enqueue cancellation notice
276
+ if (this.jobs) {
277
+ const organizationId = await this.providerOrganizationId(result.booking.providerId);
278
+ this.jobs.enqueue("appointment:cancellation-notice", { bookingId: id, customerEmail: result.booking.customerEmail }, { organizationId }).catch(() => {});
279
+ }
280
+
281
+ return { ok: true, booking: result.booking, refunded };
282
+ }
283
+
284
+ async reschedule(
285
+ bookingId: string,
286
+ newStartTime: Date,
287
+ txDb?: Db,
288
+ ): Promise<{ ok: true; oldBooking: typeof bookings.$inferSelect; newBooking: typeof bookings.$inferSelect } | { ok: false; error: string }> {
289
+ const db = txDb ?? this.db;
290
+
291
+ const existing = await this.getById(bookingId);
292
+ if (!existing) return { ok: false, error: "Booking not found" };
293
+ if (existing.status === "cancelled" || existing.status === "completed" || existing.status === "no_show") {
294
+ return { ok: false, error: `Cannot reschedule a ${existing.status} booking` };
295
+ }
296
+
297
+ // Cancel old booking
298
+ const cancelResult = await this.changeStatus(bookingId, "cancelled", "Rescheduled");
299
+ if (!cancelResult.ok) return cancelResult;
300
+
301
+ // Create new booking
302
+ const createResult = await this.create({
303
+ providerId: existing.providerId,
304
+ serviceTypeId: existing.serviceTypeId,
305
+ customerId: existing.customerId,
306
+ customerName: existing.customerName,
307
+ customerEmail: existing.customerEmail,
308
+ customerPhone: existing.customerPhone ?? undefined,
309
+ startTime: newStartTime,
310
+ paymentMethod: existing.paymentMethod as "card" | "cash" | "invoice",
311
+ notes: existing.notes ?? undefined,
312
+ metadata: { ...(existing.metadata as Record<string, unknown> ?? {}), rescheduledFromId: bookingId },
313
+ }, db);
314
+
315
+ if (!createResult.ok) {
316
+ return { ok: false, error: createResult.error };
317
+ }
318
+
319
+ // Update the new booking to reference the old one
320
+ await db.update(bookings)
321
+ .set({ rescheduledFromId: bookingId })
322
+ .where(eq(bookings.id, createResult.booking.id));
323
+
324
+ return { ok: true, oldBooking: cancelResult.booking, newBooking: createResult.booking };
325
+ }
326
+
327
+ async markNoShow(id: string): Promise<{ ok: true; booking: typeof bookings.$inferSelect } | { ok: false; error: string }> {
328
+ return this.changeStatus(id, "no_show");
329
+ }
330
+
331
+ async complete(id: string): Promise<{ ok: true; booking: typeof bookings.$inferSelect } | { ok: false; error: string }> {
332
+ return this.changeStatus(id, "confirmed").then((r) => {
333
+ if (!r.ok) {
334
+ // Try direct to completed if already confirmed
335
+ return this.changeStatus(id, "completed");
336
+ }
337
+ return this.changeStatus(id, "completed");
338
+ });
339
+ }
340
+ }
@@ -0,0 +1,223 @@
1
+ import { eq, and, desc } from "@porulle/core/drizzle";
2
+ import {
3
+ serviceTypes, providers, providerServices,
4
+ weeklyAvailability, availabilityOverrides, breaks,
5
+ } from "../schema.js";
6
+ import type { Db } from "../types.js";
7
+
8
+ export class ProviderService {
9
+ constructor(private db: Db) {}
10
+
11
+ // ─── Service Types ──────────────────────────────────────────────────────────
12
+
13
+ async createServiceType(orgId: string, data: {
14
+ name: string;
15
+ slug: string;
16
+ description?: string;
17
+ durationMinutes?: number;
18
+ priceCents?: number;
19
+ currency?: string;
20
+ bufferBeforeMinutes?: number;
21
+ bufferAfterMinutes?: number;
22
+ }) {
23
+ const [row] = await this.db.insert(serviceTypes).values({
24
+ organizationId: orgId,
25
+ name: data.name,
26
+ slug: data.slug,
27
+ description: data.description,
28
+ durationMinutes: data.durationMinutes ?? 30,
29
+ priceCents: data.priceCents ?? 0,
30
+ currency: data.currency ?? "USD",
31
+ bufferBeforeMinutes: data.bufferBeforeMinutes ?? 0,
32
+ bufferAfterMinutes: data.bufferAfterMinutes ?? 0,
33
+ }).returning();
34
+ return row;
35
+ }
36
+
37
+ async getServiceType(id: string) {
38
+ const [row] = await this.db.select().from(serviceTypes).where(eq(serviceTypes.id, id));
39
+ return row ?? null;
40
+ }
41
+
42
+ async getServiceTypeBySlug(slug: string) {
43
+ const [row] = await this.db.select().from(serviceTypes).where(eq(serviceTypes.slug, slug));
44
+ return row ?? null;
45
+ }
46
+
47
+ async listServiceTypes() {
48
+ return this.db.select().from(serviceTypes).where(eq(serviceTypes.isActive, true)).orderBy(desc(serviceTypes.createdAt));
49
+ }
50
+
51
+ async updateServiceType(id: string, data: Partial<{
52
+ name: string; description: string; durationMinutes: number;
53
+ priceCents: number; currency: string;
54
+ bufferBeforeMinutes: number; bufferAfterMinutes: number; isActive: boolean;
55
+ }>) {
56
+ const [updated] = await this.db.update(serviceTypes)
57
+ .set({ ...data, updatedAt: new Date() })
58
+ .where(eq(serviceTypes.id, id))
59
+ .returning();
60
+ return updated ?? null;
61
+ }
62
+
63
+ async deleteServiceType(id: string) {
64
+ const [deleted] = await this.db.delete(serviceTypes).where(eq(serviceTypes.id, id)).returning();
65
+ return deleted ?? null;
66
+ }
67
+
68
+ // ─── Providers ──────────────────────────────────────────────────────────────
69
+
70
+ async createProvider(orgId: string, data: {
71
+ name: string;
72
+ email?: string;
73
+ phone?: string;
74
+ timezone?: string;
75
+ metadata?: Record<string, unknown>;
76
+ }) {
77
+ const [row] = await this.db.insert(providers).values({
78
+ organizationId: orgId,
79
+ name: data.name,
80
+ email: data.email,
81
+ phone: data.phone,
82
+ timezone: data.timezone ?? "UTC",
83
+ metadata: data.metadata ?? {},
84
+ }).returning();
85
+ return row;
86
+ }
87
+
88
+ async getProvider(id: string) {
89
+ const [row] = await this.db.select().from(providers).where(eq(providers.id, id));
90
+ return row ?? null;
91
+ }
92
+
93
+ async listProviders() {
94
+ return this.db.select().from(providers).where(eq(providers.isActive, true)).orderBy(desc(providers.createdAt));
95
+ }
96
+
97
+ async updateProvider(id: string, data: Partial<{
98
+ name: string; email: string; phone: string; timezone: string; isActive: boolean;
99
+ metadata: Record<string, unknown>;
100
+ }>) {
101
+ const [updated] = await this.db.update(providers)
102
+ .set({ ...data, updatedAt: new Date() })
103
+ .where(eq(providers.id, id))
104
+ .returning();
105
+ return updated ?? null;
106
+ }
107
+
108
+ async deleteProvider(id: string) {
109
+ const [deleted] = await this.db.delete(providers).where(eq(providers.id, id)).returning();
110
+ return deleted ?? null;
111
+ }
112
+
113
+ // ─── Provider–ServiceType Links ─────────────────────────────────────────────
114
+
115
+ async linkServiceType(providerId: string, serviceTypeId: string, overrides?: {
116
+ customDurationMinutes?: number;
117
+ customPriceCents?: number;
118
+ }) {
119
+ const [row] = await this.db.insert(providerServices).values({
120
+ providerId,
121
+ serviceTypeId,
122
+ customDurationMinutes: overrides?.customDurationMinutes,
123
+ customPriceCents: overrides?.customPriceCents,
124
+ }).returning();
125
+ return row;
126
+ }
127
+
128
+ async getProviderServices(providerId: string) {
129
+ return this.db.select().from(providerServices)
130
+ .where(eq(providerServices.providerId, providerId));
131
+ }
132
+
133
+ // ─── Weekly Availability ────────────────────────────────────────────────────
134
+
135
+ async setWeeklyAvailability(providerId: string, schedules: Array<{
136
+ dayOfWeek: number;
137
+ startTime: string;
138
+ endTime: string;
139
+ }>) {
140
+ // Delete existing weekly availability for this provider
141
+ await this.db.delete(weeklyAvailability)
142
+ .where(eq(weeklyAvailability.providerId, providerId));
143
+
144
+ if (schedules.length === 0) return [];
145
+
146
+ const rows = await this.db.insert(weeklyAvailability)
147
+ .values(schedules.map((s) => ({
148
+ providerId,
149
+ dayOfWeek: s.dayOfWeek,
150
+ startTime: s.startTime,
151
+ endTime: s.endTime,
152
+ })))
153
+ .returning();
154
+
155
+ return rows;
156
+ }
157
+
158
+ async getWeeklyAvailability(providerId: string) {
159
+ return this.db.select().from(weeklyAvailability)
160
+ .where(eq(weeklyAvailability.providerId, providerId))
161
+ .orderBy(weeklyAvailability.dayOfWeek);
162
+ }
163
+
164
+ // ─── Breaks ─────────────────────────────────────────────────────────────────
165
+
166
+ async addBreak(providerId: string, data: {
167
+ dayOfWeek?: number | undefined;
168
+ startTime: string;
169
+ endTime: string;
170
+ label?: string | undefined;
171
+ }) {
172
+ const [row] = await this.db.insert(breaks).values({
173
+ providerId,
174
+ dayOfWeek: data.dayOfWeek,
175
+ startTime: data.startTime,
176
+ endTime: data.endTime,
177
+ label: data.label,
178
+ }).returning();
179
+ return row;
180
+ }
181
+
182
+ async getBreaks(providerId: string) {
183
+ return this.db.select().from(breaks)
184
+ .where(eq(breaks.providerId, providerId));
185
+ }
186
+
187
+ async deleteBreak(id: string) {
188
+ const [deleted] = await this.db.delete(breaks).where(eq(breaks.id, id)).returning();
189
+ return deleted ?? null;
190
+ }
191
+
192
+ // ─── Availability Overrides ─────────────────────────────────────────────────
193
+
194
+ async addOverride(providerId: string, data: {
195
+ date: string;
196
+ isAvailable: boolean;
197
+ startTime?: string | undefined;
198
+ endTime?: string | undefined;
199
+ reason?: string | undefined;
200
+ }) {
201
+ const [row] = await this.db.insert(availabilityOverrides).values({
202
+ providerId,
203
+ date: data.date,
204
+ isAvailable: data.isAvailable,
205
+ startTime: data.startTime,
206
+ endTime: data.endTime,
207
+ reason: data.reason,
208
+ }).returning();
209
+ return row;
210
+ }
211
+
212
+ async getOverrides(providerId: string) {
213
+ return this.db.select().from(availabilityOverrides)
214
+ .where(eq(availabilityOverrides.providerId, providerId))
215
+ .orderBy(availabilityOverrides.date);
216
+ }
217
+
218
+ async deleteOverride(id: string) {
219
+ const [deleted] = await this.db.delete(availabilityOverrides)
220
+ .where(eq(availabilityOverrides.id, id)).returning();
221
+ return deleted ?? null;
222
+ }
223
+ }