payload-reserve 2.3.0 → 3.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.
- package/README.md +218 -4
- package/dist/collections/Holds.d.ts +18 -0
- package/dist/collections/Holds.js +113 -0
- package/dist/collections/Holds.js.map +1 -0
- package/dist/collections/Reservations.js +8 -0
- package/dist/collections/Reservations.js.map +1 -1
- package/dist/collections/Resources.js +35 -1
- package/dist/collections/Resources.js.map +1 -1
- package/dist/collections/Services.js +41 -0
- package/dist/collections/Services.js.map +1 -1
- package/dist/components/CalendarView/LaneTimelineView.js +4 -5
- package/dist/components/CalendarView/LaneTimelineView.js.map +1 -1
- package/dist/components/CalendarView/index.js +55 -71
- package/dist/components/CalendarView/index.js.map +1 -1
- package/dist/components/DashboardWidget/DashboardWidgetServer.js +11 -74
- package/dist/components/DashboardWidget/DashboardWidgetServer.js.map +1 -1
- package/dist/components/DashboardWidget/fetchDashboardStats.d.ts +42 -0
- package/dist/components/DashboardWidget/fetchDashboardStats.js +113 -0
- package/dist/components/DashboardWidget/fetchDashboardStats.js.map +1 -0
- package/dist/defaults.d.ts +2 -0
- package/dist/defaults.js +12 -0
- package/dist/defaults.js.map +1 -1
- package/dist/endpoints/cancelBooking.js +43 -14
- package/dist/endpoints/cancelBooking.js.map +1 -1
- package/dist/endpoints/checkAvailability.js +27 -1
- package/dist/endpoints/checkAvailability.js.map +1 -1
- package/dist/endpoints/createBooking.js +108 -7
- package/dist/endpoints/createBooking.js.map +1 -1
- package/dist/endpoints/customerSearch.js +6 -0
- package/dist/endpoints/customerSearch.js.map +1 -1
- package/dist/endpoints/effectiveTimezone.js +1 -0
- package/dist/endpoints/effectiveTimezone.js.map +1 -1
- package/dist/endpoints/getSlots.js +27 -1
- package/dist/endpoints/getSlots.js.map +1 -1
- package/dist/endpoints/holdSlot.d.ts +12 -0
- package/dist/endpoints/holdSlot.js +126 -0
- package/dist/endpoints/holdSlot.js.map +1 -0
- package/dist/endpoints/releaseSlot.d.ts +8 -0
- package/dist/endpoints/releaseSlot.js +34 -0
- package/dist/endpoints/releaseSlot.js.map +1 -0
- package/dist/endpoints/resourceAvailability.d.ts +4 -0
- package/dist/endpoints/resourceAvailability.js +105 -4
- package/dist/endpoints/resourceAvailability.js.map +1 -1
- package/dist/hooks/holds/validateHoldSlot.d.ts +14 -0
- package/dist/hooks/holds/validateHoldSlot.js +90 -0
- package/dist/hooks/holds/validateHoldSlot.js.map +1 -0
- package/dist/hooks/reservations/acquireBookingLock.d.ts +30 -0
- package/dist/hooks/reservations/acquireBookingLock.js +71 -0
- package/dist/hooks/reservations/acquireBookingLock.js.map +1 -0
- package/dist/hooks/reservations/calculateEndTime.js +13 -1
- package/dist/hooks/reservations/calculateEndTime.js.map +1 -1
- package/dist/hooks/reservations/expandRequiredResources.js +3 -0
- package/dist/hooks/reservations/expandRequiredResources.js.map +1 -1
- package/dist/hooks/reservations/validateActive.d.ts +11 -0
- package/dist/hooks/reservations/validateActive.js +103 -0
- package/dist/hooks/reservations/validateActive.js.map +1 -0
- package/dist/hooks/reservations/validateConflicts.js +22 -1
- package/dist/hooks/reservations/validateConflicts.js.map +1 -1
- package/dist/hooks/reservations/validateGuestBooking.js +3 -0
- package/dist/hooks/reservations/validateGuestBooking.js.map +1 -1
- package/dist/hooks/shared/preventDeleteWhenReferenced.d.ts +42 -0
- package/dist/hooks/shared/preventDeleteWhenReferenced.js +87 -0
- package/dist/hooks/shared/preventDeleteWhenReferenced.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin.js +179 -3
- package/dist/plugin.js.map +1 -1
- package/dist/services/AvailabilityService.d.ts +51 -5
- package/dist/services/AvailabilityService.js +315 -38
- package/dist/services/AvailabilityService.js.map +1 -1
- package/dist/services/HoldService.d.ts +53 -0
- package/dist/services/HoldService.js +169 -0
- package/dist/services/HoldService.js.map +1 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js.map +1 -1
- package/dist/translations/ar.json +5 -1
- package/dist/translations/de.json +5 -1
- package/dist/translations/en.json +5 -1
- package/dist/translations/es.json +5 -1
- package/dist/translations/fa.json +5 -1
- package/dist/translations/fr.json +5 -1
- package/dist/translations/hi.json +5 -1
- package/dist/translations/id.json +5 -1
- package/dist/translations/pl.json +5 -1
- package/dist/translations/ru.json +5 -1
- package/dist/translations/tr.json +5 -1
- package/dist/translations/zh.json +5 -1
- package/dist/types.d.ts +32 -0
- package/dist/types.js.map +1 -1
- package/dist/utilities/calendarGrid.d.ts +44 -0
- package/dist/utilities/calendarGrid.js +67 -0
- package/dist/utilities/calendarGrid.js.map +1 -0
- package/dist/utilities/reservationChanges.d.ts +6 -1
- package/dist/utilities/reservationChanges.js +1 -1
- package/dist/utilities/reservationChanges.js.map +1 -1
- package/dist/utilities/reserveDebug.d.ts +26 -0
- package/dist/utilities/reserveDebug.js +35 -0
- package/dist/utilities/reserveDebug.js.map +1 -0
- package/dist/utilities/resolveReservationItems.d.ts +13 -2
- package/dist/utilities/resolveReservationItems.js +87 -2
- package/dist/utilities/resolveReservationItems.js.map +1 -1
- package/dist/utilities/retryOnWriteConflict.d.ts +66 -0
- package/dist/utilities/retryOnWriteConflict.js +135 -0
- package/dist/utilities/retryOnWriteConflict.js.map +1 -0
- package/dist/utilities/tenantTimezone.d.ts +77 -1
- package/dist/utilities/tenantTimezone.js +163 -2
- package/dist/utilities/tenantTimezone.js.map +1 -1
- package/dist/utilities/transactionSupport.d.ts +13 -0
- package/dist/utilities/transactionSupport.js +30 -0
- package/dist/utilities/transactionSupport.js.map +1 -0
- package/package.json +13 -13
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isTransientWriteConflict, retryOnWriteConflict } from '../utilities/retryOnWriteConflict.js';
|
|
2
|
+
import { callerMayUseTenant } from '../utilities/tenantTimezone.js';
|
|
1
3
|
import { isPrivilegedUser } from '../utilities/userRoles.js';
|
|
2
4
|
export function createBookingEndpoint(config) {
|
|
3
5
|
return {
|
|
@@ -6,10 +8,16 @@ export function createBookingEndpoint(config) {
|
|
|
6
8
|
// Cancellation tokens are server-generated secrets — never accept one
|
|
7
9
|
// from the request body.
|
|
8
10
|
delete data.cancellationToken;
|
|
11
|
+
// A hold token is a bearer secret, not booking data: it must reach the
|
|
12
|
+
// conflict check via context (so the hold does not block the booking it
|
|
13
|
+
// was taken to protect) and must never be persisted on the reservation.
|
|
14
|
+
const holdToken = typeof data.holdToken === 'string' ? data.holdToken : undefined;
|
|
15
|
+
delete data.holdToken;
|
|
16
|
+
const privileged = isPrivilegedUser(req.user, config);
|
|
9
17
|
// Who may book for whom: staff/admin for anyone (walk-ins); an
|
|
10
18
|
// authenticated customer only for themselves; anonymous callers never
|
|
11
19
|
// for an existing customer record (the guest flow covers them).
|
|
12
|
-
if (!
|
|
20
|
+
if (!privileged) {
|
|
13
21
|
if (req.user) {
|
|
14
22
|
data.customer = req.user.id;
|
|
15
23
|
} else if (data.customer) {
|
|
@@ -20,15 +28,108 @@ export function createBookingEndpoint(config) {
|
|
|
20
28
|
});
|
|
21
29
|
}
|
|
22
30
|
}
|
|
31
|
+
// The write stays PRIVILEGED for every caller, and the tenant-membership
|
|
32
|
+
// probe below is the security boundary (maintainer ruling). Applying that
|
|
33
|
+
// ruling consistently is what makes this endpoint's behaviour identical to
|
|
34
|
+
// pre-`slotHolds` releases for collection access — it never passed
|
|
35
|
+
// `overrideAccess`, so the Local API default (`true`) applied to
|
|
36
|
+
// everyone — while still closing the cross-tenant write.
|
|
37
|
+
//
|
|
38
|
+
// Delegating for any subset of callers is not a security improvement and
|
|
39
|
+
// is a real regression: `isPrivilegedUser` is true for ANY user outside
|
|
40
|
+
// `slugs.customers`, so delegating on that branch made a non-admin staff
|
|
41
|
+
// or resource-owner account hit `resourceOwnerMode`'s admin-only
|
|
42
|
+
// reservation `create` access (`makeReservationOwnerAccess` in
|
|
43
|
+
// src/utilities/ownerAccess.ts) and get a flat 403 for a walk-in booking
|
|
44
|
+
// they could take before. And it buys nothing, because Payload's `create`
|
|
45
|
+
// access check only tests the TRUTHINESS of an access function's result —
|
|
46
|
+
// a returned `Where` (how multi-tenant scopes access) is discarded, so
|
|
47
|
+
// `overrideAccess: false` cannot constrain WHICH tenant is written to.
|
|
48
|
+
// Only the probe can. See README, "/api/reserve/book: the tenant probe is
|
|
49
|
+
// the gate, not overrideAccess".
|
|
50
|
+
// See callerMayUseTenant's doc comment (src/utilities/tenantTimezone.ts)
|
|
51
|
+
// for the full mechanism and its precondition. Unconditional on
|
|
52
|
+
// `req.user`: it is what authorizes an explicit `tenant` in the body, and
|
|
53
|
+
// it is independent of the privileged write above.
|
|
54
|
+
if (req.user) {
|
|
55
|
+
const permitted = await callerMayUseTenant({
|
|
56
|
+
config,
|
|
57
|
+
data,
|
|
58
|
+
req
|
|
59
|
+
});
|
|
60
|
+
if (!permitted) {
|
|
61
|
+
return Response.json({
|
|
62
|
+
error: 'Not permitted to create this booking'
|
|
63
|
+
}, {
|
|
64
|
+
status: 403
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
23
68
|
// Create via Payload Local API — collection hooks handle conflict detection,
|
|
24
69
|
// endTime calculation, status transitions, AND the beforeBookingCreate
|
|
25
70
|
// plugin hooks (running them here too made them fire twice per booking).
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
71
|
+
//
|
|
72
|
+
// Retry only transient write conflicts. acquireBookingLock makes concurrent
|
|
73
|
+
// bookings for one resource contend on a single document; on MongoDB the
|
|
74
|
+
// loser aborts rather than waiting, so without this a legitimate booking on
|
|
75
|
+
// a quantity>1 resource fails purely because it lost a race.
|
|
76
|
+
let reservation;
|
|
77
|
+
try {
|
|
78
|
+
reservation = await retryOnWriteConflict(()=>// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
req.payload.create({
|
|
80
|
+
collection: config.slugs.reservations,
|
|
81
|
+
context: holdToken ? {
|
|
82
|
+
holdToken
|
|
83
|
+
} : undefined,
|
|
84
|
+
data,
|
|
85
|
+
overrideAccess: true,
|
|
86
|
+
req
|
|
87
|
+
}), {
|
|
88
|
+
req
|
|
89
|
+
});
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// A conflict that survived every attempt is contention, not a bad request:
|
|
92
|
+
// the caller should try again rather than change anything.
|
|
93
|
+
if (isTransientWriteConflict(err)) {
|
|
94
|
+
return Response.json({
|
|
95
|
+
error: 'That slot is being booked by someone else. Please try again.',
|
|
96
|
+
retryable: true
|
|
97
|
+
}, {
|
|
98
|
+
status: 409
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
// An access denial raised anywhere under the create (a consumer hook,
|
|
102
|
+
// a field-level rule) is an authorization failure, not a 500.
|
|
103
|
+
if (err?.status === 403) {
|
|
104
|
+
return Response.json({
|
|
105
|
+
error: 'Not permitted to create this booking'
|
|
106
|
+
}, {
|
|
107
|
+
status: 403
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
// Consume the hold now that the booking exists. Failure here must not fail
|
|
113
|
+
// the booking — the hold expires on its own, and a stale hold only ever
|
|
114
|
+
// blocks the slot for its remaining TTL.
|
|
115
|
+
if (holdToken) {
|
|
116
|
+
try {
|
|
117
|
+
await req.payload.delete({
|
|
118
|
+
collection: config.slugs.holds,
|
|
119
|
+
req,
|
|
120
|
+
where: {
|
|
121
|
+
token: {
|
|
122
|
+
equals: holdToken
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
} catch (err) {
|
|
127
|
+
req.payload.logger.warn({
|
|
128
|
+
err,
|
|
129
|
+
msg: 'payload-reserve: booking created but its hold could not be released'
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
32
133
|
// Never expose the cancellation token in the HTTP response — it is delivered
|
|
33
134
|
// to the guest by the host project via the afterBookingCreate hook.
|
|
34
135
|
const { cancellationToken: _cancellationToken, ...safeReservation } = reservation;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/endpoints/createBooking.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { isPrivilegedUser } from '../utilities/userRoles.js'\n\nexport function createBookingEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const data = (await req.json?.()) as Record<string, unknown>\n\n // Cancellation tokens are server-generated secrets — never accept one\n // from the request body.\n delete data.cancellationToken\n\n // Who may book for whom: staff/admin for anyone (walk-ins); an\n // authenticated customer only for themselves; anonymous callers never\n // for an existing customer record (the guest flow covers them).\n if (!
|
|
1
|
+
{"version":3,"sources":["../../src/endpoints/createBooking.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport {\n isTransientWriteConflict,\n retryOnWriteConflict,\n} from '../utilities/retryOnWriteConflict.js'\nimport { callerMayUseTenant } from '../utilities/tenantTimezone.js'\nimport { isPrivilegedUser } from '../utilities/userRoles.js'\n\nexport function createBookingEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const data = (await req.json?.()) as Record<string, unknown>\n\n // Cancellation tokens are server-generated secrets — never accept one\n // from the request body.\n delete data.cancellationToken\n\n // A hold token is a bearer secret, not booking data: it must reach the\n // conflict check via context (so the hold does not block the booking it\n // was taken to protect) and must never be persisted on the reservation.\n const holdToken = typeof data.holdToken === 'string' ? data.holdToken : undefined\n delete data.holdToken\n\n const privileged = isPrivilegedUser(req.user, config)\n\n // Who may book for whom: staff/admin for anyone (walk-ins); an\n // authenticated customer only for themselves; anonymous callers never\n // for an existing customer record (the guest flow covers them).\n if (!privileged) {\n if (req.user) {\n data.customer = req.user.id\n } else if (data.customer) {\n return Response.json(\n { error: 'Anonymous bookings cannot set a customer' },\n { status: 403 },\n )\n }\n }\n\n // The write stays PRIVILEGED for every caller, and the tenant-membership\n // probe below is the security boundary (maintainer ruling). Applying that\n // ruling consistently is what makes this endpoint's behaviour identical to\n // pre-`slotHolds` releases for collection access — it never passed\n // `overrideAccess`, so the Local API default (`true`) applied to\n // everyone — while still closing the cross-tenant write.\n //\n // Delegating for any subset of callers is not a security improvement and\n // is a real regression: `isPrivilegedUser` is true for ANY user outside\n // `slugs.customers`, so delegating on that branch made a non-admin staff\n // or resource-owner account hit `resourceOwnerMode`'s admin-only\n // reservation `create` access (`makeReservationOwnerAccess` in\n // src/utilities/ownerAccess.ts) and get a flat 403 for a walk-in booking\n // they could take before. And it buys nothing, because Payload's `create`\n // access check only tests the TRUTHINESS of an access function's result —\n // a returned `Where` (how multi-tenant scopes access) is discarded, so\n // `overrideAccess: false` cannot constrain WHICH tenant is written to.\n // Only the probe can. See README, \"/api/reserve/book: the tenant probe is\n // the gate, not overrideAccess\".\n\n // See callerMayUseTenant's doc comment (src/utilities/tenantTimezone.ts)\n // for the full mechanism and its precondition. Unconditional on\n // `req.user`: it is what authorizes an explicit `tenant` in the body, and\n // it is independent of the privileged write above.\n if (req.user) {\n const permitted = await callerMayUseTenant({ config, data, req })\n if (!permitted) {\n return Response.json({ error: 'Not permitted to create this booking' }, { status: 403 })\n }\n }\n\n // Create via Payload Local API — collection hooks handle conflict detection,\n // endTime calculation, status transitions, AND the beforeBookingCreate\n // plugin hooks (running them here too made them fire twice per booking).\n //\n // Retry only transient write conflicts. acquireBookingLock makes concurrent\n // bookings for one resource contend on a single document; on MongoDB the\n // loser aborts rather than waiting, so without this a legitimate booking on\n // a quantity>1 resource fails purely because it lost a race.\n let reservation: Record<string, unknown>\n try {\n reservation = await retryOnWriteConflict(\n () =>\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (req.payload.create as any)({\n collection: config.slugs.reservations,\n context: holdToken ? { holdToken } : undefined,\n data,\n overrideAccess: true,\n req,\n }) as Promise<Record<string, unknown>>,\n { req },\n )\n } catch (err) {\n // A conflict that survived every attempt is contention, not a bad request:\n // the caller should try again rather than change anything.\n if (isTransientWriteConflict(err)) {\n return Response.json(\n {\n error: 'That slot is being booked by someone else. Please try again.',\n retryable: true,\n },\n { status: 409 },\n )\n }\n // An access denial raised anywhere under the create (a consumer hook,\n // a field-level rule) is an authorization failure, not a 500.\n if ((err as { status?: number })?.status === 403) {\n return Response.json({ error: 'Not permitted to create this booking' }, { status: 403 })\n }\n throw err\n }\n\n // Consume the hold now that the booking exists. Failure here must not fail\n // the booking — the hold expires on its own, and a stale hold only ever\n // blocks the slot for its remaining TTL.\n if (holdToken) {\n try {\n await req.payload.delete({\n collection: config.slugs.holds,\n req,\n where: { token: { equals: holdToken } },\n })\n } catch (err) {\n req.payload.logger.warn({\n err,\n msg: 'payload-reserve: booking created but its hold could not be released',\n })\n }\n }\n\n // Never expose the cancellation token in the HTTP response — it is delivered\n // to the guest by the host project via the afterBookingCreate hook.\n const { cancellationToken: _cancellationToken, ...safeReservation } = reservation\n\n return Response.json(safeReservation, { status: 201 })\n },\n method: 'post',\n path: '/reserve/book',\n }\n}\n"],"names":["isTransientWriteConflict","retryOnWriteConflict","callerMayUseTenant","isPrivilegedUser","createBookingEndpoint","config","handler","req","data","json","cancellationToken","holdToken","undefined","privileged","user","customer","id","Response","error","status","permitted","reservation","payload","create","collection","slugs","reservations","context","overrideAccess","err","retryable","delete","holds","where","token","equals","logger","warn","msg","_cancellationToken","safeReservation","method","path"],"mappings":"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"}
|
|
@@ -119,7 +119,13 @@ export function createCustomerSearchEndpoint(config) {
|
|
|
119
119
|
const result = await req.payload.find({
|
|
120
120
|
collection: config.slugs.customers,
|
|
121
121
|
limit,
|
|
122
|
+
// Delegate isolation to collection access control. The multi-tenant
|
|
123
|
+
// plugin enforces tenancy through the user's memberships, which the
|
|
124
|
+
// cookie clause above structurally cannot express — with userCollection
|
|
125
|
+
// set, tenancy lives on a `tenants` ARRAY, not a flat `tenant` field.
|
|
126
|
+
overrideAccess: false,
|
|
122
127
|
page,
|
|
128
|
+
req,
|
|
123
129
|
where
|
|
124
130
|
});
|
|
125
131
|
return Response.json({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/endpoints/customerSearch.ts"],"sourcesContent":["import type { CollectionSlug, Endpoint, Field, Where } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { collectionHasTenantField, readCookie, tenantWhereClause } from '../utilities/tenantFilter.js'\nimport { isPrivilegedUser, privilegedRoles } from '../utilities/userRoles.js'\n\n/**\n * Inspect a collection's field list and return the set of top-level named\n * fields as a plain Set<string>. Unnamed fields (rows, groups without a name,\n * etc.) are skipped.\n */\nfunction getNamedFields(fields: Field[]): Set<string> {\n const names = new Set<string>()\n for (const field of fields) {\n if ('name' in field) {\n names.add(field.name)\n }\n }\n return names\n}\n\nexport function createCustomerSearchEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n if (!req.user) {\n return Response.json({ message: 'Unauthorized' }, { status: 401 })\n }\n\n // Only staff/admin may search customers. Role-aware so it works when staff\n // and customers share one auth collection (userCollection set).\n if (!isPrivilegedUser(req.user, config)) {\n return Response.json({ message: 'Forbidden' }, { status: 403 })\n }\n\n const url = new URL(req.url!)\n const search = url.searchParams.get('search') ?? ''\n const limitRaw = Number(url.searchParams.get('limit') ?? '10')\n const pageRaw = Number(url.searchParams.get('page') ?? '1')\n // Non-numeric input falls back to defaults instead of passing NaN to the DB\n const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(Math.floor(limitRaw), 1), 50) : 10\n const page = Number.isFinite(pageRaw) ? Math.max(Math.floor(pageRaw), 1) : 1\n\n // Detect which fields exist on the target collection at runtime\n const collectionConfig = req.payload.collections[config.slugs.customers as unknown as CollectionSlug]?.config\n const availableFields: Set<string> = collectionConfig\n ? getNamedFields(collectionConfig.fields)\n : new Set()\n\n const hasName = availableFields.has('name')\n const hasFirstName = availableFields.has('firstName')\n const hasLastName = availableFields.has('lastName')\n const hasPhone = availableFields.has('phone')\n\n const andClauses: Where[] = []\n\n if (search) {\n const orClauses: Where[] = []\n\n if (hasName) {\n orClauses.push({ name: { contains: search } })\n }\n if (hasFirstName) {\n orClauses.push({ firstName: { contains: search } })\n }\n if (hasLastName) {\n orClauses.push({ lastName: { contains: search } })\n }\n // email is always present on auth collections\n orClauses.push({ email: { contains: search } })\n if (hasPhone) {\n orClauses.push({ phone: { contains: search } })\n }\n\n andClauses.push({ or: orClauses })\n }\n\n // Single-collection mode: staff/admin live in the same collection as\n // customers, so exclude privileged roles — the dropdown should list only\n // actual customers, not bookable-looking staff.\n if (config.userCollection) {\n const roleField = config.staffProvisioning?.roleField ?? 'role'\n const priv = privilegedRoles(config)\n if (priv.length > 0) {\n andClauses.push({ [roleField]: { not_in: priv } })\n }\n }\n\n // Tenant scoping: when the customers collection carries the multi-tenant\n // tenant field and a tenant is selected (cookie), restrict the search to\n // that tenant. Plain installs (no tenant field / no cookie) add nothing.\n const tenantClause = tenantWhereClause({\n hasField: collectionHasTenantField(collectionConfig, config.multiTenant.tenantField),\n tenantField: config.multiTenant.tenantField,\n tenantId: readCookie(req.headers?.get('cookie'), config.multiTenant.cookieName),\n })\n if (tenantClause) {\n andClauses.push(tenantClause)\n }\n\n const where: Where =\n andClauses.length === 0\n ? {}\n : andClauses.length === 1\n ? andClauses[0]\n : { and: andClauses }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const result = await (req.payload.find as any)({\n collection: config.slugs.customers,\n limit,\n page,\n where,\n })\n\n return Response.json({\n docs: (result.docs as Record<string, unknown>[]).map((doc) => {\n const entry: Record<string, unknown> = {\n id: doc['id'],\n email: doc['email'] ?? '',\n }\n\n if (hasName) {\n entry['name'] = doc['name'] ?? ''\n }\n if (hasFirstName) {\n entry['firstName'] = doc['firstName'] ?? ''\n }\n if (hasLastName) {\n entry['lastName'] = doc['lastName'] ?? ''\n }\n if (hasPhone) {\n entry['phone'] = doc['phone'] ?? ''\n }\n\n return entry\n }),\n hasNextPage: result.hasNextPage,\n totalDocs: result.totalDocs,\n })\n },\n method: 'get',\n path: '/reservation-customer-search',\n }\n}\n"],"names":["collectionHasTenantField","readCookie","tenantWhereClause","isPrivilegedUser","privilegedRoles","getNamedFields","fields","names","Set","field","add","name","createCustomerSearchEndpoint","config","handler","req","user","Response","json","message","status","url","URL","search","searchParams","get","limitRaw","Number","pageRaw","limit","isFinite","Math","min","max","floor","page","collectionConfig","payload","collections","slugs","customers","availableFields","hasName","has","hasFirstName","hasLastName","hasPhone","andClauses","orClauses","push","contains","firstName","lastName","email","phone","or","userCollection","roleField","staffProvisioning","priv","length","not_in","tenantClause","hasField","multiTenant","tenantField","tenantId","headers","cookieName","where","and","result","find","collection","docs","map","doc","entry","id","hasNextPage","totalDocs","method","path"],"mappings":"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
|
|
1
|
+
{"version":3,"sources":["../../src/endpoints/customerSearch.ts"],"sourcesContent":["import type { CollectionSlug, Endpoint, Field, Where } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { collectionHasTenantField, readCookie, tenantWhereClause } from '../utilities/tenantFilter.js'\nimport { isPrivilegedUser, privilegedRoles } from '../utilities/userRoles.js'\n\n/**\n * Inspect a collection's field list and return the set of top-level named\n * fields as a plain Set<string>. Unnamed fields (rows, groups without a name,\n * etc.) are skipped.\n */\nfunction getNamedFields(fields: Field[]): Set<string> {\n const names = new Set<string>()\n for (const field of fields) {\n if ('name' in field) {\n names.add(field.name)\n }\n }\n return names\n}\n\nexport function createCustomerSearchEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n if (!req.user) {\n return Response.json({ message: 'Unauthorized' }, { status: 401 })\n }\n\n // Only staff/admin may search customers. Role-aware so it works when staff\n // and customers share one auth collection (userCollection set).\n if (!isPrivilegedUser(req.user, config)) {\n return Response.json({ message: 'Forbidden' }, { status: 403 })\n }\n\n const url = new URL(req.url!)\n const search = url.searchParams.get('search') ?? ''\n const limitRaw = Number(url.searchParams.get('limit') ?? '10')\n const pageRaw = Number(url.searchParams.get('page') ?? '1')\n // Non-numeric input falls back to defaults instead of passing NaN to the DB\n const limit = Number.isFinite(limitRaw) ? Math.min(Math.max(Math.floor(limitRaw), 1), 50) : 10\n const page = Number.isFinite(pageRaw) ? Math.max(Math.floor(pageRaw), 1) : 1\n\n // Detect which fields exist on the target collection at runtime\n const collectionConfig = req.payload.collections[config.slugs.customers as unknown as CollectionSlug]?.config\n const availableFields: Set<string> = collectionConfig\n ? getNamedFields(collectionConfig.fields)\n : new Set()\n\n const hasName = availableFields.has('name')\n const hasFirstName = availableFields.has('firstName')\n const hasLastName = availableFields.has('lastName')\n const hasPhone = availableFields.has('phone')\n\n const andClauses: Where[] = []\n\n if (search) {\n const orClauses: Where[] = []\n\n if (hasName) {\n orClauses.push({ name: { contains: search } })\n }\n if (hasFirstName) {\n orClauses.push({ firstName: { contains: search } })\n }\n if (hasLastName) {\n orClauses.push({ lastName: { contains: search } })\n }\n // email is always present on auth collections\n orClauses.push({ email: { contains: search } })\n if (hasPhone) {\n orClauses.push({ phone: { contains: search } })\n }\n\n andClauses.push({ or: orClauses })\n }\n\n // Single-collection mode: staff/admin live in the same collection as\n // customers, so exclude privileged roles — the dropdown should list only\n // actual customers, not bookable-looking staff.\n if (config.userCollection) {\n const roleField = config.staffProvisioning?.roleField ?? 'role'\n const priv = privilegedRoles(config)\n if (priv.length > 0) {\n andClauses.push({ [roleField]: { not_in: priv } })\n }\n }\n\n // Tenant scoping: when the customers collection carries the multi-tenant\n // tenant field and a tenant is selected (cookie), restrict the search to\n // that tenant. Plain installs (no tenant field / no cookie) add nothing.\n const tenantClause = tenantWhereClause({\n hasField: collectionHasTenantField(collectionConfig, config.multiTenant.tenantField),\n tenantField: config.multiTenant.tenantField,\n tenantId: readCookie(req.headers?.get('cookie'), config.multiTenant.cookieName),\n })\n if (tenantClause) {\n andClauses.push(tenantClause)\n }\n\n const where: Where =\n andClauses.length === 0\n ? {}\n : andClauses.length === 1\n ? andClauses[0]\n : { and: andClauses }\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const result = await (req.payload.find as any)({\n collection: config.slugs.customers,\n limit,\n // Delegate isolation to collection access control. The multi-tenant\n // plugin enforces tenancy through the user's memberships, which the\n // cookie clause above structurally cannot express — with userCollection\n // set, tenancy lives on a `tenants` ARRAY, not a flat `tenant` field.\n overrideAccess: false,\n page,\n req,\n where,\n })\n\n return Response.json({\n docs: (result.docs as Record<string, unknown>[]).map((doc) => {\n const entry: Record<string, unknown> = {\n id: doc['id'],\n email: doc['email'] ?? '',\n }\n\n if (hasName) {\n entry['name'] = doc['name'] ?? ''\n }\n if (hasFirstName) {\n entry['firstName'] = doc['firstName'] ?? ''\n }\n if (hasLastName) {\n entry['lastName'] = doc['lastName'] ?? ''\n }\n if (hasPhone) {\n entry['phone'] = doc['phone'] ?? ''\n }\n\n return entry\n }),\n hasNextPage: result.hasNextPage,\n totalDocs: result.totalDocs,\n })\n },\n method: 'get',\n path: '/reservation-customer-search',\n }\n}\n"],"names":["collectionHasTenantField","readCookie","tenantWhereClause","isPrivilegedUser","privilegedRoles","getNamedFields","fields","names","Set","field","add","name","createCustomerSearchEndpoint","config","handler","req","user","Response","json","message","status","url","URL","search","searchParams","get","limitRaw","Number","pageRaw","limit","isFinite","Math","min","max","floor","page","collectionConfig","payload","collections","slugs","customers","availableFields","hasName","has","hasFirstName","hasLastName","hasPhone","andClauses","orClauses","push","contains","firstName","lastName","email","phone","or","userCollection","roleField","staffProvisioning","priv","length","not_in","tenantClause","hasField","multiTenant","tenantField","tenantId","headers","cookieName","where","and","result","find","collection","overrideAccess","docs","map","doc","entry","id","hasNextPage","totalDocs","method","path"],"mappings":"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"}
|
|
@@ -24,6 +24,7 @@ import { getEffectiveTenantTimezone } from '../utilities/tenantTimezone.js';
|
|
|
24
24
|
const timeZone = await getEffectiveTenantTimezone({
|
|
25
25
|
globalTimezone: config.timezone,
|
|
26
26
|
payload: req.payload,
|
|
27
|
+
req,
|
|
27
28
|
scopedCollection: reservationsCollection,
|
|
28
29
|
tenantField: config.multiTenant.tenantField,
|
|
29
30
|
tenantId: readCookie(req.headers?.get('cookie'), config.multiTenant.cookieName),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/endpoints/effectiveTimezone.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { readCookie } from '../utilities/tenantFilter.js'\nimport { getEffectiveTenantTimezone } from '../utilities/tenantTimezone.js'\n\n/**\n * Resolve the effective business timezone for the current request's selected\n * tenant (tenant zone → global → UTC), read from the tenant cookie. The custom\n * admin calendar calls this so its day-boundary rendering matches the selected\n * tenant — the client can't resolve it itself because the tenant→timezone map\n * lives server-side and the tenant collection slug is only known at request time.\n *\n * Plain (non-multiTenant) installs always get `config.timezone` here, with no\n * tenant DB read.\n */\nexport function createEffectiveTimezoneEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n // Staff/admin-only view data — require an authenticated user.\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const reservationsCollection = req.payload.config.collections?.find(\n (c) => c.slug === config.slugs.reservations,\n )\n const timeZone = await getEffectiveTenantTimezone({\n globalTimezone: config.timezone,\n payload: req.payload,\n scopedCollection: reservationsCollection as { fields?: unknown[] } | undefined,\n tenantField: config.multiTenant.tenantField,\n tenantId: readCookie(req.headers?.get('cookie'), config.multiTenant.cookieName),\n timezoneField: config.multiTenant.timezoneField,\n })\n\n return Response.json({ timeZone })\n },\n method: 'get',\n path: '/reserve/effective-timezone',\n }\n}\n"],"names":["readCookie","getEffectiveTenantTimezone","createEffectiveTimezoneEndpoint","config","handler","req","user","Response","json","error","status","reservationsCollection","payload","collections","find","c","slug","slugs","reservations","timeZone","globalTimezone","timezone","scopedCollection","tenantField","multiTenant","tenantId","headers","get","cookieName","timezoneField","method","path"],"mappings":"AAIA,SAASA,UAAU,QAAQ,+BAA8B;AACzD,SAASC,0BAA0B,QAAQ,iCAAgC;AAE3E;;;;;;;;;CASC,GACD,OAAO,SAASC,gCACdC,MAAuC;IAEvC,OAAO;QACLC,SAAS,OAAOC;YACd,8DAA8D;YAC9D,IAAI,CAACA,IAAIC,IAAI,EAAE;gBACb,OAAOC,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAe,GAAG;oBAAEC,QAAQ;gBAAI;YAChE;YAEA,MAAMC,yBAAyBN,IAAIO,OAAO,CAACT,MAAM,CAACU,WAAW,EAAEC,KAC7D,CAACC,IAAMA,EAAEC,IAAI,KAAKb,OAAOc,KAAK,CAACC,YAAY;YAE7C,MAAMC,WAAW,MAAMlB,2BAA2B;gBAChDmB,gBAAgBjB,OAAOkB,QAAQ;gBAC/BT,SAASP,IAAIO,OAAO;
|
|
1
|
+
{"version":3,"sources":["../../src/endpoints/effectiveTimezone.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { readCookie } from '../utilities/tenantFilter.js'\nimport { getEffectiveTenantTimezone } from '../utilities/tenantTimezone.js'\n\n/**\n * Resolve the effective business timezone for the current request's selected\n * tenant (tenant zone → global → UTC), read from the tenant cookie. The custom\n * admin calendar calls this so its day-boundary rendering matches the selected\n * tenant — the client can't resolve it itself because the tenant→timezone map\n * lives server-side and the tenant collection slug is only known at request time.\n *\n * Plain (non-multiTenant) installs always get `config.timezone` here, with no\n * tenant DB read.\n */\nexport function createEffectiveTimezoneEndpoint(\n config: ResolvedReservationPluginConfig,\n): Endpoint {\n return {\n handler: async (req) => {\n // Staff/admin-only view data — require an authenticated user.\n if (!req.user) {\n return Response.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const reservationsCollection = req.payload.config.collections?.find(\n (c) => c.slug === config.slugs.reservations,\n )\n const timeZone = await getEffectiveTenantTimezone({\n globalTimezone: config.timezone,\n payload: req.payload,\n req,\n scopedCollection: reservationsCollection as { fields?: unknown[] } | undefined,\n tenantField: config.multiTenant.tenantField,\n tenantId: readCookie(req.headers?.get('cookie'), config.multiTenant.cookieName),\n timezoneField: config.multiTenant.timezoneField,\n })\n\n return Response.json({ timeZone })\n },\n method: 'get',\n path: '/reserve/effective-timezone',\n }\n}\n"],"names":["readCookie","getEffectiveTenantTimezone","createEffectiveTimezoneEndpoint","config","handler","req","user","Response","json","error","status","reservationsCollection","payload","collections","find","c","slug","slugs","reservations","timeZone","globalTimezone","timezone","scopedCollection","tenantField","multiTenant","tenantId","headers","get","cookieName","timezoneField","method","path"],"mappings":"AAIA,SAASA,UAAU,QAAQ,+BAA8B;AACzD,SAASC,0BAA0B,QAAQ,iCAAgC;AAE3E;;;;;;;;;CASC,GACD,OAAO,SAASC,gCACdC,MAAuC;IAEvC,OAAO;QACLC,SAAS,OAAOC;YACd,8DAA8D;YAC9D,IAAI,CAACA,IAAIC,IAAI,EAAE;gBACb,OAAOC,SAASC,IAAI,CAAC;oBAAEC,OAAO;gBAAe,GAAG;oBAAEC,QAAQ;gBAAI;YAChE;YAEA,MAAMC,yBAAyBN,IAAIO,OAAO,CAACT,MAAM,CAACU,WAAW,EAAEC,KAC7D,CAACC,IAAMA,EAAEC,IAAI,KAAKb,OAAOc,KAAK,CAACC,YAAY;YAE7C,MAAMC,WAAW,MAAMlB,2BAA2B;gBAChDmB,gBAAgBjB,OAAOkB,QAAQ;gBAC/BT,SAASP,IAAIO,OAAO;gBACpBP;gBACAiB,kBAAkBX;gBAClBY,aAAapB,OAAOqB,WAAW,CAACD,WAAW;gBAC3CE,UAAUzB,WAAWK,IAAIqB,OAAO,EAAEC,IAAI,WAAWxB,OAAOqB,WAAW,CAACI,UAAU;gBAC9EC,eAAe1B,OAAOqB,WAAW,CAACK,aAAa;YACjD;YAEA,OAAOtB,SAASC,IAAI,CAAC;gBAAEW;YAAS;QAClC;QACAW,QAAQ;QACRC,MAAM;IACR;AACF"}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { getAvailableSlots } from '../services/AvailabilityService.js';
|
|
2
|
+
import { createReserveDebug } from '../utilities/reserveDebug.js';
|
|
2
3
|
import { extractId, mergeResourceIds } from '../utilities/resolveRequiredResources.js';
|
|
3
4
|
import { getDayKeyInTimezone, isValidDayKey } from '../utilities/timezoneUtils.js';
|
|
4
5
|
export function createGetSlotsEndpoint(config) {
|
|
5
6
|
return {
|
|
6
7
|
handler: async (req)=>{
|
|
8
|
+
const dbg = createReserveDebug(req.payload.logger, config.debug);
|
|
7
9
|
const url = new URL(req.url);
|
|
8
10
|
const date = url.searchParams.get('date');
|
|
9
11
|
const resource = url.searchParams.get('resource');
|
|
@@ -58,6 +60,9 @@ export function createGetSlotsEndpoint(config) {
|
|
|
58
60
|
id: service,
|
|
59
61
|
collection: config.slugs.services,
|
|
60
62
|
depth: 0,
|
|
63
|
+
// Skip the resources join — internal logic never reads it, and without this
|
|
64
|
+
// every service read becomes an aggregation with a $lookup.
|
|
65
|
+
joins: false,
|
|
61
66
|
req
|
|
62
67
|
}).catch(()=>null);
|
|
63
68
|
if (!svcDoc) {
|
|
@@ -85,11 +90,24 @@ export function createGetSlotsEndpoint(config) {
|
|
|
85
90
|
}
|
|
86
91
|
const requiredIds = (svcDoc?.requiredResources ?? []).map((r)=>extractId(r)).filter((r)=>r !== undefined);
|
|
87
92
|
const resourceIds = mergeResourceIds(callerIds, requiredIds);
|
|
88
|
-
|
|
93
|
+
dbg.dbg('request', {
|
|
94
|
+
date: dayKey,
|
|
95
|
+
endpoint: 'slots',
|
|
96
|
+
guestCount,
|
|
97
|
+
resourceIds,
|
|
98
|
+
serviceId: service,
|
|
99
|
+
timeZone: config.timezone
|
|
100
|
+
});
|
|
101
|
+
const { reason, slots } = await getAvailableSlots({
|
|
89
102
|
blockingStatuses: config.statusMachine.blockingStatuses,
|
|
90
103
|
date: dayKey,
|
|
104
|
+
debug: dbg,
|
|
105
|
+
enforceActive: config.enforceActive,
|
|
91
106
|
getExternalBusy: config.getExternalBusy,
|
|
92
107
|
guestCount,
|
|
108
|
+
// Held slots are busy. Omitting this advertises a slot the write path
|
|
109
|
+
// will reject with a 409.
|
|
110
|
+
holdsSlug: config.slotHolds.enabled ? config.slugs.holds : undefined,
|
|
93
111
|
payload: req.payload,
|
|
94
112
|
req,
|
|
95
113
|
reservationSlug: config.slugs.reservations,
|
|
@@ -100,9 +118,17 @@ export function createGetSlotsEndpoint(config) {
|
|
|
100
118
|
serviceSlug: config.slugs.services,
|
|
101
119
|
timeZone: config.timezone
|
|
102
120
|
});
|
|
121
|
+
dbg.dbg('response', {
|
|
122
|
+
endpoint: 'slots',
|
|
123
|
+
reason,
|
|
124
|
+
slotCount: slots.length
|
|
125
|
+
});
|
|
103
126
|
return Response.json({
|
|
104
127
|
date,
|
|
105
128
|
guestCount,
|
|
129
|
+
...reason ? {
|
|
130
|
+
reason
|
|
131
|
+
} : {},
|
|
106
132
|
slots: slots.map((s)=>({
|
|
107
133
|
end: s.end.toISOString(),
|
|
108
134
|
start: s.start.toISOString()
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/endpoints/getSlots.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { getAvailableSlots } from '../services/AvailabilityService.js'\nimport { extractId, mergeResourceIds } from '../utilities/resolveRequiredResources.js'\nimport { getDayKeyInTimezone, isValidDayKey } from '../utilities/timezoneUtils.js'\n\nexport function createGetSlotsEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const url = new URL(req.url!)\n const date = url.searchParams.get('date')\n const resource = url.searchParams.get('resource')\n const service = url.searchParams.get('service')\n\n if (!date || !resource || !service) {\n return Response.json(\n { error: 'Missing required query params: resource, date, service' },\n { status: 400 },\n )\n }\n\n // YYYY-MM-DD is taken as a business-TZ calendar day verbatim; any other\n // parseable date is re-keyed into the business timezone. Never\n // `new Date('YYYY-MM-DD')` — that pins the day to UTC midnight.\n let dayKey: string\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(date)) {\n if (!isValidDayKey(date)) {\n return Response.json(\n { error: 'Invalid date format. Expected YYYY-MM-DD' },\n { status: 400 },\n )\n }\n dayKey = date\n } else {\n const parsed = new Date(date)\n if (isNaN(parsed.getTime())) {\n return Response.json(\n { error: 'Invalid date format. Expected YYYY-MM-DD' },\n { status: 400 },\n )\n }\n dayKey = getDayKeyInTimezone(parsed, config.timezone)\n }\n\n const guestCountRaw = Number(url.searchParams.get('guestCount') ?? '1')\n if (!Number.isFinite(guestCountRaw)) {\n return Response.json({ error: 'Invalid guestCount' }, { status: 400 })\n }\n const guestCount = Math.max(Math.floor(guestCountRaw), 1)\n\n // Resolve required resource set: caller resource(s) ∪ service.requiredResources\n const explicit = url.searchParams.get('resources')\n const callerIds = explicit ? explicit.split(',').map((s) => s.trim()).filter(Boolean) : [resource]\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const svcDoc = await (req.payload.findByID as any)({\n id: service,\n collection: config.slugs.services,\n depth: 0,\n req,\n }).catch(() => null)\n if (!svcDoc) {\n return Response.json({ error: 'Service not found' }, { status: 404 })\n }\n\n // Validate the primary resource id up front — a malformed id in a query\n // surfaces as an adapter cast error (500) instead of a clean 404.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const resourceDoc = await (req.payload.findByID as any)({\n id: resource,\n collection: config.slugs.resources,\n depth: 0,\n req,\n }).catch(() => null)\n if (!resourceDoc) {\n return Response.json({ error: 'Resource not found' }, { status: 404 })\n }\n const requiredIds = ((svcDoc?.requiredResources as unknown[]) ?? [])\n .map((r) => extractId(r))\n .filter((r): r is number | string => r !== undefined)\n const resourceIds = mergeResourceIds(callerIds, requiredIds)\n\n const slots = await getAvailableSlots({\n blockingStatuses: config.statusMachine.blockingStatuses,\n date: dayKey,\n getExternalBusy: config.getExternalBusy,\n guestCount,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceIds,\n resourceSlug: config.slugs.resources,\n scheduleSlug: config.slugs.schedules,\n serviceId: service,\n serviceSlug: config.slugs.services,\n timeZone: config.timezone,\n })\n\n return Response.json({\n date,\n guestCount,\n slots: slots.map((s) => ({ end: s.end.toISOString(), start: s.start.toISOString() })),\n })\n },\n method: 'get',\n path: '/reserve/slots',\n }\n}\n"],"names":["getAvailableSlots","extractId","mergeResourceIds","getDayKeyInTimezone","isValidDayKey","createGetSlotsEndpoint","config","handler","req","url","URL","date","searchParams","get","resource","service","Response","json","error","status","dayKey","test","parsed","Date","isNaN","getTime","timezone","guestCountRaw","Number","isFinite","guestCount","Math","max","floor","explicit","callerIds","split","map","s","trim","filter","Boolean","svcDoc","
|
|
1
|
+
{"version":3,"sources":["../../src/endpoints/getSlots.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { getAvailableSlots } from '../services/AvailabilityService.js'\nimport { createReserveDebug } from '../utilities/reserveDebug.js'\nimport { extractId, mergeResourceIds } from '../utilities/resolveRequiredResources.js'\nimport { getDayKeyInTimezone, isValidDayKey } from '../utilities/timezoneUtils.js'\n\nexport function createGetSlotsEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const dbg = createReserveDebug(req.payload.logger, config.debug)\n const url = new URL(req.url!)\n const date = url.searchParams.get('date')\n const resource = url.searchParams.get('resource')\n const service = url.searchParams.get('service')\n\n if (!date || !resource || !service) {\n return Response.json(\n { error: 'Missing required query params: resource, date, service' },\n { status: 400 },\n )\n }\n\n // YYYY-MM-DD is taken as a business-TZ calendar day verbatim; any other\n // parseable date is re-keyed into the business timezone. Never\n // `new Date('YYYY-MM-DD')` — that pins the day to UTC midnight.\n let dayKey: string\n if (/^\\d{4}-\\d{2}-\\d{2}$/.test(date)) {\n if (!isValidDayKey(date)) {\n return Response.json(\n { error: 'Invalid date format. Expected YYYY-MM-DD' },\n { status: 400 },\n )\n }\n dayKey = date\n } else {\n const parsed = new Date(date)\n if (isNaN(parsed.getTime())) {\n return Response.json(\n { error: 'Invalid date format. Expected YYYY-MM-DD' },\n { status: 400 },\n )\n }\n dayKey = getDayKeyInTimezone(parsed, config.timezone)\n }\n\n const guestCountRaw = Number(url.searchParams.get('guestCount') ?? '1')\n if (!Number.isFinite(guestCountRaw)) {\n return Response.json({ error: 'Invalid guestCount' }, { status: 400 })\n }\n const guestCount = Math.max(Math.floor(guestCountRaw), 1)\n\n // Resolve required resource set: caller resource(s) ∪ service.requiredResources\n const explicit = url.searchParams.get('resources')\n const callerIds = explicit ? explicit.split(',').map((s) => s.trim()).filter(Boolean) : [resource]\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const svcDoc = await (req.payload.findByID as any)({\n id: service,\n collection: config.slugs.services,\n depth: 0,\n // Skip the resources join — internal logic never reads it, and without this\n // every service read becomes an aggregation with a $lookup.\n joins: false,\n req,\n }).catch(() => null)\n if (!svcDoc) {\n return Response.json({ error: 'Service not found' }, { status: 404 })\n }\n\n // Validate the primary resource id up front — a malformed id in a query\n // surfaces as an adapter cast error (500) instead of a clean 404.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const resourceDoc = await (req.payload.findByID as any)({\n id: resource,\n collection: config.slugs.resources,\n depth: 0,\n req,\n }).catch(() => null)\n if (!resourceDoc) {\n return Response.json({ error: 'Resource not found' }, { status: 404 })\n }\n const requiredIds = ((svcDoc?.requiredResources as unknown[]) ?? [])\n .map((r) => extractId(r))\n .filter((r): r is number | string => r !== undefined)\n const resourceIds = mergeResourceIds(callerIds, requiredIds)\n\n dbg.dbg('request', {\n date: dayKey,\n endpoint: 'slots',\n guestCount,\n resourceIds,\n serviceId: service,\n timeZone: config.timezone,\n })\n\n const { reason, slots } = await getAvailableSlots({\n blockingStatuses: config.statusMachine.blockingStatuses,\n date: dayKey,\n debug: dbg,\n enforceActive: config.enforceActive,\n getExternalBusy: config.getExternalBusy,\n guestCount,\n // Held slots are busy. Omitting this advertises a slot the write path\n // will reject with a 409.\n holdsSlug: config.slotHolds.enabled ? config.slugs.holds : undefined,\n payload: req.payload,\n req,\n reservationSlug: config.slugs.reservations,\n resourceIds,\n resourceSlug: config.slugs.resources,\n scheduleSlug: config.slugs.schedules,\n serviceId: service,\n serviceSlug: config.slugs.services,\n timeZone: config.timezone,\n })\n\n dbg.dbg('response', { endpoint: 'slots', reason, slotCount: slots.length })\n\n return Response.json({\n date,\n guestCount,\n ...(reason ? { reason } : {}),\n slots: slots.map((s) => ({ end: s.end.toISOString(), start: s.start.toISOString() })),\n })\n },\n method: 'get',\n path: '/reserve/slots',\n }\n}\n"],"names":["getAvailableSlots","createReserveDebug","extractId","mergeResourceIds","getDayKeyInTimezone","isValidDayKey","createGetSlotsEndpoint","config","handler","req","dbg","payload","logger","debug","url","URL","date","searchParams","get","resource","service","Response","json","error","status","dayKey","test","parsed","Date","isNaN","getTime","timezone","guestCountRaw","Number","isFinite","guestCount","Math","max","floor","explicit","callerIds","split","map","s","trim","filter","Boolean","svcDoc","findByID","id","collection","slugs","services","depth","joins","catch","resourceDoc","resources","requiredIds","requiredResources","r","undefined","resourceIds","endpoint","serviceId","timeZone","reason","slots","blockingStatuses","statusMachine","enforceActive","getExternalBusy","holdsSlug","slotHolds","enabled","holds","reservationSlug","reservations","resourceSlug","scheduleSlug","schedules","serviceSlug","slotCount","length","end","toISOString","start","method","path"],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload';
|
|
2
|
+
import type { ResolvedReservationPluginConfig } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Claim a slot while the caller completes checkout.
|
|
5
|
+
*
|
|
6
|
+
* Retried for the same reason a booking is: taking a hold writes the resource's
|
|
7
|
+
* bookingLock, so two simultaneous callers contend on one document and MongoDB
|
|
8
|
+
* aborts the loser rather than making it wait. `takeHold` therefore RETHROWS a
|
|
9
|
+
* transient conflict instead of returning it — the wrapper below only retries a
|
|
10
|
+
* rejected promise.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createHoldSlotEndpoint(config: ResolvedReservationPluginConfig): Endpoint;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { takeHold } from '../services/HoldService.js';
|
|
2
|
+
import { isTransientWriteConflict, retryOnWriteConflict } from '../utilities/retryOnWriteConflict.js';
|
|
3
|
+
/**
|
|
4
|
+
* HTTP status per refusal reason. Exhaustive over the closed
|
|
5
|
+
* {@link HoldRefusalReason} union on purpose — a new reason cannot be added
|
|
6
|
+
* without deciding its status, which is how every failure used to collapse into
|
|
7
|
+
* a single 409 carrying an internal message.
|
|
8
|
+
*/ const REFUSAL_STATUS = {
|
|
9
|
+
resource_not_found: 404,
|
|
10
|
+
service_inactive: 409,
|
|
11
|
+
service_not_found: 404,
|
|
12
|
+
slot_taken: 409
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Claim a slot while the caller completes checkout.
|
|
16
|
+
*
|
|
17
|
+
* Retried for the same reason a booking is: taking a hold writes the resource's
|
|
18
|
+
* bookingLock, so two simultaneous callers contend on one document and MongoDB
|
|
19
|
+
* aborts the loser rather than making it wait. `takeHold` therefore RETHROWS a
|
|
20
|
+
* transient conflict instead of returning it — the wrapper below only retries a
|
|
21
|
+
* rejected promise.
|
|
22
|
+
*/ export function createHoldSlotEndpoint(config) {
|
|
23
|
+
return {
|
|
24
|
+
handler: async (req)=>{
|
|
25
|
+
const body = await req.json?.();
|
|
26
|
+
const resource = body.resource;
|
|
27
|
+
const service = body.service;
|
|
28
|
+
const startTime = body.startTime;
|
|
29
|
+
if (!resource || !service || !startTime) {
|
|
30
|
+
return Response.json({
|
|
31
|
+
error: 'resource, service and startTime are required'
|
|
32
|
+
}, {
|
|
33
|
+
status: 400
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const parsedStart = new Date(startTime);
|
|
37
|
+
if (isNaN(parsedStart.getTime())) {
|
|
38
|
+
return Response.json({
|
|
39
|
+
error: 'startTime is not a valid date'
|
|
40
|
+
}, {
|
|
41
|
+
status: 400
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// `endTime` reaches `computeEndTime` unchanged for a `flexible`-duration
|
|
45
|
+
// service, so an unparseable one became an Invalid Date whose
|
|
46
|
+
// `.toISOString()` threw a RangeError deep inside `takeHold` — neither a
|
|
47
|
+
// transient conflict nor a ValidationError, so it correctly propagated as
|
|
48
|
+
// a 500 from an endpoint reachable without authentication. Bad input is a
|
|
49
|
+
// 400, and it is cheaper to say so here.
|
|
50
|
+
let parsedEnd;
|
|
51
|
+
if (body.endTime !== undefined && body.endTime !== null) {
|
|
52
|
+
parsedEnd = new Date(body.endTime);
|
|
53
|
+
if (isNaN(parsedEnd.getTime())) {
|
|
54
|
+
return Response.json({
|
|
55
|
+
error: 'endTime is not a valid date'
|
|
56
|
+
}, {
|
|
57
|
+
status: 400
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// `guestCount` is `min: 1` on the collection, and the field-level failure
|
|
62
|
+
// it raises is a Payload ValidationError — indistinguishable inside
|
|
63
|
+
// `takeHold` from the ValidationError `validateHoldSlot` raises for genuine
|
|
64
|
+
// unavailability, so `{ guestCount: 0 }` answered `409 slot_taken`: a
|
|
65
|
+
// well-behaved client is told the slot is gone and to stop retrying,
|
|
66
|
+
// for a malformed request. Rejecting it here keeps that 409 meaning only
|
|
67
|
+
// what it says.
|
|
68
|
+
let guestCount = 1;
|
|
69
|
+
if (body.guestCount !== undefined && body.guestCount !== null) {
|
|
70
|
+
const raw = body.guestCount;
|
|
71
|
+
if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1) {
|
|
72
|
+
return Response.json({
|
|
73
|
+
error: 'guestCount must be an integer of 1 or more'
|
|
74
|
+
}, {
|
|
75
|
+
status: 400
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
guestCount = raw;
|
|
79
|
+
}
|
|
80
|
+
let result;
|
|
81
|
+
try {
|
|
82
|
+
result = await retryOnWriteConflict(()=>takeHold({
|
|
83
|
+
config,
|
|
84
|
+
endTime: parsedEnd,
|
|
85
|
+
guestCount,
|
|
86
|
+
req,
|
|
87
|
+
resourceId: resource,
|
|
88
|
+
serviceId: service,
|
|
89
|
+
startTime: parsedStart
|
|
90
|
+
}), {
|
|
91
|
+
req
|
|
92
|
+
});
|
|
93
|
+
} catch (err) {
|
|
94
|
+
// A conflict that survived every attempt is contention, not a verdict on
|
|
95
|
+
// the slot — mirrors /reserve/book's mapping so a client can distinguish
|
|
96
|
+
// "try again" from "gone".
|
|
97
|
+
if (isTransientWriteConflict(err)) {
|
|
98
|
+
return Response.json({
|
|
99
|
+
error: 'That slot is being claimed by someone else. Please try again.',
|
|
100
|
+
retryable: true
|
|
101
|
+
}, {
|
|
102
|
+
status: 409
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
if (!result.ok) {
|
|
108
|
+
return Response.json({
|
|
109
|
+
error: result.reason
|
|
110
|
+
}, {
|
|
111
|
+
status: REFUSAL_STATUS[result.reason]
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return Response.json({
|
|
115
|
+
expiresAt: result.hold.expiresAt,
|
|
116
|
+
token: result.hold.token
|
|
117
|
+
}, {
|
|
118
|
+
status: 201
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
method: 'post',
|
|
122
|
+
path: '/reserve/hold'
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
//# sourceMappingURL=holdSlot.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/endpoints/holdSlot.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { HoldRefusalReason } from '../services/HoldService.js'\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { takeHold } from '../services/HoldService.js'\nimport {\n isTransientWriteConflict,\n retryOnWriteConflict,\n} from '../utilities/retryOnWriteConflict.js'\n\n/**\n * HTTP status per refusal reason. Exhaustive over the closed\n * {@link HoldRefusalReason} union on purpose — a new reason cannot be added\n * without deciding its status, which is how every failure used to collapse into\n * a single 409 carrying an internal message.\n */\nconst REFUSAL_STATUS: Record<HoldRefusalReason, number> = {\n resource_not_found: 404,\n service_inactive: 409,\n service_not_found: 404,\n slot_taken: 409,\n}\n\n/**\n * Claim a slot while the caller completes checkout.\n *\n * Retried for the same reason a booking is: taking a hold writes the resource's\n * bookingLock, so two simultaneous callers contend on one document and MongoDB\n * aborts the loser rather than making it wait. `takeHold` therefore RETHROWS a\n * transient conflict instead of returning it — the wrapper below only retries a\n * rejected promise.\n */\nexport function createHoldSlotEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const body = (await req.json?.()) as Record<string, unknown>\n\n const resource = body.resource as number | string | undefined\n const service = body.service as number | string | undefined\n const startTime = body.startTime as string | undefined\n\n if (!resource || !service || !startTime) {\n return Response.json(\n { error: 'resource, service and startTime are required' },\n { status: 400 },\n )\n }\n\n const parsedStart = new Date(startTime)\n if (isNaN(parsedStart.getTime())) {\n return Response.json({ error: 'startTime is not a valid date' }, { status: 400 })\n }\n\n // `endTime` reaches `computeEndTime` unchanged for a `flexible`-duration\n // service, so an unparseable one became an Invalid Date whose\n // `.toISOString()` threw a RangeError deep inside `takeHold` — neither a\n // transient conflict nor a ValidationError, so it correctly propagated as\n // a 500 from an endpoint reachable without authentication. Bad input is a\n // 400, and it is cheaper to say so here.\n let parsedEnd: Date | undefined\n if (body.endTime !== undefined && body.endTime !== null) {\n parsedEnd = new Date(body.endTime as string)\n if (isNaN(parsedEnd.getTime())) {\n return Response.json({ error: 'endTime is not a valid date' }, { status: 400 })\n }\n }\n\n // `guestCount` is `min: 1` on the collection, and the field-level failure\n // it raises is a Payload ValidationError — indistinguishable inside\n // `takeHold` from the ValidationError `validateHoldSlot` raises for genuine\n // unavailability, so `{ guestCount: 0 }` answered `409 slot_taken`: a\n // well-behaved client is told the slot is gone and to stop retrying,\n // for a malformed request. Rejecting it here keeps that 409 meaning only\n // what it says.\n let guestCount = 1\n if (body.guestCount !== undefined && body.guestCount !== null) {\n const raw = body.guestCount\n if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < 1) {\n return Response.json(\n { error: 'guestCount must be an integer of 1 or more' },\n { status: 400 },\n )\n }\n guestCount = raw\n }\n\n let result: Awaited<ReturnType<typeof takeHold>>\n try {\n result = await retryOnWriteConflict(\n () =>\n takeHold({\n config,\n endTime: parsedEnd,\n guestCount,\n req,\n resourceId: resource,\n serviceId: service,\n startTime: parsedStart,\n }),\n { req },\n )\n } catch (err) {\n // A conflict that survived every attempt is contention, not a verdict on\n // the slot — mirrors /reserve/book's mapping so a client can distinguish\n // \"try again\" from \"gone\".\n if (isTransientWriteConflict(err)) {\n return Response.json(\n {\n error: 'That slot is being claimed by someone else. Please try again.',\n retryable: true,\n },\n { status: 409 },\n )\n }\n throw err\n }\n\n if (!result.ok) {\n return Response.json({ error: result.reason }, { status: REFUSAL_STATUS[result.reason] })\n }\n\n return Response.json(\n { expiresAt: result.hold.expiresAt, token: result.hold.token },\n { status: 201 },\n )\n },\n method: 'post',\n path: '/reserve/hold',\n }\n}\n"],"names":["takeHold","isTransientWriteConflict","retryOnWriteConflict","REFUSAL_STATUS","resource_not_found","service_inactive","service_not_found","slot_taken","createHoldSlotEndpoint","config","handler","req","body","json","resource","service","startTime","Response","error","status","parsedStart","Date","isNaN","getTime","parsedEnd","endTime","undefined","guestCount","raw","Number","isInteger","result","resourceId","serviceId","err","retryable","ok","reason","expiresAt","hold","token","method","path"],"mappings":"AAKA,SAASA,QAAQ,QAAQ,6BAA4B;AACrD,SACEC,wBAAwB,EACxBC,oBAAoB,QACf,uCAAsC;AAE7C;;;;;CAKC,GACD,MAAMC,iBAAoD;IACxDC,oBAAoB;IACpBC,kBAAkB;IAClBC,mBAAmB;IACnBC,YAAY;AACd;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,uBAAuBC,MAAuC;IAC5E,OAAO;QACLC,SAAS,OAAOC;YACd,MAAMC,OAAQ,MAAMD,IAAIE,IAAI;YAE5B,MAAMC,WAAWF,KAAKE,QAAQ;YAC9B,MAAMC,UAAUH,KAAKG,OAAO;YAC5B,MAAMC,YAAYJ,KAAKI,SAAS;YAEhC,IAAI,CAACF,YAAY,CAACC,WAAW,CAACC,WAAW;gBACvC,OAAOC,SAASJ,IAAI,CAClB;oBAAEK,OAAO;gBAA+C,GACxD;oBAAEC,QAAQ;gBAAI;YAElB;YAEA,MAAMC,cAAc,IAAIC,KAAKL;YAC7B,IAAIM,MAAMF,YAAYG,OAAO,KAAK;gBAChC,OAAON,SAASJ,IAAI,CAAC;oBAAEK,OAAO;gBAAgC,GAAG;oBAAEC,QAAQ;gBAAI;YACjF;YAEA,yEAAyE;YACzE,8DAA8D;YAC9D,yEAAyE;YACzE,0EAA0E;YAC1E,0EAA0E;YAC1E,yCAAyC;YACzC,IAAIK;YACJ,IAAIZ,KAAKa,OAAO,KAAKC,aAAad,KAAKa,OAAO,KAAK,MAAM;gBACvDD,YAAY,IAAIH,KAAKT,KAAKa,OAAO;gBACjC,IAAIH,MAAME,UAAUD,OAAO,KAAK;oBAC9B,OAAON,SAASJ,IAAI,CAAC;wBAAEK,OAAO;oBAA8B,GAAG;wBAAEC,QAAQ;oBAAI;gBAC/E;YACF;YAEA,0EAA0E;YAC1E,oEAAoE;YACpE,4EAA4E;YAC5E,sEAAsE;YACtE,qEAAqE;YACrE,yEAAyE;YACzE,gBAAgB;YAChB,IAAIQ,aAAa;YACjB,IAAIf,KAAKe,UAAU,KAAKD,aAAad,KAAKe,UAAU,KAAK,MAAM;gBAC7D,MAAMC,MAAMhB,KAAKe,UAAU;gBAC3B,IAAI,OAAOC,QAAQ,YAAY,CAACC,OAAOC,SAAS,CAACF,QAAQA,MAAM,GAAG;oBAChE,OAAOX,SAASJ,IAAI,CAClB;wBAAEK,OAAO;oBAA6C,GACtD;wBAAEC,QAAQ;oBAAI;gBAElB;gBACAQ,aAAaC;YACf;YAEA,IAAIG;YACJ,IAAI;gBACFA,SAAS,MAAM7B,qBACb,IACEF,SAAS;wBACPS;wBACAgB,SAASD;wBACTG;wBACAhB;wBACAqB,YAAYlB;wBACZmB,WAAWlB;wBACXC,WAAWI;oBACb,IACF;oBAAET;gBAAI;YAEV,EAAE,OAAOuB,KAAK;gBACZ,yEAAyE;gBACzE,yEAAyE;gBACzE,2BAA2B;gBAC3B,IAAIjC,yBAAyBiC,MAAM;oBACjC,OAAOjB,SAASJ,IAAI,CAClB;wBACEK,OAAO;wBACPiB,WAAW;oBACb,GACA;wBAAEhB,QAAQ;oBAAI;gBAElB;gBACA,MAAMe;YACR;YAEA,IAAI,CAACH,OAAOK,EAAE,EAAE;gBACd,OAAOnB,SAASJ,IAAI,CAAC;oBAAEK,OAAOa,OAAOM,MAAM;gBAAC,GAAG;oBAAElB,QAAQhB,cAAc,CAAC4B,OAAOM,MAAM,CAAC;gBAAC;YACzF;YAEA,OAAOpB,SAASJ,IAAI,CAClB;gBAAEyB,WAAWP,OAAOQ,IAAI,CAACD,SAAS;gBAAEE,OAAOT,OAAOQ,IAAI,CAACC,KAAK;YAAC,GAC7D;gBAAErB,QAAQ;YAAI;QAElB;QACAsB,QAAQ;QACRC,MAAM;IACR;AACF"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload';
|
|
2
|
+
import type { ResolvedReservationPluginConfig } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Release a hold early. Idempotent by design: a client that retries after a
|
|
5
|
+
* network blip, or releases a hold that already expired and was swept, gets a
|
|
6
|
+
* 200 with released: 0 rather than an error it cannot act on.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createReleaseSlotEndpoint(config: ResolvedReservationPluginConfig): Endpoint;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { releaseHold } from '../services/HoldService.js';
|
|
2
|
+
/**
|
|
3
|
+
* Release a hold early. Idempotent by design: a client that retries after a
|
|
4
|
+
* network blip, or releases a hold that already expired and was swept, gets a
|
|
5
|
+
* 200 with released: 0 rather than an error it cannot act on.
|
|
6
|
+
*/ export function createReleaseSlotEndpoint(config) {
|
|
7
|
+
return {
|
|
8
|
+
handler: async (req)=>{
|
|
9
|
+
const body = await req.json?.();
|
|
10
|
+
const token = body.token;
|
|
11
|
+
if (!token) {
|
|
12
|
+
return Response.json({
|
|
13
|
+
error: 'token is required'
|
|
14
|
+
}, {
|
|
15
|
+
status: 400
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
const { released } = await releaseHold({
|
|
19
|
+
config,
|
|
20
|
+
req,
|
|
21
|
+
token
|
|
22
|
+
});
|
|
23
|
+
return Response.json({
|
|
24
|
+
released
|
|
25
|
+
}, {
|
|
26
|
+
status: 200
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
method: 'post',
|
|
30
|
+
path: '/reserve/hold/release'
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//# sourceMappingURL=releaseSlot.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/endpoints/releaseSlot.ts"],"sourcesContent":["import type { Endpoint } from 'payload'\n\nimport type { ResolvedReservationPluginConfig } from '../types.js'\n\nimport { releaseHold } from '../services/HoldService.js'\n\n/**\n * Release a hold early. Idempotent by design: a client that retries after a\n * network blip, or releases a hold that already expired and was swept, gets a\n * 200 with released: 0 rather than an error it cannot act on.\n */\nexport function createReleaseSlotEndpoint(config: ResolvedReservationPluginConfig): Endpoint {\n return {\n handler: async (req) => {\n const body = (await req.json?.()) as Record<string, unknown>\n const token = body.token as string | undefined\n\n if (!token) {\n return Response.json({ error: 'token is required' }, { status: 400 })\n }\n\n const { released } = await releaseHold({ config, req, token })\n return Response.json({ released }, { status: 200 })\n },\n method: 'post',\n path: '/reserve/hold/release',\n }\n}\n"],"names":["releaseHold","createReleaseSlotEndpoint","config","handler","req","body","json","token","Response","error","status","released","method","path"],"mappings":"AAIA,SAASA,WAAW,QAAQ,6BAA4B;AAExD;;;;CAIC,GACD,OAAO,SAASC,0BAA0BC,MAAuC;IAC/E,OAAO;QACLC,SAAS,OAAOC;YACd,MAAMC,OAAQ,MAAMD,IAAIE,IAAI;YAC5B,MAAMC,QAAQF,KAAKE,KAAK;YAExB,IAAI,CAACA,OAAO;gBACV,OAAOC,SAASF,IAAI,CAAC;oBAAEG,OAAO;gBAAoB,GAAG;oBAAEC,QAAQ;gBAAI;YACrE;YAEA,MAAM,EAAEC,QAAQ,EAAE,GAAG,MAAMX,YAAY;gBAAEE;gBAAQE;gBAAKG;YAAM;YAC5D,OAAOC,SAASF,IAAI,CAAC;gBAAEK;YAAS,GAAG;gBAAED,QAAQ;YAAI;QACnD;QACAE,QAAQ;QACRC,MAAM;IACR;AACF"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Endpoint, Payload, PayloadRequest } from 'payload';
|
|
2
2
|
import type { ExternalBusyInterval, GetExternalBusy, ResolvedReservationPluginConfig } from '../types.js';
|
|
3
|
+
import { type ReserveDebug } from '../utilities/reserveDebug.js';
|
|
3
4
|
type DayAvailability = {
|
|
4
5
|
date: string;
|
|
5
6
|
shiftWindows: Array<{
|
|
@@ -35,8 +36,11 @@ export type ResourceAvailability = {
|
|
|
35
36
|
};
|
|
36
37
|
export declare function buildResourceAvailability(params: {
|
|
37
38
|
blockingStatuses: string[];
|
|
39
|
+
debug?: ReserveDebug;
|
|
38
40
|
end: Date;
|
|
39
41
|
getExternalBusy?: GetExternalBusy;
|
|
42
|
+
/** Slot-holds slug; omit to ignore holds (i.e. when `slotHolds` is off). */
|
|
43
|
+
holdsSlug?: string;
|
|
40
44
|
payload: Payload;
|
|
41
45
|
req?: PayloadRequest;
|
|
42
46
|
reservationSlug: string;
|