gorombo-payload-appointments 1.0.1 → 2.0.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.
@@ -1,12 +1,6 @@
1
1
  import type { PayloadHandler } from 'payload';
2
2
  /**
3
- * GET /api/appointments/available-slots
4
- *
5
- * Query params:
6
- * - date: YYYY-MM-DD (required)
7
- * - serviceId: string (required)
8
- * - teamMemberId: string (optional)
9
- *
10
- * Returns available time slots for booking
3
+ * Factory function to create the available slots handler with configurable team collection
4
+ * @param teamCollectionSlug - The slug of the external team collection to use
11
5
  */
12
- export declare const getAvailableSlotsHandler: PayloadHandler;
6
+ export declare const createGetAvailableSlotsHandler: (teamCollectionSlug: string) => PayloadHandler;
@@ -1,291 +1,286 @@
1
1
  /**
2
- * GET /api/appointments/available-slots
3
- *
4
- * Query params:
5
- * - date: YYYY-MM-DD (required)
6
- * - serviceId: string (required)
7
- * - teamMemberId: string (optional)
8
- *
9
- * Returns available time slots for booking
10
- */ export const getAvailableSlotsHandler = async (req)=>{
11
- try {
12
- const url = new URL(req.url || '', 'http://localhost');
13
- const dateParam = url.searchParams.get('date');
14
- const serviceId = url.searchParams.get('serviceId');
15
- const teamMemberId = url.searchParams.get('teamMemberId');
16
- // Validate required params
17
- if (!dateParam) {
18
- return Response.json({
19
- error: 'Missing required parameter: date (YYYY-MM-DD format)'
20
- }, {
21
- status: 400
22
- });
23
- }
24
- if (!serviceId) {
25
- return Response.json({
26
- error: 'Missing required parameter: serviceId'
27
- }, {
28
- status: 400
29
- });
30
- }
31
- // Parse date
32
- const requestedDate = new Date(dateParam);
33
- if (isNaN(requestedDate.getTime())) {
34
- return Response.json({
35
- error: 'Invalid date format. Use YYYY-MM-DD'
36
- }, {
37
- status: 400
38
- });
39
- }
40
- // Get service details
41
- const service = await req.payload.findByID({
42
- id: serviceId,
43
- collection: 'services'
44
- });
45
- if (!service) {
46
- return Response.json({
47
- error: 'Service not found'
48
- }, {
49
- status: 404
50
- });
51
- }
52
- if (!service.isActive) {
53
- return Response.json({
54
- error: 'Service is not available for booking'
55
- }, {
56
- status: 400
57
- });
58
- }
59
- // Get opening times settings
60
- const openingTimes = await req.payload.findGlobal({
61
- slug: 'opening-times'
62
- });
63
- // Check booking window
64
- const now = new Date();
65
- const maxBookingDate = new Date(now);
66
- maxBookingDate.setDate(maxBookingDate.getDate() + (openingTimes?.maxAdvanceBookingDays || 30));
67
- const minBookingDate = new Date(now);
68
- minBookingDate.setHours(minBookingDate.getHours() + (openingTimes?.minAdvanceBookingHours || 1));
69
- if (requestedDate > maxBookingDate) {
70
- return Response.json({
71
- error: `Cannot book more than ${openingTimes?.maxAdvanceBookingDays || 30} days in advance`
72
- }, {
73
- status: 400
74
- });
75
- }
76
- if (requestedDate < minBookingDate) {
77
- return Response.json({
78
- error: `Must book at least ${openingTimes?.minAdvanceBookingHours || 1} hours in advance`
79
- }, {
80
- status: 400
81
- });
82
- }
83
- // Get day of week
84
- const dayNames = [
85
- 'sunday',
86
- 'monday',
87
- 'tuesday',
88
- 'wednesday',
89
- 'thursday',
90
- 'friday',
91
- 'saturday'
92
- ];
93
- const dayOfWeek = dayNames[requestedDate.getDay()];
94
- // Find business hours for this day
95
- const daySchedule = openingTimes?.schedule?.find((s)=>s.day === dayOfWeek);
96
- if (!daySchedule || !daySchedule.isOpen) {
97
- return Response.json({
98
- date: dateParam,
99
- message: 'Business is closed on this day',
100
- serviceId,
101
- slots: [],
102
- teamMemberId: teamMemberId || undefined
103
- });
104
- }
105
- // Calculate slot duration (service duration + buffer)
106
- const slotDuration = service.duration || 30;
107
- const bufferBefore = service.bufferBefore || 0;
108
- const bufferAfter = service.bufferAfter || 0;
109
- const totalSlotTime = slotDuration + bufferBefore + bufferAfter;
110
- // Parse business hours
111
- const [openHour, openMin] = (daySchedule.openTime || '09:00').split(':').map(Number);
112
- const [closeHour, closeMin] = (daySchedule.closeTime || '17:00').split(':').map(Number);
113
- // Parse break times if set
114
- let breakStart = null;
115
- let breakEnd = null;
116
- if (daySchedule.breakStart && daySchedule.breakEnd) {
117
- const [breakStartHour, breakStartMin] = daySchedule.breakStart.split(':').map(Number);
118
- const [breakEndHour, breakEndMin] = daySchedule.breakEnd.split(':').map(Number);
119
- breakStart = breakStartHour * 60 + breakStartMin;
120
- breakEnd = breakEndHour * 60 + breakEndMin;
121
- }
122
- // Get team member availability if specified
123
- let teamMemberSchedule = null;
124
- if (teamMemberId) {
125
- const teamMember = await req.payload.findByID({
126
- id: teamMemberId,
127
- collection: 'team-members'
2
+ * Factory function to create the available slots handler with configurable team collection
3
+ * @param teamCollectionSlug - The slug of the external team collection to use
4
+ */ export const createGetAvailableSlotsHandler = (teamCollectionSlug)=>async (req)=>{
5
+ try {
6
+ const url = new URL(req.url || '', 'http://localhost');
7
+ const dateParam = url.searchParams.get('date');
8
+ const serviceId = url.searchParams.get('serviceId');
9
+ const teamMemberId = url.searchParams.get('teamMemberId');
10
+ // Validate required params
11
+ if (!dateParam) {
12
+ return Response.json({
13
+ error: 'Missing required parameter: date (YYYY-MM-DD format)'
14
+ }, {
15
+ status: 400
16
+ });
17
+ }
18
+ if (!serviceId) {
19
+ return Response.json({
20
+ error: 'Missing required parameter: serviceId'
21
+ }, {
22
+ status: 400
23
+ });
24
+ }
25
+ // Parse date
26
+ const requestedDate = new Date(dateParam);
27
+ if (isNaN(requestedDate.getTime())) {
28
+ return Response.json({
29
+ error: 'Invalid date format. Use YYYY-MM-DD'
30
+ }, {
31
+ status: 400
32
+ });
33
+ }
34
+ // Get service details
35
+ const service = await req.payload.findByID({
36
+ id: serviceId,
37
+ collection: 'services'
128
38
  });
129
- if (!teamMember) {
39
+ if (!service) {
130
40
  return Response.json({
131
- error: 'Team member not found'
41
+ error: 'Service not found'
132
42
  }, {
133
43
  status: 404
134
44
  });
135
45
  }
136
- if (!teamMember.takingAppointments) {
46
+ if (!service.isActive) {
137
47
  return Response.json({
138
- error: 'Team member is not taking appointments'
48
+ error: 'Service is not available for booking'
139
49
  }, {
140
50
  status: 400
141
51
  });
142
52
  }
143
- // Check if team member provides this service
144
- const teamMemberServices = teamMember.services || [];
145
- const serviceIds = teamMemberServices.map((s)=>typeof s === 'object' ? s.id : s);
146
- if (serviceIds.length > 0 && !serviceIds.includes(serviceId)) {
53
+ // Get opening times settings
54
+ const openingTimes = await req.payload.findGlobal({
55
+ slug: 'opening-times'
56
+ });
57
+ // Check booking window
58
+ const now = new Date();
59
+ const maxBookingDate = new Date(now);
60
+ maxBookingDate.setDate(maxBookingDate.getDate() + (openingTimes?.maxAdvanceBookingDays || 30));
61
+ const minBookingDate = new Date(now);
62
+ minBookingDate.setHours(minBookingDate.getHours() + (openingTimes?.minAdvanceBookingHours || 1));
63
+ if (requestedDate > maxBookingDate) {
147
64
  return Response.json({
148
- error: 'Team member does not provide this service'
65
+ error: `Cannot book more than ${openingTimes?.maxAdvanceBookingDays || 30} days in advance`
149
66
  }, {
150
67
  status: 400
151
68
  });
152
69
  }
153
- // Get team member's schedule for this day
154
- teamMemberSchedule = teamMember.availability?.find((a)=>a.day === dayOfWeek);
155
- if (teamMemberSchedule && !teamMemberSchedule.isAvailable) {
70
+ if (requestedDate < minBookingDate) {
71
+ return Response.json({
72
+ error: `Must book at least ${openingTimes?.minAdvanceBookingHours || 1} hours in advance`
73
+ }, {
74
+ status: 400
75
+ });
76
+ }
77
+ // Get day of week
78
+ const dayNames = [
79
+ 'sunday',
80
+ 'monday',
81
+ 'tuesday',
82
+ 'wednesday',
83
+ 'thursday',
84
+ 'friday',
85
+ 'saturday'
86
+ ];
87
+ const dayOfWeek = dayNames[requestedDate.getDay()];
88
+ // Find business hours for this day
89
+ const daySchedule = openingTimes?.schedule?.find((s)=>s.day === dayOfWeek);
90
+ if (!daySchedule || !daySchedule.isOpen) {
156
91
  return Response.json({
157
92
  date: dateParam,
158
- message: 'Team member is not available on this day',
93
+ message: 'Business is closed on this day',
159
94
  serviceId,
160
95
  slots: [],
161
- teamMemberId
96
+ teamMemberId: teamMemberId || undefined
162
97
  });
163
98
  }
164
- }
165
- // Get existing appointments for this day
166
- const dayStart = new Date(requestedDate);
167
- dayStart.setHours(0, 0, 0, 0);
168
- const dayEnd = new Date(requestedDate);
169
- dayEnd.setHours(23, 59, 59, 999);
170
- const existingAppointments = await req.payload.find({
171
- collection: 'appointments',
172
- limit: 100,
173
- where: {
174
- and: [
175
- {
176
- startDateTime: {
177
- greater_than_equal: dayStart.toISOString()
178
- }
179
- },
180
- {
181
- startDateTime: {
182
- less_than: dayEnd.toISOString()
183
- }
184
- },
185
- {
186
- status: {
187
- not_in: [
188
- 'cancelled'
189
- ]
190
- }
191
- },
192
- ...teamMemberId ? [
99
+ // Calculate slot duration (service duration + buffer)
100
+ const slotDuration = service.duration || 30;
101
+ const bufferBefore = service.bufferBefore || 0;
102
+ const bufferAfter = service.bufferAfter || 0;
103
+ const totalSlotTime = slotDuration + bufferBefore + bufferAfter;
104
+ // Parse business hours
105
+ const [openHour, openMin] = (daySchedule.openTime || '09:00').split(':').map(Number);
106
+ const [closeHour, closeMin] = (daySchedule.closeTime || '17:00').split(':').map(Number);
107
+ // Parse break times if set
108
+ let breakStart = null;
109
+ let breakEnd = null;
110
+ if (daySchedule.breakStart && daySchedule.breakEnd) {
111
+ const [breakStartHour, breakStartMin] = daySchedule.breakStart.split(':').map(Number);
112
+ const [breakEndHour, breakEndMin] = daySchedule.breakEnd.split(':').map(Number);
113
+ breakStart = breakStartHour * 60 + breakStartMin;
114
+ breakEnd = breakEndHour * 60 + breakEndMin;
115
+ }
116
+ // Get team member availability if specified
117
+ let teamMemberSchedule = null;
118
+ if (teamMemberId) {
119
+ const teamMember = await req.payload.findByID({
120
+ id: teamMemberId,
121
+ collection: teamCollectionSlug
122
+ });
123
+ if (!teamMember) {
124
+ return Response.json({
125
+ error: 'Team member not found'
126
+ }, {
127
+ status: 404
128
+ });
129
+ }
130
+ // Only block if explicitly set to false - undefined means taking appointments
131
+ if (teamMember.takingAppointments === false) {
132
+ return Response.json({
133
+ error: 'Team member is not taking appointments'
134
+ }, {
135
+ status: 400
136
+ });
137
+ }
138
+ // Check if team member provides this service
139
+ const teamMemberServices = teamMember.services || [];
140
+ const serviceIds = teamMemberServices.map((s)=>typeof s === 'object' ? s.id : s);
141
+ if (serviceIds.length > 0 && !serviceIds.includes(serviceId)) {
142
+ return Response.json({
143
+ error: 'Team member does not provide this service'
144
+ }, {
145
+ status: 400
146
+ });
147
+ }
148
+ // Get team member's schedule for this day
149
+ teamMemberSchedule = teamMember.availability?.find((a)=>a.day === dayOfWeek);
150
+ if (teamMemberSchedule && !teamMemberSchedule.isAvailable) {
151
+ return Response.json({
152
+ date: dateParam,
153
+ message: 'Team member is not available on this day',
154
+ serviceId,
155
+ slots: [],
156
+ teamMemberId
157
+ });
158
+ }
159
+ }
160
+ // Get existing appointments for this day
161
+ const dayStart = new Date(requestedDate);
162
+ dayStart.setHours(0, 0, 0, 0);
163
+ const dayEnd = new Date(requestedDate);
164
+ dayEnd.setHours(23, 59, 59, 999);
165
+ const existingAppointments = await req.payload.find({
166
+ collection: 'appointments',
167
+ limit: 100,
168
+ where: {
169
+ and: [
193
170
  {
194
- teamMember: {
195
- equals: teamMemberId
171
+ startDateTime: {
172
+ greater_than_equal: dayStart.toISOString()
196
173
  }
197
- }
198
- ] : []
199
- ]
200
- }
201
- });
202
- // Convert appointments to blocked time ranges (in minutes from midnight)
203
- const blockedRanges = [];
204
- for (const apt of existingAppointments.docs){
205
- const aptStart = new Date(apt.startDateTime);
206
- const aptEnd = apt.endDateTime ? new Date(apt.endDateTime) : new Date(aptStart.getTime() + 30 * 60000);
207
- const startMinutes = aptStart.getHours() * 60 + aptStart.getMinutes();
208
- const endMinutes = aptEnd.getHours() * 60 + aptEnd.getMinutes();
209
- blockedRanges.push({
210
- end: endMinutes + bufferAfter,
211
- start: startMinutes - bufferBefore
212
- });
213
- }
214
- // Determine effective working hours
215
- let effectiveOpenMinutes = openHour * 60 + openMin;
216
- let effectiveCloseMinutes = closeHour * 60 + closeMin;
217
- // Apply team member schedule if available
218
- if (teamMemberSchedule) {
219
- const [tmOpenHour, tmOpenMin] = (teamMemberSchedule.startTime || '09:00').split(':').map(Number);
220
- const [tmCloseHour, tmCloseMin] = (teamMemberSchedule.endTime || '17:00').split(':').map(Number);
221
- effectiveOpenMinutes = Math.max(effectiveOpenMinutes, tmOpenHour * 60 + tmOpenMin);
222
- effectiveCloseMinutes = Math.min(effectiveCloseMinutes, tmCloseHour * 60 + tmCloseMin);
223
- // Apply team member break
224
- if (teamMemberSchedule.breakStart && teamMemberSchedule.breakEnd) {
225
- const [tmBreakStartHour, tmBreakStartMin] = teamMemberSchedule.breakStart.split(':').map(Number);
226
- const [tmBreakEndHour, tmBreakEndMin] = teamMemberSchedule.breakEnd.split(':').map(Number);
227
- // Use the more restrictive break
228
- if (breakStart === null) {
229
- breakStart = tmBreakStartHour * 60 + tmBreakStartMin;
230
- breakEnd = tmBreakEndHour * 60 + tmBreakEndMin;
231
- } else {
232
- breakStart = Math.min(breakStart, tmBreakStartHour * 60 + tmBreakStartMin);
233
- breakEnd = Math.max(breakEnd, tmBreakEndHour * 60 + tmBreakEndMin);
174
+ },
175
+ {
176
+ startDateTime: {
177
+ less_than: dayEnd.toISOString()
178
+ }
179
+ },
180
+ {
181
+ status: {
182
+ not_in: [
183
+ 'cancelled'
184
+ ]
185
+ }
186
+ },
187
+ ...teamMemberId ? [
188
+ {
189
+ teamMember: {
190
+ equals: teamMemberId
191
+ }
192
+ }
193
+ ] : []
194
+ ]
234
195
  }
196
+ });
197
+ // Convert appointments to blocked time ranges (in minutes from midnight)
198
+ const blockedRanges = [];
199
+ for (const apt of existingAppointments.docs){
200
+ const aptStart = new Date(apt.startDateTime);
201
+ const aptEnd = apt.endDateTime ? new Date(apt.endDateTime) : new Date(aptStart.getTime() + 30 * 60000);
202
+ const startMinutes = aptStart.getHours() * 60 + aptStart.getMinutes();
203
+ const endMinutes = aptEnd.getHours() * 60 + aptEnd.getMinutes();
204
+ blockedRanges.push({
205
+ end: endMinutes + bufferAfter,
206
+ start: startMinutes - bufferBefore
207
+ });
235
208
  }
236
- }
237
- // Generate slots
238
- const slots = [];
239
- const slotInterval = openingTimes?.slotDuration || 30;
240
- for(let minutes = effectiveOpenMinutes; minutes + totalSlotTime <= effectiveCloseMinutes; minutes += slotInterval){
241
- const slotEnd = minutes + totalSlotTime;
242
- // Check if slot is during break
243
- if (breakStart !== null && breakEnd !== null) {
244
- if (minutes < breakEnd && slotEnd > breakStart) {
245
- continue; // Skip slots that overlap with break
209
+ // Determine effective working hours
210
+ let effectiveOpenMinutes = openHour * 60 + openMin;
211
+ let effectiveCloseMinutes = closeHour * 60 + closeMin;
212
+ // Apply team member schedule if available
213
+ if (teamMemberSchedule) {
214
+ const [tmOpenHour, tmOpenMin] = (teamMemberSchedule.startTime || '09:00').split(':').map(Number);
215
+ const [tmCloseHour, tmCloseMin] = (teamMemberSchedule.endTime || '17:00').split(':').map(Number);
216
+ effectiveOpenMinutes = Math.max(effectiveOpenMinutes, tmOpenHour * 60 + tmOpenMin);
217
+ effectiveCloseMinutes = Math.min(effectiveCloseMinutes, tmCloseHour * 60 + tmCloseMin);
218
+ // Apply team member break
219
+ if (teamMemberSchedule.breakStart && teamMemberSchedule.breakEnd) {
220
+ const [tmBreakStartHour, tmBreakStartMin] = teamMemberSchedule.breakStart.split(':').map(Number);
221
+ const [tmBreakEndHour, tmBreakEndMin] = teamMemberSchedule.breakEnd.split(':').map(Number);
222
+ // Use the more restrictive break
223
+ if (breakStart === null) {
224
+ breakStart = tmBreakStartHour * 60 + tmBreakStartMin;
225
+ breakEnd = tmBreakEndHour * 60 + tmBreakEndMin;
226
+ } else {
227
+ breakStart = Math.min(breakStart, tmBreakStartHour * 60 + tmBreakStartMin);
228
+ breakEnd = Math.max(breakEnd, tmBreakEndHour * 60 + tmBreakEndMin);
229
+ }
246
230
  }
247
231
  }
248
- // Check if slot conflicts with existing appointments
249
- let isBlocked = false;
250
- for (const blocked of blockedRanges){
251
- if (minutes < blocked.end && slotEnd > blocked.start) {
232
+ // Generate slots
233
+ const slots = [];
234
+ const slotInterval = openingTimes?.slotDuration || 30;
235
+ for(let minutes = effectiveOpenMinutes; minutes + totalSlotTime <= effectiveCloseMinutes; minutes += slotInterval){
236
+ const slotEnd = minutes + totalSlotTime;
237
+ // Check if slot is during break
238
+ if (breakStart !== null && breakEnd !== null) {
239
+ if (minutes < breakEnd && slotEnd > breakStart) {
240
+ continue; // Skip slots that overlap with break
241
+ }
242
+ }
243
+ // Check if slot conflicts with existing appointments
244
+ let isBlocked = false;
245
+ for (const blocked of blockedRanges){
246
+ if (minutes < blocked.end && slotEnd > blocked.start) {
247
+ isBlocked = true;
248
+ break;
249
+ }
250
+ }
251
+ // Create slot datetime
252
+ const slotStart = new Date(requestedDate);
253
+ slotStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
254
+ const slotEndDate = new Date(requestedDate);
255
+ slotEndDate.setHours(Math.floor(slotEnd / 60), slotEnd % 60, 0, 0);
256
+ // Check if slot is in the past
257
+ if (slotStart < now) {
252
258
  isBlocked = true;
253
- break;
254
259
  }
260
+ slots.push({
261
+ available: !isBlocked,
262
+ end: slotEndDate.toISOString(),
263
+ start: slotStart.toISOString()
264
+ });
255
265
  }
256
- // Create slot datetime
257
- const slotStart = new Date(requestedDate);
258
- slotStart.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
259
- const slotEndDate = new Date(requestedDate);
260
- slotEndDate.setHours(Math.floor(slotEnd / 60), slotEnd % 60, 0, 0);
261
- // Check if slot is in the past
262
- if (slotStart < now) {
263
- isBlocked = true;
264
- }
265
- slots.push({
266
- available: !isBlocked,
267
- end: slotEndDate.toISOString(),
268
- start: slotStart.toISOString()
266
+ const response = {
267
+ date: dateParam,
268
+ serviceId,
269
+ slots,
270
+ teamMemberId: teamMemberId || undefined
271
+ };
272
+ return Response.json(response);
273
+ } catch (error) {
274
+ req.payload.logger.error({
275
+ error,
276
+ msg: 'Error getting available slots'
277
+ });
278
+ return Response.json({
279
+ error: 'Internal server error'
280
+ }, {
281
+ status: 500
269
282
  });
270
283
  }
271
- const response = {
272
- date: dateParam,
273
- serviceId,
274
- slots,
275
- teamMemberId: teamMemberId || undefined
276
- };
277
- return Response.json(response);
278
- } catch (error) {
279
- req.payload.logger.error({
280
- error,
281
- msg: 'Error getting available slots'
282
- });
283
- return Response.json({
284
- error: 'Internal server error'
285
- }, {
286
- status: 500
287
- });
288
- }
289
- };
284
+ };
290
285
 
291
286
  //# sourceMappingURL=getAvailableSlots.js.map