@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/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 fitsCapacity(candidate, declared, occupied) {
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 false;
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, enabled) VALUES (?, ?, ?, ?)
309
- ON CONFLICT (tenant_id, id) DO UPDATE SET enabled = EXCLUDED.enabled
310
- WHERE smrt_booking_offerings.kind = EXCLUDED.kind RETURNING id`, ctx.tenantId, offeringId, kind, enabled)).rows.length) throw new BookingError("conflict", "An offering kind cannot change");
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
- if (kind === "supervision-day" && !projectId) throw new BookingError("invalid", "A supervision request requires a project");
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
- const window = resolveBookingWindow(input.window);
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
- if (row.kind === "supervision-day" ? !approvedPlan : approvedPlan || !elapsedHour(window)) throw new BookingError("invalid", "Supervision requires a plan version; dedicated time requires one elapsed hour");
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
- declared(row.kind, window, await this.#resource(tx, ctx, providerId, false));
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 (tenant_id, booking_id, id, provider_id, coverage, terms, approved_plan)
388
- VALUES (?, ?, ?, ?, ?, ?, ?)`, ctx.tenantId, bookingId, proposalId, providerId, JSON.stringify(window), JSON.stringify(terms), approvedPlan && JSON.stringify(approvedPlan));
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
- declared(row.kind, window, availability);
441
- if (!fitsCapacity({
442
- ...window,
443
- exclusive
444
- }, availability, await this.#allocations(tx, ctx, proposal.providerId, window))) throw new BookingError("unavailable", "The proposed window has insufficient capacity");
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 rows = (await this.#db.query(`SELECT b.id, b.offering_id, b.contact_id, b.project_id, b.kind, b.status,
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.terms::text, p.approved_plan::text
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
- /** Lists configured offerings. Contacts only see enabled offerings. */
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.#discover(ctx);
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, enabled FROM smrt_booking_offerings
499
- WHERE tenant_id = ? AND (?::boolean OR enabled)
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, limit, (row) => row.id);
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, terms::text, approved_plan::text FROM smrt_booking_proposals
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.coverage::text FROM smrt_booking_allocations a
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.map((row) => {
614
- const snapshot = JSON.parse(row.coverage);
615
- return {
616
- ...snapshot,
617
- window: snapshot,
618
- exclusive: row.exclusive
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