@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,141 @@
|
|
|
1
|
+
import { router } from "@porulle/core";
|
|
2
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
3
|
+
import { z } from "@hono/zod-openapi";
|
|
4
|
+
import type { BookingService } from "../services/booking-service.js";
|
|
5
|
+
import type { ProviderService } from "../services/provider-service.js";
|
|
6
|
+
import type { BookingStatus } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const CreateBookingSchema = z.object({
|
|
9
|
+
providerId: z.string().uuid(),
|
|
10
|
+
serviceTypeId: z.string().uuid(),
|
|
11
|
+
startTime: z.string().datetime().openapi({ example: "2026-03-20T09:00:00Z" }),
|
|
12
|
+
customerName: z.string().min(1).openapi({ example: "John Doe" }),
|
|
13
|
+
customerEmail: z.string().email().openapi({ example: "john@example.com" }),
|
|
14
|
+
customerPhone: z.string().optional(),
|
|
15
|
+
paymentMethod: z.enum(["card", "cash", "invoice"]).openapi({ example: "cash" }),
|
|
16
|
+
notes: z.string().optional(),
|
|
17
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
18
|
+
}).openapi("CreateBookingRequest");
|
|
19
|
+
|
|
20
|
+
const RescheduleBookingSchema = z.object({
|
|
21
|
+
newStartTime: z.string().datetime().openapi({ example: "2026-03-21T10:00:00Z" }),
|
|
22
|
+
}).openapi("RescheduleBookingRequest");
|
|
23
|
+
|
|
24
|
+
const CancelBookingSchema = z.object({
|
|
25
|
+
reason: z.string().optional(),
|
|
26
|
+
}).openapi("CancelBookingRequest");
|
|
27
|
+
|
|
28
|
+
export function buildBookingRoutes(services: {
|
|
29
|
+
booking: BookingService;
|
|
30
|
+
provider: ProviderService;
|
|
31
|
+
}): PluginRouteRegistration[] {
|
|
32
|
+
const r = router("Appointments - Bookings", "/appointments/bookings");
|
|
33
|
+
|
|
34
|
+
r.get("/")
|
|
35
|
+
.summary("List bookings")
|
|
36
|
+
.permission("appointments:manage")
|
|
37
|
+
.query(z.object({
|
|
38
|
+
providerId: z.string().uuid().optional(),
|
|
39
|
+
from: z.string().datetime().optional(),
|
|
40
|
+
to: z.string().datetime().optional(),
|
|
41
|
+
}))
|
|
42
|
+
.handler(async ({ query }) => {
|
|
43
|
+
const providerId = query.providerId as string | undefined;
|
|
44
|
+
if (!providerId) throw new Error("providerId is required");
|
|
45
|
+
return services.booking.listByProvider(
|
|
46
|
+
providerId,
|
|
47
|
+
query.from ? new Date(query.from as string) : undefined,
|
|
48
|
+
query.to ? new Date(query.to as string) : undefined,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
r.post("/")
|
|
53
|
+
.summary("Create booking")
|
|
54
|
+
.auth()
|
|
55
|
+
.input(CreateBookingSchema)
|
|
56
|
+
.handler(async ({ input, actor }) => {
|
|
57
|
+
const body = input as z.infer<typeof CreateBookingSchema>;
|
|
58
|
+
const result = await services.booking.create({
|
|
59
|
+
providerId: body.providerId,
|
|
60
|
+
serviceTypeId: body.serviceTypeId,
|
|
61
|
+
customerId: actor!.userId,
|
|
62
|
+
customerName: body.customerName,
|
|
63
|
+
customerEmail: body.customerEmail,
|
|
64
|
+
customerPhone: body.customerPhone,
|
|
65
|
+
startTime: new Date(body.startTime),
|
|
66
|
+
paymentMethod: body.paymentMethod,
|
|
67
|
+
notes: body.notes,
|
|
68
|
+
metadata: body.metadata,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!result.ok) {
|
|
72
|
+
const status = result.code === "CONFLICT" ? 409 : result.code === "NOT_FOUND" ? 404 : 400;
|
|
73
|
+
throw Object.assign(new Error(result.error), { statusCode: status });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
booking: result.booking,
|
|
78
|
+
...(result.paymentIntent ? { paymentIntent: result.paymentIntent } : {}),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
r.get("/{id}")
|
|
83
|
+
.summary("Get booking")
|
|
84
|
+
.permission("appointments:manage")
|
|
85
|
+
.handler(async ({ params }) => {
|
|
86
|
+
const booking = await services.booking.getById(params.id!);
|
|
87
|
+
if (!booking) throw new Error("Booking not found");
|
|
88
|
+
return booking;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
r.post("/{id}/confirm")
|
|
92
|
+
.summary("Confirm booking")
|
|
93
|
+
.permission("appointments:manage")
|
|
94
|
+
.handler(async ({ params }) => {
|
|
95
|
+
const result = await services.booking.changeStatus(params.id!, "confirmed");
|
|
96
|
+
if (!result.ok) throw new Error(result.error);
|
|
97
|
+
return result.booking;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
r.post("/{id}/complete")
|
|
101
|
+
.summary("Complete appointment")
|
|
102
|
+
.permission("appointments:manage")
|
|
103
|
+
.handler(async ({ params }) => {
|
|
104
|
+
const result = await services.booking.complete(params.id!);
|
|
105
|
+
if (!result.ok) throw new Error(result.error);
|
|
106
|
+
return result.booking;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
r.post("/{id}/no-show")
|
|
110
|
+
.summary("Mark as no-show")
|
|
111
|
+
.permission("appointments:manage")
|
|
112
|
+
.handler(async ({ params }) => {
|
|
113
|
+
const result = await services.booking.markNoShow(params.id!);
|
|
114
|
+
if (!result.ok) throw new Error(result.error);
|
|
115
|
+
return result.booking;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
r.post("/{id}/cancel")
|
|
119
|
+
.summary("Cancel booking")
|
|
120
|
+
.auth()
|
|
121
|
+
.input(CancelBookingSchema)
|
|
122
|
+
.handler(async ({ params, input }) => {
|
|
123
|
+
const body = input as z.infer<typeof CancelBookingSchema>;
|
|
124
|
+
const result = await services.booking.cancel(params.id!, body.reason);
|
|
125
|
+
if (!result.ok) throw new Error(result.error);
|
|
126
|
+
return { booking: result.booking, refunded: result.refunded };
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
r.post("/{id}/reschedule")
|
|
130
|
+
.summary("Reschedule booking")
|
|
131
|
+
.auth()
|
|
132
|
+
.input(RescheduleBookingSchema)
|
|
133
|
+
.handler(async ({ params, input }) => {
|
|
134
|
+
const body = input as z.infer<typeof RescheduleBookingSchema>;
|
|
135
|
+
const result = await services.booking.reschedule(params.id!, new Date(body.newStartTime));
|
|
136
|
+
if (!result.ok) throw new Error(result.error);
|
|
137
|
+
return { oldBooking: result.oldBooking, newBooking: result.newBooking };
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return r.routes();
|
|
141
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { router } from "@porulle/core";
|
|
2
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
3
|
+
import type { BookingService } from "../services/booking-service.js";
|
|
4
|
+
|
|
5
|
+
export function buildMyBookingRoutes(services: {
|
|
6
|
+
booking: BookingService;
|
|
7
|
+
}): PluginRouteRegistration[] {
|
|
8
|
+
const r = router("Appointments - My Bookings", "/appointments/my-bookings");
|
|
9
|
+
|
|
10
|
+
r.get("/")
|
|
11
|
+
.summary("List my bookings")
|
|
12
|
+
.auth()
|
|
13
|
+
.handler(async ({ actor }) => {
|
|
14
|
+
return services.booking.listByCustomer(actor!.userId);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
r.get("/{id}")
|
|
18
|
+
.summary("Get my booking")
|
|
19
|
+
.auth()
|
|
20
|
+
.handler(async ({ params, actor }) => {
|
|
21
|
+
const booking = await services.booking.getById(params.id!);
|
|
22
|
+
if (!booking) throw new Error("Booking not found");
|
|
23
|
+
if (booking.customerId !== actor!.userId) throw new Error("Booking not found");
|
|
24
|
+
return booking;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return r.routes();
|
|
28
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { router, resolveOrgId } from "@porulle/core";
|
|
2
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
3
|
+
import { z } from "@hono/zod-openapi";
|
|
4
|
+
import type { ProviderService } from "../services/provider-service.js";
|
|
5
|
+
import { stripUndefined } from "./util.js";
|
|
6
|
+
|
|
7
|
+
const CreateProviderSchema = z.object({
|
|
8
|
+
name: z.string().min(1).openapi({ example: "Jane Smith" }),
|
|
9
|
+
email: z.string().email().optional(),
|
|
10
|
+
phone: z.string().optional(),
|
|
11
|
+
timezone: z.string().optional().openapi({ example: "Asia/Colombo" }),
|
|
12
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
13
|
+
}).openapi("CreateProviderRequest");
|
|
14
|
+
|
|
15
|
+
const UpdateProviderSchema = z.object({
|
|
16
|
+
name: z.string().optional(),
|
|
17
|
+
email: z.string().email().optional(),
|
|
18
|
+
phone: z.string().optional(),
|
|
19
|
+
timezone: z.string().optional(),
|
|
20
|
+
isActive: z.boolean().optional(),
|
|
21
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
22
|
+
}).openapi("UpdateProviderRequest");
|
|
23
|
+
|
|
24
|
+
export function buildProviderRoutes(services: {
|
|
25
|
+
provider: ProviderService;
|
|
26
|
+
}): PluginRouteRegistration[] {
|
|
27
|
+
const r = router("Appointments - Providers", "/appointments/providers");
|
|
28
|
+
|
|
29
|
+
r.get("/")
|
|
30
|
+
.summary("List providers")
|
|
31
|
+
.handler(async () => {
|
|
32
|
+
return services.provider.listProviders();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
r.post("/")
|
|
36
|
+
.summary("Create provider")
|
|
37
|
+
.permission("appointments:admin")
|
|
38
|
+
.input(CreateProviderSchema)
|
|
39
|
+
.handler(async ({ input, actor }) => {
|
|
40
|
+
const body = input as z.infer<typeof CreateProviderSchema>;
|
|
41
|
+
return services.provider.createProvider(resolveOrgId(actor), stripUndefined(body));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
r.get("/{id}")
|
|
45
|
+
.summary("Get provider")
|
|
46
|
+
.handler(async ({ params }) => {
|
|
47
|
+
const provider = await services.provider.getProvider(params.id!);
|
|
48
|
+
if (!provider) throw new Error("Provider not found");
|
|
49
|
+
return provider;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
r.patch("/{id}")
|
|
53
|
+
.summary("Update provider")
|
|
54
|
+
.permission("appointments:admin")
|
|
55
|
+
.input(UpdateProviderSchema)
|
|
56
|
+
.handler(async ({ params, input }) => {
|
|
57
|
+
const body = input as z.infer<typeof UpdateProviderSchema>;
|
|
58
|
+
const updated = await services.provider.updateProvider(params.id!, stripUndefined(body));
|
|
59
|
+
if (!updated) throw new Error("Provider not found");
|
|
60
|
+
return updated;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
r.delete("/{id}")
|
|
64
|
+
.summary("Delete provider")
|
|
65
|
+
.permission("appointments:admin")
|
|
66
|
+
.handler(async ({ params }) => {
|
|
67
|
+
const deleted = await services.provider.deleteProvider(params.id!);
|
|
68
|
+
if (!deleted) throw new Error("Provider not found");
|
|
69
|
+
return deleted;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ─── Provider ↔ Service Links ───────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
r.post("/{id}/services")
|
|
75
|
+
.summary("Link service type to provider")
|
|
76
|
+
.permission("appointments:admin")
|
|
77
|
+
.input(z.object({
|
|
78
|
+
serviceTypeId: z.string().uuid().openapi({ example: "uuid" }),
|
|
79
|
+
customDurationMinutes: z.number().int().min(5).optional(),
|
|
80
|
+
customPriceCents: z.number().int().min(0).optional(),
|
|
81
|
+
}).openapi("LinkProviderServiceRequest"))
|
|
82
|
+
.handler(async ({ params, input }) => {
|
|
83
|
+
const body = input as { serviceTypeId: string; customDurationMinutes?: number; customPriceCents?: number };
|
|
84
|
+
return services.provider.linkServiceType(params.id!, body.serviceTypeId, stripUndefined({
|
|
85
|
+
customDurationMinutes: body.customDurationMinutes,
|
|
86
|
+
customPriceCents: body.customPriceCents,
|
|
87
|
+
}));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
r.get("/{id}/services")
|
|
91
|
+
.summary("List provider's service types")
|
|
92
|
+
.handler(async ({ params }) => {
|
|
93
|
+
return services.provider.getProviderServices(params.id!);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return r.routes();
|
|
97
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { router, resolveOrgId } from "@porulle/core";
|
|
2
|
+
import type { PluginRouteRegistration } from "@porulle/core";
|
|
3
|
+
import { z } from "@hono/zod-openapi";
|
|
4
|
+
import type { ProviderService } from "../services/provider-service.js";
|
|
5
|
+
import { stripUndefined } from "./util.js";
|
|
6
|
+
|
|
7
|
+
const CreateServiceTypeSchema = z.object({
|
|
8
|
+
name: z.string().min(1).openapi({ example: "Haircut" }),
|
|
9
|
+
slug: z.string().min(1).openapi({ example: "haircut" }),
|
|
10
|
+
description: z.string().optional(),
|
|
11
|
+
durationMinutes: z.number().int().min(5).optional().openapi({ example: 30 }),
|
|
12
|
+
priceCents: z.number().int().min(0).optional().openapi({ example: 3000 }),
|
|
13
|
+
currency: z.string().optional().openapi({ example: "USD" }),
|
|
14
|
+
bufferBeforeMinutes: z.number().int().min(0).optional(),
|
|
15
|
+
bufferAfterMinutes: z.number().int().min(0).optional(),
|
|
16
|
+
}).openapi("CreateServiceTypeRequest");
|
|
17
|
+
|
|
18
|
+
const UpdateServiceTypeSchema = z.object({
|
|
19
|
+
name: z.string().optional(),
|
|
20
|
+
description: z.string().optional(),
|
|
21
|
+
durationMinutes: z.number().int().min(5).optional(),
|
|
22
|
+
priceCents: z.number().int().min(0).optional(),
|
|
23
|
+
currency: z.string().optional(),
|
|
24
|
+
bufferBeforeMinutes: z.number().int().min(0).optional(),
|
|
25
|
+
bufferAfterMinutes: z.number().int().min(0).optional(),
|
|
26
|
+
isActive: z.boolean().optional(),
|
|
27
|
+
}).openapi("UpdateServiceTypeRequest");
|
|
28
|
+
|
|
29
|
+
export function buildServiceRoutes(services: {
|
|
30
|
+
provider: ProviderService;
|
|
31
|
+
}): PluginRouteRegistration[] {
|
|
32
|
+
const r = router("Appointments - Services", "/appointments/services");
|
|
33
|
+
|
|
34
|
+
r.get("/")
|
|
35
|
+
.summary("List service types")
|
|
36
|
+
.handler(async () => {
|
|
37
|
+
return services.provider.listServiceTypes();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
r.post("/")
|
|
41
|
+
.summary("Create service type")
|
|
42
|
+
.permission("appointments:admin")
|
|
43
|
+
.input(CreateServiceTypeSchema)
|
|
44
|
+
.handler(async ({ input, actor }) => {
|
|
45
|
+
const body = input as z.infer<typeof CreateServiceTypeSchema>;
|
|
46
|
+
return services.provider.createServiceType(resolveOrgId(actor), stripUndefined(body));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
r.get("/{id}")
|
|
50
|
+
.summary("Get service type")
|
|
51
|
+
.handler(async ({ params }) => {
|
|
52
|
+
const svc = await services.provider.getServiceType(params.id!);
|
|
53
|
+
if (!svc) throw new Error("Service type not found");
|
|
54
|
+
return svc;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
r.patch("/{id}")
|
|
58
|
+
.summary("Update service type")
|
|
59
|
+
.permission("appointments:admin")
|
|
60
|
+
.input(UpdateServiceTypeSchema)
|
|
61
|
+
.handler(async ({ params, input }) => {
|
|
62
|
+
const body = input as z.infer<typeof UpdateServiceTypeSchema>;
|
|
63
|
+
const updated = await services.provider.updateServiceType(params.id!, stripUndefined(body));
|
|
64
|
+
if (!updated) throw new Error("Service type not found");
|
|
65
|
+
return updated;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
r.delete("/{id}")
|
|
69
|
+
.summary("Delete service type")
|
|
70
|
+
.permission("appointments:admin")
|
|
71
|
+
.handler(async ({ params }) => {
|
|
72
|
+
const deleted = await services.provider.deleteServiceType(params.id!);
|
|
73
|
+
if (!deleted) throw new Error("Service type not found");
|
|
74
|
+
return deleted;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return r.routes();
|
|
78
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
2
|
+
export function stripUndefined<T>(obj: T): T extends Record<string, any> ? { [K in keyof T]: Exclude<T[K], undefined> } : T {
|
|
3
|
+
if (obj == null || typeof obj !== "object") return obj as never;
|
|
4
|
+
const result: Record<string, unknown> = {};
|
|
5
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
6
|
+
if (value !== undefined) {
|
|
7
|
+
result[key] = value;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
return result as never;
|
|
11
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { boolean, integer, jsonb, pgTable, text, timestamp, uuid, index, uniqueIndex } from "@porulle/core/drizzle";
|
|
2
|
+
|
|
3
|
+
// ─── Service Types ──────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export const serviceTypes = pgTable("appointment_service_types", {
|
|
6
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
7
|
+
organizationId: text("organization_id").notNull(),
|
|
8
|
+
name: text("name").notNull(),
|
|
9
|
+
slug: text("slug").notNull(),
|
|
10
|
+
description: text("description"),
|
|
11
|
+
durationMinutes: integer("duration_minutes").notNull().default(30),
|
|
12
|
+
priceCents: integer("price_cents").notNull().default(0),
|
|
13
|
+
currency: text("currency").notNull().default("USD"),
|
|
14
|
+
mode: text("mode").notNull().default("in_person"), // in_person | online | hybrid
|
|
15
|
+
bufferBeforeMinutes: integer("buffer_before_minutes").notNull().default(0),
|
|
16
|
+
bufferAfterMinutes: integer("buffer_after_minutes").notNull().default(0),
|
|
17
|
+
maxAdvanceDays: integer("max_advance_days").notNull().default(60),
|
|
18
|
+
minNoticeMins: integer("min_notice_mins").notNull().default(60),
|
|
19
|
+
isActive: boolean("is_active").notNull().default(true),
|
|
20
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
21
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
22
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
23
|
+
}, (table) => ({
|
|
24
|
+
orgSlugUnique: uniqueIndex("apt_service_types_org_slug_unique").on(table.organizationId, table.slug),
|
|
25
|
+
orgIdx: index("idx_apt_service_types_org").on(table.organizationId),
|
|
26
|
+
slugIdx: index("idx_apt_service_types_slug").on(table.slug),
|
|
27
|
+
activeIdx: index("idx_apt_service_types_active").on(table.isActive),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// ─── Providers ──────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export const providers = pgTable("appointment_providers", {
|
|
33
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
34
|
+
organizationId: text("organization_id").notNull(),
|
|
35
|
+
userId: text("user_id"), // Better Auth user ID (nullable for admin-created providers)
|
|
36
|
+
name: text("name").notNull(),
|
|
37
|
+
bio: text("bio"),
|
|
38
|
+
email: text("email"),
|
|
39
|
+
phone: text("phone"),
|
|
40
|
+
timezone: text("timezone").notNull().default("UTC"),
|
|
41
|
+
isActive: boolean("is_active").notNull().default(true),
|
|
42
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
43
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
44
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
45
|
+
}, (table) => ({
|
|
46
|
+
orgIdx: index("idx_apt_providers_org").on(table.organizationId),
|
|
47
|
+
activeIdx: index("idx_apt_providers_active").on(table.isActive),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// ─── Provider–ServiceType Link ──────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export const providerServices = pgTable("appointment_provider_services", {
|
|
53
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
54
|
+
providerId: uuid("provider_id").notNull(),
|
|
55
|
+
serviceTypeId: uuid("service_type_id").notNull(),
|
|
56
|
+
customDurationMinutes: integer("custom_duration_minutes"),
|
|
57
|
+
customPriceCents: integer("custom_price_cents"),
|
|
58
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
59
|
+
}, (table) => ({
|
|
60
|
+
providerServiceIdx: uniqueIndex("idx_provider_service").on(table.providerId, table.serviceTypeId),
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
// ─── Weekly Availability ────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export const weeklyAvailability = pgTable("appointment_weekly_availability", {
|
|
66
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
67
|
+
providerId: uuid("provider_id").notNull(),
|
|
68
|
+
dayOfWeek: integer("day_of_week").notNull(), // 0 = Sunday, 6 = Saturday
|
|
69
|
+
startTime: text("start_time").notNull(), // "09:00" (HH:mm in provider's timezone)
|
|
70
|
+
endTime: text("end_time").notNull(), // "17:00"
|
|
71
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
72
|
+
}, (table) => ({
|
|
73
|
+
providerDayIdx: index("idx_weekly_provider_day").on(table.providerId, table.dayOfWeek),
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
// ─── Availability Overrides (date-level) ────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export const availabilityOverrides = pgTable("appointment_availability_overrides", {
|
|
79
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
80
|
+
providerId: uuid("provider_id").notNull(),
|
|
81
|
+
date: text("date").notNull(), // "2026-03-25" (YYYY-MM-DD)
|
|
82
|
+
isAvailable: boolean("is_available").notNull().default(true),
|
|
83
|
+
startTime: text("start_time"), // null if isAvailable = false (day off)
|
|
84
|
+
endTime: text("end_time"),
|
|
85
|
+
reason: text("reason"),
|
|
86
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
87
|
+
}, (table) => ({
|
|
88
|
+
providerDateIdx: uniqueIndex("idx_override_provider_date").on(table.providerId, table.date),
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
// ─── Breaks ─────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export const breaks = pgTable("appointment_breaks", {
|
|
94
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
95
|
+
providerId: uuid("provider_id").notNull(),
|
|
96
|
+
dayOfWeek: integer("day_of_week"), // null = every day
|
|
97
|
+
startTime: text("start_time").notNull(), // "12:00"
|
|
98
|
+
endTime: text("end_time").notNull(), // "13:00"
|
|
99
|
+
label: text("label"), // "Lunch"
|
|
100
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ─── Bookings ───────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export const bookings = pgTable("appointment_bookings", {
|
|
106
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
107
|
+
providerId: uuid("provider_id").notNull(),
|
|
108
|
+
serviceTypeId: uuid("service_type_id").notNull(),
|
|
109
|
+
customerId: text("customer_id").notNull(),
|
|
110
|
+
customerName: text("customer_name").notNull(),
|
|
111
|
+
customerEmail: text("customer_email").notNull(),
|
|
112
|
+
customerPhone: text("customer_phone"),
|
|
113
|
+
startTime: timestamp("start_time", { withTimezone: true }).notNull(),
|
|
114
|
+
endTime: timestamp("end_time", { withTimezone: true }).notNull(),
|
|
115
|
+
status: text("status").notNull().default("provisional"),
|
|
116
|
+
// provisional → confirmed → completed | cancelled | no_show
|
|
117
|
+
paymentMethod: text("payment_method").notNull(), // "card" | "cash" | "invoice"
|
|
118
|
+
notes: text("notes"),
|
|
119
|
+
cancellationReason: text("cancellation_reason"),
|
|
120
|
+
rescheduledFromId: uuid("rescheduled_from_id"),
|
|
121
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
122
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
123
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
|
124
|
+
}, (table) => ({
|
|
125
|
+
providerTimeIdx: index("idx_apt_bookings_provider_time").on(table.providerId, table.startTime),
|
|
126
|
+
customerIdx: index("idx_apt_bookings_customer").on(table.customerId),
|
|
127
|
+
statusIdx: index("idx_apt_bookings_status").on(table.status),
|
|
128
|
+
startTimeIdx: index("idx_apt_bookings_start_time").on(table.startTime),
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
// ─── Booking Payments ───────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
export const bookingPayments = pgTable("appointment_booking_payments", {
|
|
134
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
135
|
+
bookingId: uuid("booking_id").notNull().unique(),
|
|
136
|
+
amountCents: integer("amount_cents").notNull(),
|
|
137
|
+
currency: text("currency").notNull(),
|
|
138
|
+
status: text("status").notNull().default("pending"), // pending | paid | refunded | failed
|
|
139
|
+
paymentIntentId: text("payment_intent_id"),
|
|
140
|
+
paidAt: timestamp("paid_at", { withTimezone: true }),
|
|
141
|
+
refundedAt: timestamp("refunded_at", { withTimezone: true }),
|
|
142
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
143
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
|
144
|
+
});
|