@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 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,YAAY;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAuBtB,CAAC;AAIJ,eAAO,MAAM,SAAS;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAgBnB,CAAC;AAIJ,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAS1B,CAAC;AAIJ,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAS5B,CAAC;AAIJ,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAW/B,CAAC;AAIJ,eAAO,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAQjB,CAAC;AAIH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAwBlB,CAAC;AAIJ,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAW1B,CAAC"}
package/dist/schema.js ADDED
@@ -0,0 +1,128 @@
1
+ import { boolean, integer, jsonb, pgTable, text, timestamp, uuid, index, uniqueIndex } from "@porulle/core/drizzle";
2
+ // ─── Service Types ──────────────────────────────────────────────────────────
3
+ export const serviceTypes = pgTable("appointment_service_types", {
4
+ id: uuid("id").defaultRandom().primaryKey(),
5
+ organizationId: text("organization_id").notNull(),
6
+ name: text("name").notNull(),
7
+ slug: text("slug").notNull(),
8
+ description: text("description"),
9
+ durationMinutes: integer("duration_minutes").notNull().default(30),
10
+ priceCents: integer("price_cents").notNull().default(0),
11
+ currency: text("currency").notNull().default("USD"),
12
+ mode: text("mode").notNull().default("in_person"), // in_person | online | hybrid
13
+ bufferBeforeMinutes: integer("buffer_before_minutes").notNull().default(0),
14
+ bufferAfterMinutes: integer("buffer_after_minutes").notNull().default(0),
15
+ maxAdvanceDays: integer("max_advance_days").notNull().default(60),
16
+ minNoticeMins: integer("min_notice_mins").notNull().default(60),
17
+ isActive: boolean("is_active").notNull().default(true),
18
+ metadata: jsonb("metadata").$type().default({}),
19
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
20
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
21
+ }, (table) => ({
22
+ orgSlugUnique: uniqueIndex("apt_service_types_org_slug_unique").on(table.organizationId, table.slug),
23
+ orgIdx: index("idx_apt_service_types_org").on(table.organizationId),
24
+ slugIdx: index("idx_apt_service_types_slug").on(table.slug),
25
+ activeIdx: index("idx_apt_service_types_active").on(table.isActive),
26
+ }));
27
+ // ─── Providers ──────────────────────────────────────────────────────────────
28
+ export const providers = pgTable("appointment_providers", {
29
+ id: uuid("id").defaultRandom().primaryKey(),
30
+ organizationId: text("organization_id").notNull(),
31
+ userId: text("user_id"), // Better Auth user ID (nullable for admin-created providers)
32
+ name: text("name").notNull(),
33
+ bio: text("bio"),
34
+ email: text("email"),
35
+ phone: text("phone"),
36
+ timezone: text("timezone").notNull().default("UTC"),
37
+ isActive: boolean("is_active").notNull().default(true),
38
+ metadata: jsonb("metadata").$type().default({}),
39
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
40
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
41
+ }, (table) => ({
42
+ orgIdx: index("idx_apt_providers_org").on(table.organizationId),
43
+ activeIdx: index("idx_apt_providers_active").on(table.isActive),
44
+ }));
45
+ // ─── Provider–ServiceType Link ──────────────────────────────────────────────
46
+ export const providerServices = pgTable("appointment_provider_services", {
47
+ id: uuid("id").defaultRandom().primaryKey(),
48
+ providerId: uuid("provider_id").notNull(),
49
+ serviceTypeId: uuid("service_type_id").notNull(),
50
+ customDurationMinutes: integer("custom_duration_minutes"),
51
+ customPriceCents: integer("custom_price_cents"),
52
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
53
+ }, (table) => ({
54
+ providerServiceIdx: uniqueIndex("idx_provider_service").on(table.providerId, table.serviceTypeId),
55
+ }));
56
+ // ─── Weekly Availability ────────────────────────────────────────────────────
57
+ export const weeklyAvailability = pgTable("appointment_weekly_availability", {
58
+ id: uuid("id").defaultRandom().primaryKey(),
59
+ providerId: uuid("provider_id").notNull(),
60
+ dayOfWeek: integer("day_of_week").notNull(), // 0 = Sunday, 6 = Saturday
61
+ startTime: text("start_time").notNull(), // "09:00" (HH:mm in provider's timezone)
62
+ endTime: text("end_time").notNull(), // "17:00"
63
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
64
+ }, (table) => ({
65
+ providerDayIdx: index("idx_weekly_provider_day").on(table.providerId, table.dayOfWeek),
66
+ }));
67
+ // ─── Availability Overrides (date-level) ────────────────────────────────────
68
+ export const availabilityOverrides = pgTable("appointment_availability_overrides", {
69
+ id: uuid("id").defaultRandom().primaryKey(),
70
+ providerId: uuid("provider_id").notNull(),
71
+ date: text("date").notNull(), // "2026-03-25" (YYYY-MM-DD)
72
+ isAvailable: boolean("is_available").notNull().default(true),
73
+ startTime: text("start_time"), // null if isAvailable = false (day off)
74
+ endTime: text("end_time"),
75
+ reason: text("reason"),
76
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
77
+ }, (table) => ({
78
+ providerDateIdx: uniqueIndex("idx_override_provider_date").on(table.providerId, table.date),
79
+ }));
80
+ // ─── Breaks ─────────────────────────────────────────────────────────────────
81
+ export const breaks = pgTable("appointment_breaks", {
82
+ id: uuid("id").defaultRandom().primaryKey(),
83
+ providerId: uuid("provider_id").notNull(),
84
+ dayOfWeek: integer("day_of_week"), // null = every day
85
+ startTime: text("start_time").notNull(), // "12:00"
86
+ endTime: text("end_time").notNull(), // "13:00"
87
+ label: text("label"), // "Lunch"
88
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
89
+ });
90
+ // ─── Bookings ───────────────────────────────────────────────────────────────
91
+ export const bookings = pgTable("appointment_bookings", {
92
+ id: uuid("id").defaultRandom().primaryKey(),
93
+ providerId: uuid("provider_id").notNull(),
94
+ serviceTypeId: uuid("service_type_id").notNull(),
95
+ customerId: text("customer_id").notNull(),
96
+ customerName: text("customer_name").notNull(),
97
+ customerEmail: text("customer_email").notNull(),
98
+ customerPhone: text("customer_phone"),
99
+ startTime: timestamp("start_time", { withTimezone: true }).notNull(),
100
+ endTime: timestamp("end_time", { withTimezone: true }).notNull(),
101
+ status: text("status").notNull().default("provisional"),
102
+ // provisional → confirmed → completed | cancelled | no_show
103
+ paymentMethod: text("payment_method").notNull(), // "card" | "cash" | "invoice"
104
+ notes: text("notes"),
105
+ cancellationReason: text("cancellation_reason"),
106
+ rescheduledFromId: uuid("rescheduled_from_id"),
107
+ metadata: jsonb("metadata").$type().default({}),
108
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
109
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
110
+ }, (table) => ({
111
+ providerTimeIdx: index("idx_apt_bookings_provider_time").on(table.providerId, table.startTime),
112
+ customerIdx: index("idx_apt_bookings_customer").on(table.customerId),
113
+ statusIdx: index("idx_apt_bookings_status").on(table.status),
114
+ startTimeIdx: index("idx_apt_bookings_start_time").on(table.startTime),
115
+ }));
116
+ // ─── Booking Payments ───────────────────────────────────────────────────────
117
+ export const bookingPayments = pgTable("appointment_booking_payments", {
118
+ id: uuid("id").defaultRandom().primaryKey(),
119
+ bookingId: uuid("booking_id").notNull().unique(),
120
+ amountCents: integer("amount_cents").notNull(),
121
+ currency: text("currency").notNull(),
122
+ status: text("status").notNull().default("pending"), // pending | paid | refunded | failed
123
+ paymentIntentId: text("payment_intent_id"),
124
+ paidAt: timestamp("paid_at", { withTimezone: true }),
125
+ refundedAt: timestamp("refunded_at", { withTimezone: true }),
126
+ metadata: jsonb("metadata").$type().default({}),
127
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
128
+ });
@@ -0,0 +1,156 @@
1
+ import { bookings } from "../schema.js";
2
+ import { SlotService } from "./slot-service.js";
3
+ import type { Db, BookingStatus } from "../types.js";
4
+ import type { JobsAdapter } from "@porulle/core";
5
+ interface BookingInput {
6
+ providerId: string;
7
+ serviceTypeId: string;
8
+ customerId: string;
9
+ customerName: string;
10
+ customerEmail: string;
11
+ customerPhone?: string | undefined;
12
+ startTime: Date;
13
+ paymentMethod: "card" | "cash" | "invoice";
14
+ notes?: string | undefined;
15
+ metadata?: Record<string, unknown> | undefined;
16
+ }
17
+ interface PaymentAdapterLike {
18
+ createPaymentIntent(params: {
19
+ amount: number;
20
+ currency: string;
21
+ orderId: string;
22
+ customerId?: string | undefined;
23
+ metadata?: Record<string, string> | undefined;
24
+ }): Promise<{
25
+ ok: true;
26
+ value: {
27
+ id: string;
28
+ clientSecret?: string | null;
29
+ };
30
+ } | {
31
+ ok: false;
32
+ error: {
33
+ message: string;
34
+ };
35
+ }>;
36
+ refundPayment(paymentId: string, amount: number, reason?: string): Promise<{
37
+ ok: boolean;
38
+ }>;
39
+ }
40
+ export declare class BookingService {
41
+ private db;
42
+ private slotService;
43
+ private paymentAdapter?;
44
+ private jobs?;
45
+ private transitions;
46
+ constructor(db: Db, slotService: SlotService, paymentAdapter?: PaymentAdapterLike | undefined, jobs?: JobsAdapter | undefined);
47
+ private providerOrganizationId;
48
+ create(input: BookingInput, txDb?: Db): Promise<{
49
+ ok: true;
50
+ booking: typeof bookings.$inferSelect;
51
+ paymentIntent?: {
52
+ id: string;
53
+ clientSecret?: string | null;
54
+ } | undefined;
55
+ } | {
56
+ ok: false;
57
+ error: string;
58
+ code: string;
59
+ }>;
60
+ getById(id: string): Promise<{
61
+ id: string;
62
+ providerId: string;
63
+ serviceTypeId: string;
64
+ customerId: string;
65
+ customerName: string;
66
+ customerEmail: string;
67
+ customerPhone: string | null;
68
+ startTime: Date;
69
+ endTime: Date;
70
+ status: string;
71
+ paymentMethod: string;
72
+ notes: string | null;
73
+ cancellationReason: string | null;
74
+ rescheduledFromId: string | null;
75
+ metadata: Record<string, unknown> | null;
76
+ createdAt: Date;
77
+ updatedAt: Date;
78
+ } | null>;
79
+ listByCustomer(customerId: string): Promise<{
80
+ id: string;
81
+ providerId: string;
82
+ serviceTypeId: string;
83
+ customerId: string;
84
+ customerName: string;
85
+ customerEmail: string;
86
+ customerPhone: string | null;
87
+ startTime: Date;
88
+ endTime: Date;
89
+ status: string;
90
+ paymentMethod: string;
91
+ notes: string | null;
92
+ cancellationReason: string | null;
93
+ rescheduledFromId: string | null;
94
+ metadata: Record<string, unknown> | null;
95
+ createdAt: Date;
96
+ updatedAt: Date;
97
+ }[]>;
98
+ listByProvider(providerId: string, from?: Date, to?: Date): Promise<{
99
+ id: string;
100
+ providerId: string;
101
+ serviceTypeId: string;
102
+ customerId: string;
103
+ customerName: string;
104
+ customerEmail: string;
105
+ customerPhone: string | null;
106
+ startTime: Date;
107
+ endTime: Date;
108
+ status: string;
109
+ paymentMethod: string;
110
+ notes: string | null;
111
+ cancellationReason: string | null;
112
+ rescheduledFromId: string | null;
113
+ metadata: Record<string, unknown> | null;
114
+ createdAt: Date;
115
+ updatedAt: Date;
116
+ }[]>;
117
+ changeStatus(id: string, newStatus: BookingStatus, reason?: string): Promise<{
118
+ ok: true;
119
+ booking: typeof bookings.$inferSelect;
120
+ } | {
121
+ ok: false;
122
+ error: string;
123
+ }>;
124
+ cancel(id: string, reason?: string): Promise<{
125
+ ok: true;
126
+ booking: typeof bookings.$inferSelect;
127
+ refunded: boolean;
128
+ } | {
129
+ ok: false;
130
+ error: string;
131
+ }>;
132
+ reschedule(bookingId: string, newStartTime: Date, txDb?: Db): Promise<{
133
+ ok: true;
134
+ oldBooking: typeof bookings.$inferSelect;
135
+ newBooking: typeof bookings.$inferSelect;
136
+ } | {
137
+ ok: false;
138
+ error: string;
139
+ }>;
140
+ markNoShow(id: string): Promise<{
141
+ ok: true;
142
+ booking: typeof bookings.$inferSelect;
143
+ } | {
144
+ ok: false;
145
+ error: string;
146
+ }>;
147
+ complete(id: string): Promise<{
148
+ ok: true;
149
+ booking: typeof bookings.$inferSelect;
150
+ } | {
151
+ ok: false;
152
+ error: string;
153
+ }>;
154
+ }
155
+ export {};
156
+ //# sourceMappingURL=booking-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"booking-service.d.ts","sourceRoot":"","sources":["../../src/services/booking-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,249 @@
1
+ import { eq, and, gte, lte } from "@porulle/core/drizzle";
2
+ import { bookings, bookingPayments, serviceTypes, providers } from "../schema.js";
3
+ export class BookingService {
4
+ db;
5
+ slotService;
6
+ paymentAdapter;
7
+ jobs;
8
+ transitions = {
9
+ provisional: ["confirmed", "cancelled"],
10
+ confirmed: ["completed", "cancelled", "no_show"],
11
+ completed: [],
12
+ cancelled: [],
13
+ no_show: [],
14
+ };
15
+ constructor(db, slotService, paymentAdapter, jobs) {
16
+ this.db = db;
17
+ this.slotService = slotService;
18
+ this.paymentAdapter = paymentAdapter;
19
+ this.jobs = jobs;
20
+ }
21
+ async providerOrganizationId(providerId) {
22
+ const [row] = await this.db
23
+ .select({ organizationId: providers.organizationId })
24
+ .from(providers)
25
+ .where(eq(providers.id, providerId));
26
+ if (!row) {
27
+ throw new Error(`Appointment provider not found: ${providerId}`);
28
+ }
29
+ return row.organizationId;
30
+ }
31
+ async create(input, txDb) {
32
+ // Validate service type exists (outside transaction -- read-only, no lock needed)
33
+ const [service] = await this.db.select().from(serviceTypes).where(eq(serviceTypes.id, input.serviceTypeId));
34
+ if (!service)
35
+ return { ok: false, error: "Service type not found", code: "NOT_FOUND" };
36
+ // Validate provider exists
37
+ const [provider] = await this.db.select().from(providers).where(eq(providers.id, input.providerId));
38
+ if (!provider)
39
+ return { ok: false, error: "Provider not found", code: "NOT_FOUND" };
40
+ const durationMinutes = service.durationMinutes;
41
+ const endTime = new Date(input.startTime.getTime() + durationMinutes * 60_000);
42
+ const priceCents = service.priceCents;
43
+ // Create payment intent if card payment (outside transaction -- external API call)
44
+ let paymentIntentId;
45
+ let paymentIntent;
46
+ if (input.paymentMethod === "card" && this.paymentAdapter) {
47
+ const result = await this.paymentAdapter.createPaymentIntent({
48
+ amount: priceCents,
49
+ currency: service.currency,
50
+ orderId: `appointment-${Date.now()}`,
51
+ customerId: input.customerId,
52
+ metadata: { serviceTypeId: input.serviceTypeId, providerId: input.providerId },
53
+ });
54
+ if (!result.ok) {
55
+ return { ok: false, error: "Payment creation failed", code: "PAYMENT_ERROR" };
56
+ }
57
+ paymentIntentId = result.value.id;
58
+ paymentIntent = result.value;
59
+ }
60
+ // Conflict check + insert MUST be in a transaction for SELECT FOR UPDATE to hold the lock.
61
+ // Without a transaction, the row lock is released immediately after the SELECT.
62
+ const db = txDb ?? this.db;
63
+ const bookingOrError = await db.transaction(async (tx) => {
64
+ // Check for conflicts (SELECT FOR UPDATE holds lock until COMMIT)
65
+ const conflicting = await tx
66
+ .select()
67
+ .from(bookings)
68
+ .where(and(eq(bookings.providerId, input.providerId), lte(bookings.startTime, endTime), gte(bookings.endTime, input.startTime)))
69
+ .for("update");
70
+ const activeConflicts = conflicting.filter((b) => b.status !== "cancelled");
71
+ if (activeConflicts.length > 0) {
72
+ return { ok: false, error: "Time slot is already booked", code: "CONFLICT" };
73
+ }
74
+ const [booking] = await tx.insert(bookings).values({
75
+ providerId: input.providerId,
76
+ serviceTypeId: input.serviceTypeId,
77
+ customerId: input.customerId,
78
+ customerName: input.customerName,
79
+ customerEmail: input.customerEmail,
80
+ customerPhone: input.customerPhone,
81
+ startTime: input.startTime,
82
+ endTime,
83
+ status: "provisional",
84
+ paymentMethod: input.paymentMethod,
85
+ notes: input.notes,
86
+ metadata: input.metadata ?? {},
87
+ }).returning();
88
+ // Create payment record if card payment
89
+ if (paymentIntentId && booking) {
90
+ await tx.insert(bookingPayments).values({
91
+ bookingId: booking.id,
92
+ amountCents: priceCents,
93
+ currency: service.currency,
94
+ status: "pending",
95
+ paymentIntentId,
96
+ });
97
+ }
98
+ return { ok: true, booking: booking };
99
+ });
100
+ if (!bookingOrError.ok) {
101
+ return bookingOrError;
102
+ }
103
+ // Enqueue notification jobs (fire-and-forget, must not block the response)
104
+ if (this.jobs) {
105
+ const bk = bookingOrError.booking;
106
+ const organizationId = provider.organizationId;
107
+ const startMs = bk.startTime.getTime();
108
+ // 24h reminder
109
+ const r24 = startMs - 24 * 60 * 60_000 - Date.now();
110
+ if (r24 > 0) {
111
+ this.jobs.enqueue("appointment:reminder", { bookingId: bk.id, customerEmail: bk.customerEmail, reminderType: "24h" }, { organizationId, delayMs: r24 }).catch(() => { });
112
+ }
113
+ // 1h reminder
114
+ const r1 = startMs - 60 * 60_000 - Date.now();
115
+ if (r1 > 0) {
116
+ this.jobs.enqueue("appointment:reminder", { bookingId: bk.id, customerEmail: bk.customerEmail, reminderType: "1h" }, { organizationId, delayMs: r1 }).catch(() => { });
117
+ }
118
+ // Auto-cancel unpaid provisionals
119
+ if (bk.paymentMethod !== "card") {
120
+ const ac = startMs - 24 * 60 * 60_000 - Date.now();
121
+ if (ac > 0) {
122
+ this.jobs.enqueue("appointment:auto-cancel", { bookingId: bk.id, reason: "Unpaid provisional auto-cancelled" }, { organizationId, delayMs: ac }).catch(() => { });
123
+ }
124
+ }
125
+ }
126
+ const result = { ok: true, booking: bookingOrError.booking };
127
+ if (paymentIntent)
128
+ result.paymentIntent = paymentIntent;
129
+ return result;
130
+ }
131
+ async getById(id) {
132
+ const [row] = await this.db.select().from(bookings).where(eq(bookings.id, id));
133
+ return row ?? null;
134
+ }
135
+ async listByCustomer(customerId) {
136
+ return this.db.select().from(bookings)
137
+ .where(eq(bookings.customerId, customerId))
138
+ .orderBy(bookings.startTime);
139
+ }
140
+ async listByProvider(providerId, from, to) {
141
+ const conditions = [eq(bookings.providerId, providerId)];
142
+ if (from)
143
+ conditions.push(gte(bookings.startTime, from));
144
+ if (to)
145
+ conditions.push(lte(bookings.startTime, to));
146
+ return this.db.select().from(bookings)
147
+ .where(and(...conditions))
148
+ .orderBy(bookings.startTime);
149
+ }
150
+ async changeStatus(id, newStatus, reason) {
151
+ const booking = await this.getById(id);
152
+ if (!booking)
153
+ return { ok: false, error: "Booking not found" };
154
+ const allowed = this.transitions[booking.status];
155
+ if (!allowed || !allowed.includes(newStatus)) {
156
+ return { ok: false, error: `Cannot transition from ${booking.status} to ${newStatus}` };
157
+ }
158
+ const updates = {
159
+ status: newStatus,
160
+ updatedAt: new Date(),
161
+ };
162
+ if (newStatus === "cancelled" && reason) {
163
+ updates.cancellationReason = reason;
164
+ }
165
+ const [updated] = await this.db.update(bookings)
166
+ .set(updates)
167
+ .where(eq(bookings.id, id))
168
+ .returning();
169
+ // Enqueue status-specific notifications
170
+ if (this.jobs && updated) {
171
+ const organizationId = await this.providerOrganizationId(updated.providerId);
172
+ if (newStatus === "confirmed") {
173
+ this.jobs.enqueue("appointment:confirmation-notice", { bookingId: id, customerEmail: updated.customerEmail, providerId: updated.providerId }, { organizationId }).catch(() => { });
174
+ }
175
+ else if (newStatus === "no_show") {
176
+ this.jobs.enqueue("appointment:no-show-notice", { bookingId: id, customerEmail: updated.customerEmail }, { organizationId }).catch(() => { });
177
+ }
178
+ }
179
+ return { ok: true, booking: updated };
180
+ }
181
+ async cancel(id, reason) {
182
+ const result = await this.changeStatus(id, "cancelled", reason);
183
+ if (!result.ok)
184
+ return result;
185
+ let refunded = false;
186
+ // Check for payment record and refund if paid
187
+ const [payment] = await this.db.select().from(bookingPayments)
188
+ .where(eq(bookingPayments.bookingId, id));
189
+ if (payment?.paymentIntentId && this.paymentAdapter) {
190
+ await this.paymentAdapter.refundPayment(payment.paymentIntentId, payment.amountCents, reason ?? "Booking cancelled");
191
+ await this.db.update(bookingPayments)
192
+ .set({ status: "refunded", refundedAt: new Date() })
193
+ .where(eq(bookingPayments.id, payment.id));
194
+ refunded = true;
195
+ }
196
+ // Enqueue cancellation notice
197
+ if (this.jobs) {
198
+ const organizationId = await this.providerOrganizationId(result.booking.providerId);
199
+ this.jobs.enqueue("appointment:cancellation-notice", { bookingId: id, customerEmail: result.booking.customerEmail }, { organizationId }).catch(() => { });
200
+ }
201
+ return { ok: true, booking: result.booking, refunded };
202
+ }
203
+ async reschedule(bookingId, newStartTime, txDb) {
204
+ const db = txDb ?? this.db;
205
+ const existing = await this.getById(bookingId);
206
+ if (!existing)
207
+ return { ok: false, error: "Booking not found" };
208
+ if (existing.status === "cancelled" || existing.status === "completed" || existing.status === "no_show") {
209
+ return { ok: false, error: `Cannot reschedule a ${existing.status} booking` };
210
+ }
211
+ // Cancel old booking
212
+ const cancelResult = await this.changeStatus(bookingId, "cancelled", "Rescheduled");
213
+ if (!cancelResult.ok)
214
+ return cancelResult;
215
+ // Create new booking
216
+ const createResult = await this.create({
217
+ providerId: existing.providerId,
218
+ serviceTypeId: existing.serviceTypeId,
219
+ customerId: existing.customerId,
220
+ customerName: existing.customerName,
221
+ customerEmail: existing.customerEmail,
222
+ customerPhone: existing.customerPhone ?? undefined,
223
+ startTime: newStartTime,
224
+ paymentMethod: existing.paymentMethod,
225
+ notes: existing.notes ?? undefined,
226
+ metadata: { ...(existing.metadata ?? {}), rescheduledFromId: bookingId },
227
+ }, db);
228
+ if (!createResult.ok) {
229
+ return { ok: false, error: createResult.error };
230
+ }
231
+ // Update the new booking to reference the old one
232
+ await db.update(bookings)
233
+ .set({ rescheduledFromId: bookingId })
234
+ .where(eq(bookings.id, createResult.booking.id));
235
+ return { ok: true, oldBooking: cancelResult.booking, newBooking: createResult.booking };
236
+ }
237
+ async markNoShow(id) {
238
+ return this.changeStatus(id, "no_show");
239
+ }
240
+ async complete(id) {
241
+ return this.changeStatus(id, "confirmed").then((r) => {
242
+ if (!r.ok) {
243
+ // Try direct to completed if already confirmed
244
+ return this.changeStatus(id, "completed");
245
+ }
246
+ return this.changeStatus(id, "completed");
247
+ });
248
+ }
249
+ }