@happyvertical/smrt-bookings 0.60.8 → 0.60.10
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/AGENTS.md +3 -0
- package/README.md +76 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +393 -46
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migration.d.ts +6 -0
- package/dist/migration.d.ts.map +1 -1
- package/dist/service.d.ts +18 -7
- package/dist/service.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +7 -7
- package/dist/smrt-knowledge.okf.md +48 -0
- package/dist/types.d.ts +89 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/windows.d.ts +29 -2
- package/dist/windows.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -99,6 +99,57 @@ async function migrateBookingsV1(db) {
|
|
|
99
99
|
for (const statement of statements) await tx.query(statement);
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
|
+
var v2 = [
|
|
103
|
+
`ALTER TABLE smrt_booking_offerings ADD COLUMN unit text`,
|
|
104
|
+
`UPDATE smrt_booking_offerings SET unit = CASE kind WHEN 'supervision-day' THEN 'day' ELSE 'hour' END`,
|
|
105
|
+
`ALTER TABLE smrt_booking_offerings ALTER COLUMN unit SET NOT NULL`,
|
|
106
|
+
`ALTER TABLE smrt_booking_offerings ADD COLUMN price_cents bigint`,
|
|
107
|
+
`ALTER TABLE smrt_booking_offerings ADD COLUMN currency text`,
|
|
108
|
+
`ALTER TABLE smrt_booking_offerings
|
|
109
|
+
ADD CONSTRAINT smrt_booking_offerings_unit CHECK (unit IN ('hour','day','week','month')),
|
|
110
|
+
ADD CONSTRAINT smrt_booking_offerings_supervision_unit CHECK (kind <> 'supervision-day' OR unit = 'day'),
|
|
111
|
+
ADD CONSTRAINT smrt_booking_offerings_price CHECK (price_cents IS NULL OR price_cents >= 0),
|
|
112
|
+
ADD CONSTRAINT smrt_booking_offerings_currency CHECK (currency IS NULL OR currency ~ '^[A-Z]{3}$'),
|
|
113
|
+
ADD CONSTRAINT smrt_booking_offerings_priced CHECK ((price_cents IS NULL) = (currency IS NULL))`,
|
|
114
|
+
`ALTER TABLE smrt_booking_offerings ADD COLUMN title text NOT NULL DEFAULT ''`,
|
|
115
|
+
`ALTER TABLE smrt_booking_offerings ADD COLUMN description text NOT NULL DEFAULT ''`,
|
|
116
|
+
`ALTER TABLE smrt_booking_offerings ADD COLUMN public boolean NOT NULL DEFAULT false`,
|
|
117
|
+
`ALTER TABLE smrt_booking_offerings ADD CONSTRAINT smrt_booking_offerings_title CHECK (char_length(title) <= 120)`,
|
|
118
|
+
`ALTER TABLE smrt_booking_offerings ADD CONSTRAINT smrt_booking_offerings_description CHECK (char_length(description) <= 2000)`,
|
|
119
|
+
`ALTER TABLE smrt_bookings ADD COLUMN unit text`,
|
|
120
|
+
`UPDATE smrt_bookings b SET unit = o.unit FROM smrt_booking_offerings o
|
|
121
|
+
WHERE o.tenant_id = b.tenant_id AND o.id = b.offering_id`,
|
|
122
|
+
`ALTER TABLE smrt_bookings ALTER COLUMN unit SET NOT NULL`,
|
|
123
|
+
`ALTER TABLE smrt_bookings ADD COLUMN quantity integer NOT NULL DEFAULT 1`,
|
|
124
|
+
`ALTER TABLE smrt_bookings
|
|
125
|
+
ADD CONSTRAINT smrt_bookings_unit CHECK (unit IN ('hour','day','week','month')),
|
|
126
|
+
ADD CONSTRAINT smrt_bookings_quantity CHECK (quantity >= 1 AND quantity <= 1000),
|
|
127
|
+
ADD CONSTRAINT smrt_bookings_supervision_quantity CHECK (kind <> 'supervision-day' OR (unit = 'day' AND quantity = 1))`,
|
|
128
|
+
`ALTER TABLE smrt_booking_proposals ADD COLUMN windows jsonb`,
|
|
129
|
+
`UPDATE smrt_booking_proposals SET windows = jsonb_build_array(coverage)`,
|
|
130
|
+
`ALTER TABLE smrt_booking_proposals ALTER COLUMN windows SET NOT NULL`,
|
|
131
|
+
`ALTER TABLE smrt_booking_proposals ADD COLUMN unit text`,
|
|
132
|
+
`UPDATE smrt_booking_proposals p SET unit = b.unit FROM smrt_bookings b
|
|
133
|
+
WHERE b.tenant_id = p.tenant_id AND b.id = p.booking_id`,
|
|
134
|
+
`ALTER TABLE smrt_booking_proposals ALTER COLUMN unit SET NOT NULL`,
|
|
135
|
+
`ALTER TABLE smrt_booking_proposals ADD COLUMN quantity integer NOT NULL DEFAULT 1`,
|
|
136
|
+
`ALTER TABLE smrt_booking_proposals ADD COLUMN unit_price_cents bigint`,
|
|
137
|
+
`ALTER TABLE smrt_booking_proposals ADD COLUMN currency text`,
|
|
138
|
+
`ALTER TABLE smrt_booking_proposals ADD COLUMN total_cents bigint`,
|
|
139
|
+
`ALTER TABLE smrt_booking_proposals
|
|
140
|
+
ADD CONSTRAINT smrt_booking_proposals_unit CHECK (unit IN ('hour','day','week','month')),
|
|
141
|
+
ADD CONSTRAINT smrt_booking_proposals_quantity CHECK (quantity >= 1 AND quantity <= 1000),
|
|
142
|
+
ADD CONSTRAINT smrt_booking_proposals_priced
|
|
143
|
+
CHECK ((unit_price_cents IS NULL) = (currency IS NULL) AND (unit_price_cents IS NULL) = (total_cents IS NULL)),
|
|
144
|
+
ADD CONSTRAINT smrt_booking_proposals_total CHECK (total_cents IS NULL OR total_cents = unit_price_cents * quantity),
|
|
145
|
+
ADD CONSTRAINT smrt_booking_proposals_windows CHECK (jsonb_typeof(windows) = 'array' AND jsonb_array_length(windows) >= 1)`
|
|
146
|
+
];
|
|
147
|
+
async function migrateBookingsV2(db) {
|
|
148
|
+
if (!db.transaction) throw new TypeError("Bookings requires a PostgreSQL transaction");
|
|
149
|
+
await db.transaction(async (tx) => {
|
|
150
|
+
for (const statement of v2) await tx.query(statement);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
102
153
|
//#endregion
|
|
103
154
|
//#region src/windows.ts
|
|
104
155
|
var WALL = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})([+-]\d{2}:\d{2})$/;
|
|
@@ -144,9 +195,6 @@ function resolveBookingWindow(window) {
|
|
|
144
195
|
endUtc
|
|
145
196
|
};
|
|
146
197
|
}
|
|
147
|
-
function elapsedHour(window) {
|
|
148
|
-
return Date.parse(window.endUtc) - Date.parse(window.startUtc) === 60 * MINUTE_MS;
|
|
149
|
-
}
|
|
150
198
|
function oneLocalDay(window) {
|
|
151
199
|
const startDate = window.start.slice(0, 10);
|
|
152
200
|
const endDate = window.end.slice(0, 10);
|
|
@@ -184,7 +232,7 @@ function declareWindows(zone, windows) {
|
|
|
184
232
|
function supervisionDeclared(candidate, windows) {
|
|
185
233
|
return windows.some((window) => window.supervision && candidate.start === window.start && candidate.end === window.end && candidate.zone === window.zone);
|
|
186
234
|
}
|
|
187
|
-
function
|
|
235
|
+
function remainingCapacity(candidate, declared, occupied) {
|
|
188
236
|
const boundaries = /* @__PURE__ */ new Map();
|
|
189
237
|
const add = (at, key, delta) => {
|
|
190
238
|
const event = boundaries.get(at) ?? {
|
|
@@ -206,23 +254,138 @@ function fitsCapacity(candidate, declared, occupied) {
|
|
|
206
254
|
let capacity = 0;
|
|
207
255
|
let shared = 0;
|
|
208
256
|
let exclusive = 0;
|
|
257
|
+
let remaining = Number.POSITIVE_INFINITY;
|
|
209
258
|
for (const at of [...boundaries.keys()].sort()) {
|
|
210
259
|
const delta = boundaries.get(at);
|
|
211
260
|
if (!delta) continue;
|
|
212
261
|
capacity += delta.capacity;
|
|
213
262
|
shared += delta.shared;
|
|
214
263
|
exclusive += delta.exclusive;
|
|
215
|
-
if (capacity < 1 || exclusive > 0 || (candidate.exclusive ? shared > 0 : shared >= capacity)) return
|
|
264
|
+
if (capacity < 1 || exclusive > 0 || (candidate.exclusive ? shared > 0 : shared >= capacity)) return 0;
|
|
265
|
+
remaining = Math.min(remaining, candidate.exclusive ? 1 : capacity - shared);
|
|
266
|
+
}
|
|
267
|
+
return Number.isFinite(remaining) ? remaining : 0;
|
|
268
|
+
}
|
|
269
|
+
function fitsCapacity(candidate, declared, occupied) {
|
|
270
|
+
return remainingCapacity(candidate, declared, occupied) >= 1;
|
|
271
|
+
}
|
|
272
|
+
function resolveCoverage(windows) {
|
|
273
|
+
if (!Array.isArray(windows) || windows.length < 1 || windows.length > 400) throw new BookingError("invalid", `Expected between 1 and 400 booking windows`);
|
|
274
|
+
const resolved = windows.map((window) => resolveBookingWindow(window)).sort((a, b) => a.startUtc.localeCompare(b.startUtc));
|
|
275
|
+
for (const [i, window] of resolved.entries()) {
|
|
276
|
+
if (window.zone !== resolved[0].zone) throw new BookingError("invalid", "Booking windows must share one zone");
|
|
277
|
+
if (i > 0 && resolved[i - 1].endUtc > window.startUtc) throw new BookingError("invalid", "Booking windows must not overlap");
|
|
278
|
+
}
|
|
279
|
+
const first = resolved[0];
|
|
280
|
+
const last = resolved[resolved.length - 1];
|
|
281
|
+
return {
|
|
282
|
+
windows: resolved,
|
|
283
|
+
span: {
|
|
284
|
+
zone: first.zone,
|
|
285
|
+
start: first.start,
|
|
286
|
+
end: last.end,
|
|
287
|
+
startUtc: first.startUtc,
|
|
288
|
+
endUtc: last.endUtc
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
function pad(value, width = 2) {
|
|
293
|
+
return String(value).padStart(width, "0");
|
|
294
|
+
}
|
|
295
|
+
function addLocalDays(date, days) {
|
|
296
|
+
const moved = /* @__PURE__ */ new Date(`${date}T00:00:00Z`);
|
|
297
|
+
moved.setUTCDate(moved.getUTCDate() + days);
|
|
298
|
+
return moved.toISOString().slice(0, 10);
|
|
299
|
+
}
|
|
300
|
+
function addLocalMonths(date, months) {
|
|
301
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
302
|
+
const index = year * 12 + (month - 1) + months;
|
|
303
|
+
const targetYear = Math.floor(index / 12);
|
|
304
|
+
const targetMonth = index % 12;
|
|
305
|
+
const length = new Date(Date.UTC(targetYear, targetMonth + 1, 0)).getUTCDate();
|
|
306
|
+
return `${pad(targetYear, 4)}-${pad(targetMonth + 1)}-${pad(Math.min(day, length))}`;
|
|
307
|
+
}
|
|
308
|
+
function localEnd(unit, quantity, start) {
|
|
309
|
+
if (unit === "day") return addLocalDays(start, quantity);
|
|
310
|
+
if (unit === "week") return addLocalDays(start, 7 * quantity);
|
|
311
|
+
return addLocalMonths(start, quantity);
|
|
312
|
+
}
|
|
313
|
+
function within(window, from, to) {
|
|
314
|
+
return window.start.slice(0, 10) >= from && (window.end.slice(0, 10) < to || window.end.slice(0, 16) === `${to}T00:00`);
|
|
315
|
+
}
|
|
316
|
+
function measures(unit, quantity, windows) {
|
|
317
|
+
if (!Number.isSafeInteger(quantity) || quantity < 1 || windows.length < 1) return false;
|
|
318
|
+
if (unit === "hour") return windows.reduce((sum, w) => sum + (Date.parse(w.endUtc) - Date.parse(w.startUtc)), 0) === quantity * 60 * MINUTE_MS;
|
|
319
|
+
if (unit !== "day" && unit !== "week" && unit !== "month") return false;
|
|
320
|
+
const from = windows[0].start.slice(0, 10);
|
|
321
|
+
const to = localEnd(unit, quantity, from);
|
|
322
|
+
if (!windows.every((window) => within(window, from, to))) return false;
|
|
323
|
+
if (unit === "day") {
|
|
324
|
+
const days = /* @__PURE__ */ new Set();
|
|
325
|
+
for (const window of windows) {
|
|
326
|
+
const last = window.end.slice(0, 16) === `${window.end.slice(0, 10)}T00:00` && window.end.slice(0, 10) > window.start.slice(0, 10) ? addLocalDays(window.end.slice(0, 10), -1) : window.end.slice(0, 10);
|
|
327
|
+
for (let day = window.start.slice(0, 10); day <= last; day = addLocalDays(day, 1)) days.add(day);
|
|
328
|
+
}
|
|
329
|
+
return days.size === quantity;
|
|
216
330
|
}
|
|
217
331
|
return true;
|
|
218
332
|
}
|
|
333
|
+
function formatWall(instantIso, zone) {
|
|
334
|
+
const time = Date.parse(instantIso);
|
|
335
|
+
if (!Number.isFinite(time)) throw new BookingError("invalid", "Invalid instant");
|
|
336
|
+
const parts = Object.fromEntries(formatter(zone).formatToParts(time).map(({ type, value }) => [type, value]));
|
|
337
|
+
const local = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day), Number(parts.hour), Number(parts.minute), Number(parts.second));
|
|
338
|
+
const offset = Math.round((local - time) / MINUTE_MS);
|
|
339
|
+
const sign = offset < 0 ? "-" : "+";
|
|
340
|
+
const abs = Math.abs(offset);
|
|
341
|
+
return `${pad(Number(parts.year), 4)}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
342
|
+
}
|
|
343
|
+
function windowFromInstants(zone, startMs, endMs) {
|
|
344
|
+
return resolveBookingWindow({
|
|
345
|
+
zone,
|
|
346
|
+
start: formatWall(new Date(startMs).toISOString(), zone),
|
|
347
|
+
end: formatWall(new Date(endMs).toISOString(), zone)
|
|
348
|
+
});
|
|
349
|
+
}
|
|
219
350
|
//#endregion
|
|
220
351
|
//#region src/service.ts
|
|
352
|
+
var CURRENCY = /^[A-Z]{3}$/;
|
|
353
|
+
var UNITS = [
|
|
354
|
+
"hour",
|
|
355
|
+
"day",
|
|
356
|
+
"week",
|
|
357
|
+
"month"
|
|
358
|
+
];
|
|
359
|
+
var MAX_QUANTITY = 1e3;
|
|
360
|
+
var MAX_SLOT_RANGE_MS = 400 * 864e5;
|
|
361
|
+
var HOUR_MS = 36e5;
|
|
362
|
+
var SLOT_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
363
|
+
function project(window) {
|
|
364
|
+
const { zone, start, end, startUtc, endUtc } = window;
|
|
365
|
+
return {
|
|
366
|
+
zone,
|
|
367
|
+
start,
|
|
368
|
+
end,
|
|
369
|
+
startUtc,
|
|
370
|
+
endUtc
|
|
371
|
+
};
|
|
372
|
+
}
|
|
221
373
|
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
222
374
|
function id(value) {
|
|
223
375
|
if (typeof value !== "string" || !UUID.test(value)) throw new BookingError("invalid", "Expected a UUID reference");
|
|
224
376
|
return value.toLowerCase();
|
|
225
377
|
}
|
|
378
|
+
function quantityOf(value, fallback = 1) {
|
|
379
|
+
const quantity = value === void 0 ? fallback : value;
|
|
380
|
+
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > MAX_QUANTITY) throw new BookingError("invalid", `Quantity must be an integer between 1 and ${MAX_QUANTITY}`);
|
|
381
|
+
return quantity;
|
|
382
|
+
}
|
|
383
|
+
function money(value) {
|
|
384
|
+
if (value === null) return null;
|
|
385
|
+
const amount = Number(value);
|
|
386
|
+
if (!Number.isSafeInteger(amount)) throw new BookingError("invalid", "Amount is not a safe integer");
|
|
387
|
+
return amount;
|
|
388
|
+
}
|
|
226
389
|
function pageBounds({ afterId, limit = 50 }) {
|
|
227
390
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new BookingError("invalid", "Page limit must be between 1 and 100");
|
|
228
391
|
return {
|
|
@@ -243,6 +406,8 @@ function bookingView(row, proposal) {
|
|
|
243
406
|
contactId: row.contact_id,
|
|
244
407
|
projectId: row.project_id,
|
|
245
408
|
kind: row.kind,
|
|
409
|
+
unit: row.unit,
|
|
410
|
+
quantity: row.quantity,
|
|
246
411
|
status: row.status,
|
|
247
412
|
proposal,
|
|
248
413
|
assentedProposalId: row.assented_proposal_id,
|
|
@@ -254,6 +419,12 @@ function proposalView(proposalId, row) {
|
|
|
254
419
|
id: proposalId,
|
|
255
420
|
providerId: row.provider_id,
|
|
256
421
|
window: JSON.parse(row.coverage),
|
|
422
|
+
windows: JSON.parse(row.windows),
|
|
423
|
+
unit: row.proposal_unit,
|
|
424
|
+
quantity: row.proposal_quantity,
|
|
425
|
+
unitPriceCents: money(row.unit_price_cents),
|
|
426
|
+
currency: row.currency,
|
|
427
|
+
totalCents: money(row.total_cents),
|
|
257
428
|
terms: JSON.parse(row.terms),
|
|
258
429
|
approvedPlan: row.approved_plan === null ? null : JSON.parse(row.approved_plan)
|
|
259
430
|
};
|
|
@@ -300,14 +471,35 @@ var BookingService = class {
|
|
|
300
471
|
const offeringId = id(input.offeringId);
|
|
301
472
|
const { kind, enabled } = input;
|
|
302
473
|
if (kind !== "developer-hour" && kind !== "supervision-day" || typeof enabled !== "boolean") throw new BookingError("invalid", "Invalid offering kind or enabled flag");
|
|
474
|
+
const unit = input.unit ?? (kind === "supervision-day" ? "day" : "hour");
|
|
475
|
+
if (!UNITS.includes(unit) || kind === "supervision-day" && unit !== "day") throw new BookingError("invalid", "Invalid offering unit; supervision is sold by the day");
|
|
476
|
+
const priceCents = input.priceCents ?? null;
|
|
477
|
+
const currency = input.currency ?? null;
|
|
478
|
+
if (priceCents !== null && (!Number.isSafeInteger(priceCents) || priceCents < 0) || currency !== null && (typeof currency !== "string" || !CURRENCY.test(currency)) || priceCents === null !== (currency === null)) throw new BookingError("invalid", "A price needs a nonnegative integer amount in minor units and an uppercase ISO 4217 currency");
|
|
479
|
+
const title = input.title ?? "";
|
|
480
|
+
const description = input.description ?? "";
|
|
481
|
+
const isPublic = input.public ?? false;
|
|
482
|
+
if (typeof title !== "string" || typeof description !== "string" || title.length > 120 || description.length > 2e3 || title.includes("\0") || description.includes("\0") || typeof isPublic !== "boolean") throw new BookingError("invalid", "Offering title (120), description (2000) and public flag are invalid");
|
|
303
483
|
return this.#write(context, "configure-offering", key, {
|
|
304
484
|
offeringId,
|
|
305
485
|
kind,
|
|
306
|
-
enabled
|
|
486
|
+
enabled,
|
|
487
|
+
...unit !== (kind === "supervision-day" ? "day" : "hour") ? { unit } : {},
|
|
488
|
+
...priceCents !== null ? {
|
|
489
|
+
priceCents,
|
|
490
|
+
currency
|
|
491
|
+
} : {},
|
|
492
|
+
...title !== "" ? { title } : {},
|
|
493
|
+
...description !== "" ? { description } : {},
|
|
494
|
+
...isPublic ? { public: true } : {}
|
|
307
495
|
}, operator, async (tx, ctx) => {
|
|
308
|
-
if (!(await tx.query(`INSERT INTO smrt_booking_offerings (tenant_id, id, kind,
|
|
309
|
-
|
|
310
|
-
|
|
496
|
+
if (!(await tx.query(`INSERT INTO smrt_booking_offerings (tenant_id, id, kind, unit, price_cents, currency, title, description, public, enabled)
|
|
497
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
498
|
+
ON CONFLICT (tenant_id, id) DO UPDATE
|
|
499
|
+
SET enabled = EXCLUDED.enabled, price_cents = EXCLUDED.price_cents, currency = EXCLUDED.currency,
|
|
500
|
+
title = EXCLUDED.title, description = EXCLUDED.description, public = EXCLUDED.public
|
|
501
|
+
WHERE smrt_booking_offerings.kind = EXCLUDED.kind AND smrt_booking_offerings.unit = EXCLUDED.unit
|
|
502
|
+
RETURNING id`, ctx.tenantId, offeringId, kind, unit, priceCents, currency, title, description, isPublic, enabled)).rows.length) throw new BookingError("conflict", "An offering kind or unit cannot change");
|
|
311
503
|
return { id: offeringId };
|
|
312
504
|
});
|
|
313
505
|
}
|
|
@@ -333,31 +525,36 @@ var BookingService = class {
|
|
|
333
525
|
const offeringId = id(input.offeringId);
|
|
334
526
|
const contactId = id(input.contactId);
|
|
335
527
|
const projectId = input.projectId === void 0 ? null : id(input.projectId);
|
|
528
|
+
const quantity = quantityOf(input.quantity);
|
|
336
529
|
return this.#write(context, "request", key, {
|
|
337
530
|
offeringId,
|
|
338
531
|
contactId,
|
|
339
|
-
projectId
|
|
532
|
+
projectId,
|
|
533
|
+
...quantity !== 1 ? { quantity } : {}
|
|
340
534
|
}, (access) => {
|
|
341
535
|
if (!access.operator) contact(access, contactId);
|
|
342
536
|
}, async (tx, ctx) => {
|
|
343
|
-
const { rows } = await tx.query("SELECT kind, enabled FROM smrt_booking_offerings WHERE tenant_id = ? AND id = ? FOR SHARE", ctx.tenantId, offeringId);
|
|
537
|
+
const { rows } = await tx.query("SELECT kind, unit, enabled FROM smrt_booking_offerings WHERE tenant_id = ? AND id = ? FOR SHARE", ctx.tenantId, offeringId);
|
|
344
538
|
if (!rows[0]?.enabled) throw new BookingError("not-found", "Offering is not available");
|
|
345
539
|
const kind = rows[0].kind;
|
|
346
|
-
|
|
540
|
+
const unit = rows[0].unit;
|
|
541
|
+
if (kind === "supervision-day" && (!projectId || quantity !== 1)) throw new BookingError("invalid", "A supervision request requires a project and a quantity of one");
|
|
347
542
|
await this.#verify(ctx, {
|
|
348
543
|
contactId,
|
|
349
544
|
projectId: projectId ?? void 0
|
|
350
545
|
});
|
|
351
546
|
const bookingId = randomUUID();
|
|
352
|
-
await tx.query(`INSERT INTO smrt_bookings (tenant_id, id, offering_id, contact_id, project_id, kind, status)
|
|
353
|
-
VALUES (?, ?, ?, ?, ?, ?, 'requested')`, ctx.tenantId, bookingId, offeringId, contactId, projectId, kind);
|
|
547
|
+
await tx.query(`INSERT INTO smrt_bookings (tenant_id, id, offering_id, contact_id, project_id, kind, unit, quantity, status)
|
|
548
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'requested')`, ctx.tenantId, bookingId, offeringId, contactId, projectId, kind, unit, quantity);
|
|
354
549
|
return { id: bookingId };
|
|
355
550
|
}, { createsBooking: true });
|
|
356
551
|
}
|
|
357
552
|
async propose(context, key, input) {
|
|
358
553
|
const bookingId = id(input.bookingId);
|
|
359
554
|
const providerId = id(input.providerId);
|
|
360
|
-
|
|
555
|
+
if (input.window === void 0 === (input.windows === void 0)) throw new BookingError("invalid", "Provide exactly one of window or windows");
|
|
556
|
+
const { windows, span } = resolveCoverage(input.windows ?? [input.window]);
|
|
557
|
+
const requestedQuantity = input.quantity === void 0 ? null : quantityOf(input.quantity);
|
|
361
558
|
const terms = {
|
|
362
559
|
revision: required(input.terms.revision),
|
|
363
560
|
text: required(input.terms.text, 65536)
|
|
@@ -370,22 +567,38 @@ var BookingService = class {
|
|
|
370
567
|
return this.#write(context, "propose", key, {
|
|
371
568
|
bookingId,
|
|
372
569
|
providerId,
|
|
373
|
-
window,
|
|
570
|
+
...windows.length === 1 ? { window: windows[0] } : { windows },
|
|
571
|
+
...requestedQuantity !== null ? { quantity: requestedQuantity } : {},
|
|
374
572
|
terms,
|
|
375
573
|
approvedPlan
|
|
376
574
|
}, operator, async (tx, ctx) => {
|
|
377
575
|
const row = await this.#booking(tx, ctx, bookingId, true);
|
|
378
576
|
pending(row);
|
|
379
|
-
|
|
577
|
+
const quantity = requestedQuantity ?? row.quantity;
|
|
578
|
+
if (row.kind === "supervision-day") {
|
|
579
|
+
if (!approvedPlan || quantity !== 1 || windows.length !== 1) throw new BookingError("invalid", "Supervision requires a plan version, one window and a quantity of one");
|
|
580
|
+
} else {
|
|
581
|
+
if (approvedPlan) throw new BookingError("invalid", "Dedicated time does not take an approved plan");
|
|
582
|
+
if (!measures(row.unit, quantity, windows)) throw new BookingError("invalid", "Proposed windows do not measure the requested quantity");
|
|
583
|
+
}
|
|
380
584
|
await this.#verify(ctx, {
|
|
381
585
|
...this.#refs(row),
|
|
382
586
|
providerId,
|
|
383
587
|
approvedPlan: approvedPlan ?? void 0
|
|
384
588
|
});
|
|
385
|
-
|
|
589
|
+
const availability = await this.#resource(tx, ctx, providerId, false);
|
|
590
|
+
for (const window of windows) declared(row.kind, window, availability);
|
|
591
|
+
const offering = await tx.query(`SELECT price_cents, currency FROM smrt_booking_offerings
|
|
592
|
+
WHERE tenant_id = ? AND id = ? FOR SHARE`, ctx.tenantId, row.offering_id);
|
|
593
|
+
const unitPriceCents = money(offering.rows[0]?.price_cents ?? null);
|
|
594
|
+
const currency = offering.rows[0]?.currency ?? null;
|
|
595
|
+
const totalCents = unitPriceCents === null ? null : unitPriceCents * quantity;
|
|
596
|
+
if (totalCents !== null && !Number.isSafeInteger(totalCents)) throw new BookingError("invalid", "Total price is too large");
|
|
386
597
|
const proposalId = randomUUID();
|
|
387
|
-
await tx.query(`INSERT INTO smrt_booking_proposals
|
|
388
|
-
|
|
598
|
+
await tx.query(`INSERT INTO smrt_booking_proposals
|
|
599
|
+
(tenant_id, booking_id, id, provider_id, coverage, windows, unit, quantity,
|
|
600
|
+
unit_price_cents, currency, total_cents, terms, approved_plan)
|
|
601
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ctx.tenantId, bookingId, proposalId, providerId, JSON.stringify(span), JSON.stringify(windows), row.unit, quantity, unitPriceCents, currency, totalCents, JSON.stringify(terms), approvedPlan && JSON.stringify(approvedPlan));
|
|
389
602
|
await tx.query(`UPDATE smrt_bookings SET status = 'proposed', proposal_id = ?, assented_proposal_id = NULL
|
|
390
603
|
WHERE tenant_id = ? AND id = ?`, proposalId, ctx.tenantId, bookingId);
|
|
391
604
|
return {
|
|
@@ -437,11 +650,14 @@ var BookingService = class {
|
|
|
437
650
|
});
|
|
438
651
|
const exclusive = row.kind === "developer-hour";
|
|
439
652
|
const window = proposal.window;
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
653
|
+
const occupied = await this.#allocations(tx, ctx, proposal.providerId, window);
|
|
654
|
+
for (const segment of proposal.windows) {
|
|
655
|
+
declared(row.kind, segment, availability);
|
|
656
|
+
if (!fitsCapacity({
|
|
657
|
+
...segment,
|
|
658
|
+
exclusive
|
|
659
|
+
}, availability, occupied)) throw new BookingError("unavailable", "The proposed window has insufficient capacity");
|
|
660
|
+
}
|
|
445
661
|
if (!(await tx.query(`INSERT INTO smrt_booking_allocations
|
|
446
662
|
(tenant_id, booking_id, proposal_id, provider_id, project_id, exclusive, start_at, end_at)
|
|
447
663
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
@@ -455,6 +671,26 @@ var BookingService = class {
|
|
|
455
671
|
};
|
|
456
672
|
}, { bookingId });
|
|
457
673
|
}
|
|
674
|
+
/** Operator command: bind a lead-bound booking to a project once one exists. */
|
|
675
|
+
async attachProject(context, key, input) {
|
|
676
|
+
const bookingId = id(input.bookingId);
|
|
677
|
+
const projectId = id(input.projectId);
|
|
678
|
+
return this.#write(context, "attach-project", key, {
|
|
679
|
+
bookingId,
|
|
680
|
+
projectId
|
|
681
|
+
}, operator, async (tx, ctx) => {
|
|
682
|
+
const row = await this.#booking(tx, ctx, bookingId, true);
|
|
683
|
+
if (row.status === "declined" || row.status === "cancelled") throw new BookingError("conflict", "Booking is no longer active");
|
|
684
|
+
if (row.project_id === projectId) return { id: bookingId };
|
|
685
|
+
if (row.project_id !== null) throw new BookingError("conflict", "Booking is already bound to a different project");
|
|
686
|
+
await this.#verify(ctx, {
|
|
687
|
+
contactId: row.contact_id,
|
|
688
|
+
projectId
|
|
689
|
+
});
|
|
690
|
+
await tx.query("UPDATE smrt_bookings SET project_id = ? WHERE tenant_id = ? AND id = ?", projectId, ctx.tenantId, bookingId);
|
|
691
|
+
return { id: bookingId };
|
|
692
|
+
}, { bookingId });
|
|
693
|
+
}
|
|
458
694
|
decline(context, key, input) {
|
|
459
695
|
return this.#close(context, "decline", key, input);
|
|
460
696
|
}
|
|
@@ -474,32 +710,137 @@ var BookingService = class {
|
|
|
474
710
|
const access = await this.#discover(ctx);
|
|
475
711
|
const { after, limit } = pageBounds(options);
|
|
476
712
|
const projectId = options.projectId === void 0 ? null : id(options.projectId);
|
|
477
|
-
const
|
|
713
|
+
const contactId = options.contactId === void 0 ? null : id(options.contactId);
|
|
714
|
+
if (!access.operator && contactId && contactId !== access.contactId) throw new BookingError("forbidden", "Only the named customer contact may be listed");
|
|
715
|
+
const rows = (await this.#db.query(`SELECT b.id, b.offering_id, b.contact_id, b.project_id, b.kind, b.unit, b.quantity, b.status,
|
|
478
716
|
b.proposal_id, b.assented_proposal_id, b.confirmed_proposal_id,
|
|
479
|
-
p.provider_id, p.coverage::text, p.
|
|
717
|
+
p.provider_id, p.coverage::text, p.windows::text, p.unit AS proposal_unit,
|
|
718
|
+
p.quantity AS proposal_quantity, p.unit_price_cents, p.currency, p.total_cents,
|
|
719
|
+
p.terms::text, p.approved_plan::text
|
|
480
720
|
FROM smrt_bookings b
|
|
481
721
|
LEFT JOIN smrt_booking_proposals p
|
|
482
722
|
ON p.tenant_id = b.tenant_id AND p.booking_id = b.id AND p.id = b.proposal_id
|
|
483
723
|
WHERE b.tenant_id = ? AND (?::boolean OR b.contact_id = ?)
|
|
484
724
|
AND (?::uuid IS NULL OR b.project_id = ?)
|
|
725
|
+
AND (?::uuid IS NULL OR b.contact_id = ?)
|
|
485
726
|
AND (?::uuid IS NULL OR b.id > ?)
|
|
486
|
-
ORDER BY b.id LIMIT ?`, ctx.tenantId, access.operator, access.contactId ?? null, projectId, projectId, after, after, limit + 1)).rows;
|
|
727
|
+
ORDER BY b.id LIMIT ?`, ctx.tenantId, access.operator, access.contactId ?? null, projectId, projectId, contactId, contactId, after, after, limit + 1)).rows;
|
|
487
728
|
const selected = page(rows, limit, (row) => row.id);
|
|
488
729
|
return {
|
|
489
730
|
items: selected.items.map((row) => bookingView(row, row.proposal_id ? proposalView(row.proposal_id, row) : null)),
|
|
490
731
|
nextCursor: selected.nextCursor
|
|
491
732
|
};
|
|
492
733
|
}
|
|
493
|
-
/**
|
|
734
|
+
/**
|
|
735
|
+
* Lists configured offerings. Operators see all, contacts see enabled
|
|
736
|
+
* offerings, and a reader with no contact mapping sees enabled public ones.
|
|
737
|
+
*/
|
|
494
738
|
async listOfferings(context, options = {}) {
|
|
495
739
|
const ctx = identity(context);
|
|
496
|
-
const access = await this.#
|
|
740
|
+
const access = await this.#access(ctx, "read");
|
|
497
741
|
const { after, limit } = pageBounds(options);
|
|
498
|
-
const { rows } = await this.#db.query(`SELECT id, kind,
|
|
499
|
-
|
|
742
|
+
const { rows } = await this.#db.query(`SELECT id, kind, unit, price_cents, currency, title, description, public, enabled
|
|
743
|
+
FROM smrt_booking_offerings
|
|
744
|
+
WHERE tenant_id = ? AND (?::boolean OR (enabled AND (?::boolean OR public)))
|
|
500
745
|
AND (?::uuid IS NULL OR id > ?)
|
|
501
|
-
ORDER BY id LIMIT ?`, ctx.tenantId, access.operator, after, after, limit + 1);
|
|
502
|
-
return page(rows
|
|
746
|
+
ORDER BY id LIMIT ?`, ctx.tenantId, access.operator, access.operator || access.contactId !== void 0, after, after, limit + 1);
|
|
747
|
+
return page(rows.map((row) => ({
|
|
748
|
+
id: row.id,
|
|
749
|
+
kind: row.kind,
|
|
750
|
+
unit: row.unit,
|
|
751
|
+
priceCents: money(row.price_cents),
|
|
752
|
+
currency: row.currency,
|
|
753
|
+
title: row.title,
|
|
754
|
+
description: row.description,
|
|
755
|
+
public: row.public,
|
|
756
|
+
enabled: row.enabled
|
|
757
|
+
})), limit, (row) => row.id);
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Lists candidate open slots for an offering: a pure read that reserves nothing
|
|
761
|
+
* and takes no retry key. Confirmation re-checks capacity under the provider lock.
|
|
762
|
+
* Offerings are discoverable by operators (all), contact-mapped readers (enabled)
|
|
763
|
+
* and contact-less readers (enabled and public); the host decides who may read.
|
|
764
|
+
*/
|
|
765
|
+
async listOpenSlots(context, options) {
|
|
766
|
+
const ctx = identity(context);
|
|
767
|
+
const access = await this.#access(ctx, "read");
|
|
768
|
+
const offeringId = id(options.offeringId);
|
|
769
|
+
const fromMs = Date.parse(options.from);
|
|
770
|
+
const toMs = Date.parse(options.to);
|
|
771
|
+
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs || toMs - fromMs > MAX_SLOT_RANGE_MS) throw new BookingError("invalid", "Expected from before to, at most 400 days apart");
|
|
772
|
+
const from = new Date(fromMs).toISOString();
|
|
773
|
+
const to = new Date(toMs).toISOString();
|
|
774
|
+
const quantity = quantityOf(options.quantity);
|
|
775
|
+
const providerId = options.providerId === void 0 ? null : id(options.providerId);
|
|
776
|
+
const { limit } = pageBounds({ limit: options.limit });
|
|
777
|
+
if (options.after !== void 0 && !SLOT_CURSOR.test(options.after)) throw new BookingError("invalid", "Invalid slot cursor");
|
|
778
|
+
const after = options.after?.replace(/\|.*$/, (provider) => provider.toLowerCase());
|
|
779
|
+
const offering = (await this.#db.query("SELECT kind, unit, enabled, public FROM smrt_booking_offerings WHERE tenant_id = ? AND id = ?", ctx.tenantId, offeringId)).rows[0];
|
|
780
|
+
if (!(offering && (access.operator || offering.enabled && (access.contactId !== void 0 || offering.public)))) throw new BookingError("not-found", "Offering is not available");
|
|
781
|
+
const kind = offering.kind;
|
|
782
|
+
const unit = offering.unit;
|
|
783
|
+
if (kind === "supervision-day" && quantity !== 1) throw new BookingError("invalid", "Supervision slots have a quantity of one");
|
|
784
|
+
const resources = await this.#db.query(`SELECT id, windows::text FROM smrt_booking_resources
|
|
785
|
+
WHERE tenant_id = ? AND (?::uuid IS NULL OR id = ?)`, ctx.tenantId, providerId, providerId);
|
|
786
|
+
const occupied = await this.#allocations(this.#db, ctx, providerId, {
|
|
787
|
+
startUtc: from,
|
|
788
|
+
endUtc: to
|
|
789
|
+
});
|
|
790
|
+
const slots = [];
|
|
791
|
+
for (const resource of resources.rows) {
|
|
792
|
+
const declaredWindows = JSON.parse(resource.windows).sort((a, b) => a.startUtc.localeCompare(b.startUtc));
|
|
793
|
+
const taken = occupied.filter((item) => item.providerId === resource.id);
|
|
794
|
+
const inRange = (w) => w.startUtc >= from && w.endUtc <= to;
|
|
795
|
+
const push = (windows, remaining) => {
|
|
796
|
+
if (remaining >= 1) slots.push({
|
|
797
|
+
providerId: resource.id,
|
|
798
|
+
offeringId,
|
|
799
|
+
unit,
|
|
800
|
+
quantity,
|
|
801
|
+
windows,
|
|
802
|
+
remainingCapacity: remaining
|
|
803
|
+
});
|
|
804
|
+
};
|
|
805
|
+
if (kind === "supervision-day") {
|
|
806
|
+
for (const w of declaredWindows) if (w.supervision && inRange(w)) push([project(w)], remainingCapacity({
|
|
807
|
+
...w,
|
|
808
|
+
exclusive: false
|
|
809
|
+
}, declaredWindows, taken));
|
|
810
|
+
} else if (unit === "hour") {
|
|
811
|
+
const length = quantity * HOUR_MS;
|
|
812
|
+
for (const w of declaredWindows) {
|
|
813
|
+
const start = Date.parse(w.startUtc);
|
|
814
|
+
const end = Date.parse(w.endUtc);
|
|
815
|
+
if (w.endUtc <= from || w.startUtc >= to) continue;
|
|
816
|
+
const first = Math.max(0, Math.ceil((fromMs - start) / HOUR_MS));
|
|
817
|
+
for (let k = first; start + k * HOUR_MS + length <= end; k++) {
|
|
818
|
+
const candidate = windowFromInstants(w.zone, start + k * HOUR_MS, start + k * HOUR_MS + length);
|
|
819
|
+
if (candidate.startUtc >= to) break;
|
|
820
|
+
if (!inRange(candidate)) continue;
|
|
821
|
+
push([candidate], remainingCapacity({
|
|
822
|
+
...candidate,
|
|
823
|
+
exclusive: true
|
|
824
|
+
}, declaredWindows, taken));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
} else {
|
|
828
|
+
const starts = [...new Set(declaredWindows.map((w) => w.start.slice(0, 10)))];
|
|
829
|
+
for (const d0 of starts) {
|
|
830
|
+
const d1 = localEnd(unit, quantity, d0);
|
|
831
|
+
const bundle = declaredWindows.filter((w) => w.start.slice(0, 10) >= d0 && w.start.slice(0, 10) < d1 && (w.end.slice(0, 10) < d1 || w.end.slice(0, 16) === `${d1}T00:00`));
|
|
832
|
+
const segments = bundle.map(project);
|
|
833
|
+
if (segments.length === 0 || segments.length > 400 || segments[0].start.slice(0, 10) !== d0 || !segments.every(inRange) || !measures(unit, quantity, segments) || !bundle.every((w) => remainingCapacity({
|
|
834
|
+
...w,
|
|
835
|
+
exclusive: true
|
|
836
|
+
}, declaredWindows, taken) >= 1)) continue;
|
|
837
|
+
push(segments, 1);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const key = (slot) => `${slot.windows[0].startUtc}|${slot.providerId}`;
|
|
842
|
+
const seen = /* @__PURE__ */ new Set();
|
|
843
|
+
return page(slots.sort((a, b) => key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0).filter((slot) => !seen.has(key(slot)) && seen.add(key(slot))).filter((slot) => after === void 0 || key(slot) > after).slice(0, limit + 1), limit, key);
|
|
503
844
|
}
|
|
504
845
|
/** Lists provider schedules so operators can inspect or replace existing windows. */
|
|
505
846
|
async listAvailability(context, options = {}) {
|
|
@@ -578,6 +919,13 @@ var BookingService = class {
|
|
|
578
919
|
return access;
|
|
579
920
|
}
|
|
580
921
|
async #verify(ctx, refs) {
|
|
922
|
+
if (refs.projectId === void 0 && refs.contactId !== void 0 && this.#authority.verifyContact) {
|
|
923
|
+
if (await this.#authority.verifyContact(ctx, refs.contactId) !== true) throw new BookingError("forbidden", "Booking references or approval could not be verified");
|
|
924
|
+
const { contactId: _contact, ...others } = refs;
|
|
925
|
+
const rest = Object.fromEntries(Object.entries(others).filter(([, value]) => value !== void 0));
|
|
926
|
+
if (Object.keys(rest).length === 0) return;
|
|
927
|
+
refs = rest;
|
|
928
|
+
}
|
|
581
929
|
if (await this.#authority.verifyReferences(ctx, structuredClone(refs)) !== true) throw new BookingError("forbidden", "Booking references or approval could not be verified");
|
|
582
930
|
}
|
|
583
931
|
#refs(row) {
|
|
@@ -595,7 +943,8 @@ var BookingService = class {
|
|
|
595
943
|
if (row.status !== "proposed" || row.proposal_id !== proposalId) throw new BookingError("conflict", "Booking does not have this current proposal");
|
|
596
944
|
}
|
|
597
945
|
async #proposal(tx, ctx, booking, proposalId) {
|
|
598
|
-
const { rows } = await tx.query(`SELECT provider_id, coverage::text,
|
|
946
|
+
const { rows } = await tx.query(`SELECT provider_id, coverage::text, windows::text, unit AS proposal_unit, quantity AS proposal_quantity,
|
|
947
|
+
unit_price_cents, currency, total_cents, terms::text, approved_plan::text FROM smrt_booking_proposals
|
|
599
948
|
WHERE tenant_id = ? AND booking_id = ? AND id = ?`, ctx.tenantId, booking.id, proposalId);
|
|
600
949
|
const row = rows[0];
|
|
601
950
|
if (!row) throw new BookingError("not-found", "Proposal does not exist");
|
|
@@ -607,17 +956,15 @@ var BookingService = class {
|
|
|
607
956
|
return JSON.parse(rows[0].windows);
|
|
608
957
|
}
|
|
609
958
|
async #allocations(tx, ctx, providerId, window) {
|
|
610
|
-
const { rows } = await tx.query(`SELECT a.exclusive, p.
|
|
959
|
+
const { rows } = await tx.query(`SELECT a.provider_id, a.exclusive, p.windows::text FROM smrt_booking_allocations a
|
|
611
960
|
JOIN smrt_booking_proposals p ON p.tenant_id = a.tenant_id AND p.booking_id = a.booking_id AND p.id = a.proposal_id
|
|
612
|
-
WHERE a.tenant_id = ? AND a.provider_id = ? AND a.released_at IS NULL${window ? " AND a.start_at < ? AND a.end_at > ?" : ""}`, ctx.tenantId, providerId, ...window ? [window.endUtc, window.startUtc] : []);
|
|
613
|
-
return rows.
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
};
|
|
620
|
-
});
|
|
961
|
+
WHERE a.tenant_id = ? AND (?::uuid IS NULL OR a.provider_id = ?) AND a.released_at IS NULL${window ? " AND a.start_at < ? AND a.end_at > ?" : ""}`, ctx.tenantId, providerId, providerId, ...window ? [window.endUtc, window.startUtc] : []);
|
|
962
|
+
return rows.flatMap((row) => JSON.parse(row.windows).filter((segment) => !window || segment.startUtc < window.endUtc && segment.endUtc > window.startUtc).map((segment) => ({
|
|
963
|
+
...segment,
|
|
964
|
+
window: segment,
|
|
965
|
+
providerId: row.provider_id,
|
|
966
|
+
exclusive: row.exclusive
|
|
967
|
+
})));
|
|
621
968
|
}
|
|
622
969
|
async #write(context, operation, key, canonical, permit, work, options = {}) {
|
|
623
970
|
const ctx = identity(context);
|
|
@@ -644,6 +991,6 @@ var BookingService = class {
|
|
|
644
991
|
}
|
|
645
992
|
};
|
|
646
993
|
//#endregion
|
|
647
|
-
export { BookingError, BookingService, migrateBookingsV1, resolveBookingWindow };
|
|
994
|
+
export { BookingError, BookingService, migrateBookingsV1, migrateBookingsV2, resolveBookingWindow };
|
|
648
995
|
|
|
649
996
|
//# sourceMappingURL=index.js.map
|