slotlock 0.8.0 → 0.9.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +237 -0
  2. package/agent-server.d.ts +25 -5
  3. package/agent-server.js +152 -7
  4. package/agent-store-backend.d.ts +6 -1
  5. package/agent-store-backend.js +861 -55
  6. package/application-api.d.ts +19 -1
  7. package/application-api.js +578 -21
  8. package/approvals.d.ts +2 -0
  9. package/approvals.js +12 -5
  10. package/assistant.d.ts +34 -8
  11. package/assistant.js +81 -26
  12. package/availability-store.d.ts +193 -0
  13. package/availability-store.js +558 -0
  14. package/booking-rules.d.ts +92 -5
  15. package/booking-rules.js +243 -15
  16. package/dashboard-assistant-ui.d.ts +1 -1
  17. package/dashboard-assistant-ui.js +109 -8
  18. package/dashboard.d.ts +55 -6
  19. package/dashboard.js +790 -105
  20. package/ddl.d.ts +5 -1
  21. package/ddl.js +760 -9
  22. package/event-feed.d.ts +1 -1
  23. package/event-feed.js +18 -0
  24. package/holiday-import.d.ts +38 -0
  25. package/holiday-import.js +57 -0
  26. package/holidays.d.ts +38 -0
  27. package/holidays.js +150 -0
  28. package/hours.d.ts +125 -0
  29. package/hours.js +224 -0
  30. package/index.d.ts +14 -3
  31. package/index.js +10 -2
  32. package/interop.d.ts +25 -7
  33. package/interop.js +145 -17
  34. package/lifecycle.d.ts +31 -0
  35. package/lifecycle.js +115 -0
  36. package/maintenance.d.ts +19 -0
  37. package/maintenance.js +55 -1
  38. package/metrics.js +3 -0
  39. package/openapi.json +16237 -7795
  40. package/package.json +1 -1
  41. package/ranges.d.ts +28 -0
  42. package/ranges.js +135 -0
  43. package/resource-search.d.ts +19 -0
  44. package/resource-search.js +153 -0
  45. package/rest-api.js +7 -0
  46. package/rules.d.ts +2 -0
  47. package/rules.js +2 -0
  48. package/self-host.d.ts +1 -1
  49. package/self-host.js +221 -21
  50. package/slotlock.manifest.json +1 -1
  51. package/store.d.ts +367 -24
  52. package/store.js +1567 -97
  53. package/types.d.ts +198 -0
package/dashboard.js CHANGED
@@ -16,12 +16,12 @@
16
16
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
17
17
  import { SLOTLOCK_TEST_TENANT_SUFFIX, } from './api-keys.js';
18
18
  import { describeSlotlockAgentWrite, invokeSlotlockAgentOperation, slotlockAgentTools, } from './agent-server.js';
19
- import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_PRESETS, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKeyWithAny, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
19
+ import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_PRESETS, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKeyWithAny, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, SLOTLOCK_ASSISTANT_DIRECTORY_SIZE, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
20
20
  import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
21
21
  import { createSlotlockRateLimiter } from './rate-limit.js';
22
22
  import { createSlotlockGuardedFetch } from './webhooks.js';
23
23
  import { lookup as dnsLookup } from 'node:dns/promises';
24
- import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
24
+ import { SLOTLOCK_ASSISTANT_CHAT_ID, SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
25
25
  import { ignoreRejection } from './hooks.js';
26
26
  import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
27
27
  import { SLOTLOCK_DASHBOARD_ICON_SVG } from './dashboard-icon.js';
@@ -33,12 +33,19 @@ import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK
33
33
  import { slotlockQuotaPeriod } from './quotas.js';
34
34
  import { slotlockUsageMonthToDate, summarizeSlotlockUsage } from './usage.js';
35
35
  import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
36
+ import { dayToDate } from './hours.js';
37
+ import { SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES } from './holidays.js';
38
+ import { readSlotlockHolidayCalendar } from './holiday-import.js';
36
39
  import { SlotlockAgentOperationError } from './agent-server.js';
37
40
  import { runSlotlockApprovalGrant } from './agent-store-backend.js';
38
41
  import { SLOTLOCK_SANDBOX_SCENARIOS, fastForwardSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
39
42
  import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
40
43
  /** Resources one dashboard tenant may hold. */
41
44
  export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 500;
45
+ /** Closures the Closures page shows. */
46
+ const SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS = 200;
47
+ /** Contacts the Contacts page shows at once. */
48
+ const SLOTLOCK_DASHBOARD_CONTACTS_PAGE = 100;
42
49
  const SESSION_TTL_MS = 12 * 3_600_000;
43
50
  const SIGN_IN_TTL_MS = 10 * 60_000;
44
51
  /**
@@ -51,6 +58,8 @@ const GOOGLE_KEYS_DEFAULT_TTL_MS = 3_600_000;
51
58
  const MAX_FORM_BYTES = 8_192;
52
59
  /** A message to the assistant: 2,000 characters, each up to 9 bytes as a form encodes it. */
53
60
  const MAX_ASSISTANT_FORM_BYTES = 24_576;
61
+ /** A holiday calendar import's form: the calendar (SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES) and its fields. */
62
+ const MAX_IMPORT_FORM_BYTES = SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES + 65_536;
54
63
  /**
55
64
  * Sign-ins (record, then GitHub's token and user) one instance runs at once; more are told to retry.
56
65
  * This also bounds how fast unauthenticated callbacks can write sign-in records.
@@ -87,6 +96,11 @@ const POST_ROUTES = new Set([
87
96
  '/sandbox/reset',
88
97
  '/sandbox/fast-forward',
89
98
  '/hours',
99
+ '/closures',
100
+ '/closures/delete',
101
+ '/closures/import',
102
+ '/closures/calendars/delete',
103
+ '/contacts/forget',
90
104
  '/organization',
91
105
  '/environment',
92
106
  '/teams/enable',
@@ -96,6 +110,8 @@ const POST_ROUTES = new Set([
96
110
  '/assistant/approve',
97
111
  '/assistant/dismiss',
98
112
  '/assistant/reset',
113
+ '/assistant/rename',
114
+ '/assistant/delete',
99
115
  '/settings/assistant',
100
116
  '/settings/assistant/test',
101
117
  '/settings/assistant/models',
@@ -221,12 +237,49 @@ export function createSlotlockDashboardResources(store, options = {}) {
221
237
  timezone,
222
238
  maxTenantResources: SLOTLOCK_DASHBOARD_MAX_RESOURCES,
223
239
  }), { isolation: 'read committed', ...by(actor) }),
224
- setHours: (tenantRef, target, rules, actor) => store.withTenant(tenantRef, async (tenant) => {
225
- if (target === 'all')
226
- return tenant.setTenantAvailability({ tenantRef, rules });
240
+ setHours: (tenantRef, target, rules, actor, override) => store.withTenant(tenantRef, async (tenant) => {
241
+ if (target === 'all') {
242
+ return (await tenant.storeTenantAvailability({ tenantRef, rules, overrideResources: override ?? 'none' })).inheriting;
243
+ }
244
+ if ('location' in target) {
245
+ const result = await tenant.setLocationAvailability({ tenantRef, location: target.location, rules });
246
+ return result === null ? 0 : Math.max(1, result.inheriting);
247
+ }
227
248
  const updated = await tenant.setResourceAvailability({ tenantRef, id: target.id, rules });
228
249
  return updated ? 1 : 0;
229
250
  }, by(actor)),
251
+ contacts: {
252
+ list: (tenantRef, limit, after) => store.withTenant(tenantRef, (tenant) => tenant.listContacts({ tenantRef, limit, ...(after ? { after } : {}) })),
253
+ forget: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteContact({ tenantRef, ref }), { isolation: 'read committed', ...by(actor) }),
254
+ },
255
+ hours: {
256
+ defaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getAvailabilityDefaults({ tenantRef })),
257
+ exceptions: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAvailabilityExceptions({
258
+ tenantRef,
259
+ from: dayToDate(Math.floor(Date.now() / 86_400_000) - 1),
260
+ limit,
261
+ })),
262
+ addException: (tenantRef, exception, actor) => store.withTenant(tenantRef, (tenant) => tenant.createAvailabilityException({ ...exception, tenantRef }), by(actor)),
263
+ deleteException: (tenantRef, id, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteAvailabilityException({ tenantRef, id }), by(actor)),
264
+ importHolidays: async (tenantRef, source, scope, actor) => {
265
+ // Read (a URL fetched) before the transaction opens.
266
+ const read = await readSlotlockHolidayCalendar(source, options.holidayFetch);
267
+ return store.withTenant(tenantRef, async (tenant) => {
268
+ const result = await tenant.importHolidayCalendar({
269
+ tenantRef,
270
+ ...scope,
271
+ name: read.name,
272
+ ...(read.url !== undefined ? { url: read.url } : {}),
273
+ holidays: read.holidays,
274
+ span: read.span,
275
+ });
276
+ return { imported: result.imported, skipped: read.skipped, removed: result.removed };
277
+ }, by(actor));
278
+ },
279
+ byUrl: options.holidayFetch !== undefined,
280
+ calendars: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listHolidayCalendars({ tenantRef })),
281
+ deleteCalendar: (tenantRef, id, actor) => store.withTenant(tenantRef, async (tenant) => (await tenant.deleteHolidayCalendar({ tenantRef, id })) !== null, by(actor)),
282
+ },
230
283
  agenda: (tenantRef, window, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAgenda({ tenantRef, window, limit })),
231
284
  recentChanges: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listRecentChanges({ tenantRef, limit })),
232
285
  setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
@@ -463,6 +516,11 @@ const RULE_NAMES = Object.freeze({
463
516
  bufferBeforeMinutes: 'Buffer before',
464
517
  turnaroundMinutes: 'Turnaround',
465
518
  checkInMinutes: 'Check-in release',
519
+ minDurationMinutes: 'Shortest booking',
520
+ maxDurationMinutes: 'Longest booking',
521
+ defaultDurationMinutes: 'Usual length',
522
+ cancelCutoffMinutes: 'Cancel cutoff',
523
+ rescheduleCutoffMinutes: 'Move cutoff',
466
524
  });
467
525
  /** The booking rules form's fields, by rule. */
468
526
  const RULE_FIELDS = Object.freeze({
@@ -473,6 +531,11 @@ const RULE_FIELDS = Object.freeze({
473
531
  bufferBeforeMinutes: 'buffer_before_minutes',
474
532
  turnaroundMinutes: 'turnaround_minutes',
475
533
  checkInMinutes: 'check_in_minutes',
534
+ minDurationMinutes: 'min_duration_minutes',
535
+ maxDurationMinutes: 'max_duration_minutes',
536
+ defaultDurationMinutes: 'default_duration_minutes',
537
+ cancelCutoffMinutes: 'cancel_cutoff_minutes',
538
+ rescheduleCutoffMinutes: 'reschedule_cutoff_minutes',
476
539
  });
477
540
  /** How the inherited-rules note names each rule, with its unit. */
478
541
  const INHERITED_NAMES = Object.freeze({
@@ -480,7 +543,28 @@ const INHERITED_NAMES = Object.freeze({
480
543
  maxAdvanceDays: 'Book up to (days ahead)',
481
544
  enforceHours: 'Only inside bookable hours',
482
545
  requireCertain: 'Only in time its calendar syncs have read today',
546
+ allowedDurations: 'Lengths (min)',
547
+ pickupTimes: 'Pick-up times',
548
+ returnTimes: 'Return times',
549
+ cancellationFee: 'Cancellation fee',
550
+ hoursApply: 'Hours hold a hire at',
483
551
  });
552
+ /** A rule's value as the dashboard shows it. */
553
+ function ruleValueText(value) {
554
+ if (typeof value === 'number')
555
+ return value.toLocaleString('en');
556
+ if (typeof value === 'boolean')
557
+ return value ? 'on' : 'off';
558
+ if (typeof value === 'string')
559
+ return value === 'ends' ? 'its start and end' : 'all of it';
560
+ if (!Array.isArray(value)) {
561
+ const fee = value;
562
+ return `${(fee.amountMinor / 100).toFixed(2)} ${fee.currency}${fee.withinMinutes !== undefined ? ` within ${fee.withinMinutes.toLocaleString('en')} min` : ''}`;
563
+ }
564
+ return value
565
+ .map((item) => (typeof item === 'number' ? item.toLocaleString('en') : hoursOf(item.startMinutes, item.durationMinutes)))
566
+ .join(', ');
567
+ }
484
568
  /**
485
569
  * The rules a resource takes from defaults, each with where it comes from: a rule its pool's
486
570
  * defaults set, else one the tenant's set, where its own rules do not. Without the defaults, the
@@ -508,7 +592,7 @@ function inheritedNote(inherited) {
508
592
  return html ``;
509
593
  const parts = [...inherited].map(([name, { value, from }]) => typeof value === 'boolean'
510
594
  ? `${INHERITED_NAMES[name]}${value ? '' : ': off'} (${from})`
511
- : `${INHERITED_NAMES[name]} ${value.toLocaleString('en')} (${from})`);
595
+ : `${INHERITED_NAMES[name]} ${ruleValueText(value)} (${from})`);
512
596
  return html `<p class="fine">Inherited: ${parts.join('; ')}.</p>`;
513
597
  }
514
598
  const icon = (paths) => new Html(`<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`);
@@ -916,6 +1000,25 @@ function weekFromForm(form) {
916
1000
  }
917
1001
  return week;
918
1002
  }
1003
+ /** Minutes after midnight for `HH:MM`, or null. */
1004
+ function minutesOf(value) {
1005
+ const match = TIME.exec(value);
1006
+ return match ? Number(match[1]) * 60 + Number(match[2]) : null;
1007
+ }
1008
+ /**
1009
+ * A resource's hours in one line: its own, or what it inherits and from where (RFC 0004 B2), or the
1010
+ * server's.
1011
+ */
1012
+ function resourceHours(resource, server) {
1013
+ if (resource.availabilityRules !== undefined)
1014
+ return hoursSummary(resource.availabilityRules);
1015
+ const inherited = resource.inheritedAvailability;
1016
+ if (inherited) {
1017
+ const from = inherited.source === 'location' ? `${inherited.location ?? 'Location'}'s hours` : 'Your hours';
1018
+ return `${from}: ${hoursSummary(inherited.rules)}`;
1019
+ }
1020
+ return server.length === 0 ? "Server's hours (none)" : `Server's hours: ${hoursSummary(server)}`;
1021
+ }
919
1022
  export function createSlotlockDashboard(options) {
920
1023
  const publicUrl = new URL(options.publicUrl);
921
1024
  const origin = publicUrl.origin;
@@ -1393,11 +1496,13 @@ ${options.termsUrl
1393
1496
  runs.push({ change, times: 1 });
1394
1497
  }
1395
1498
  }
1499
+ // Own entries only: a type such as `constructor` names nothing here.
1500
+ const own = (map, type) => (Object.hasOwn(map, type) ? map[type] : undefined);
1396
1501
  return html `<ol class="feed">${runs.map(({ change, times }) => {
1397
- const tone = CHANGE_LABELS[change.type]?.tone ?? 'neutral';
1502
+ const tone = own(CHANGE_LABELS, change.type)?.tone ?? 'neutral';
1398
1503
  const where = change.resourceId ? resourceName(byId.get(change.resourceId), change.resourceId) : null;
1399
1504
  const object = where ?? (ABOUT_A_RESOURCE.test(change.type) ? 'a resource' : null);
1400
- const words = ACTIVITY[change.type];
1505
+ const words = own(ACTIVITY, change.type);
1401
1506
  let sentence;
1402
1507
  if (change.type === 'usage.threshold_reached') {
1403
1508
  const metric = typeof change.data.metric === 'string' ? change.data.metric : 'use';
@@ -1412,9 +1517,11 @@ ${options.termsUrl
1412
1517
  sentence = rest === undefined ? html `${before}` : html `${before}<strong>${object ?? 'a resource'}</strong>${rest}`;
1413
1518
  }
1414
1519
  else {
1415
- // A change type this dashboard does not know yet: its label, or its name in words.
1416
- const label = CHANGE_LABELS[change.type]?.text ?? change.type.replace(/[._]/g, ' ').replace(/^./, (first) => first.toUpperCase());
1417
- sentence = html `${label}${object ? html ` <span class="on">on</span> <strong>${object}</strong>` : html ``}`;
1520
+ // A change this dashboard has no words for (a newer server's): its label, never its type.
1521
+ const label = own(CHANGE_LABELS, change.type)?.text;
1522
+ sentence = label
1523
+ ? html `${label}${object ? html ` <span class="on">on</span> <strong>${object}</strong>` : html ``}`
1524
+ : html `Something changed${where ? html ` on <strong>${where}</strong>` : html ``}`;
1418
1525
  }
1419
1526
  return html `<li class="event"><span class="dot ${tone}" aria-hidden="true"></span><span class="what"><span class="title">${sentence}${times > 1 ? html ` <span class="times">${times === 2 ? 'twice' : `${String(times)} times`}</span>` : html ``}</span></span><time class="when" datetime="${change.occurredAt.toISOString()}" title="${formatDate(change.occurredAt)}">${ago(change.occurredAt, now())}</time></li>`;
1420
1527
  })}</ol>`;
@@ -1618,12 +1725,16 @@ ${keys.length === 0
1618
1725
  archived: Math.max(0, counted - resources.length),
1619
1726
  };
1620
1727
  }
1621
- async function resourcesPage(session, status = 200, notice) {
1622
- const resources = await options.resources.list(tenantOf(session));
1623
- const allowance = await resourceAllowance(tenantOf(session), resources);
1728
+ async function resourcesPage(session, status = 200, notice, ownHoursOnly = false) {
1729
+ const every = await options.resources.list(tenantOf(session));
1730
+ const allowance = await resourceAllowance(tenantOf(session), every);
1731
+ // `?hours=own`: those that keep hours of their own, which the tenant's hours do not reach.
1732
+ const resources = ownHoursOnly
1733
+ ? every.filter((resource) => resource.archivedAt === undefined && resource.availabilityRules !== undefined)
1734
+ : every;
1624
1735
  const token = csrfToken(session);
1625
1736
  const content = html `${noticeOf(notice, status)}
1626
- ${pageHead('Resources', html `<div class="row-actions">${options.resources.grouping ? html `<a class="button" href="${root}/locations">Locations and groups</a>` : html ``}${resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` : html ``}</div>`)}
1737
+ ${pageHead('Resources', html `<div class="row-actions">${options.resources.grouping ? html `<a class="button" href="${root}/locations">Locations and groups</a>` : html ``}<a class="button" href="${root}/hours">Your hours</a>${options.resources.hours ? html `<a class="button" href="${root}/closures">Closures</a>` : html ``}${options.resources.contacts ? html `<a class="button" href="${root}/contacts">Contacts</a>` : html ``}</div>`)}
1627
1738
  <section class="panel" id="add-resource">
1628
1739
  <h2>Add a resource</h2>
1629
1740
  <form class="create" method="post" action="${root}/resources">
@@ -1635,9 +1746,11 @@ ${pageHead('Resources', html `<div class="row-actions">${options.resources.group
1635
1746
  </form>
1636
1747
  <p class="fine">${String(allowance.used)} of ${String(allowance.limit)}${allowance.planned ? html ` on your plan${allowance.archived > 0 ? html `, ${String(allowance.archived)} of them archived (archived resources count until deleted)` : html ``}` : html ``}. Adding a reference you have changes its time zone.</p>
1637
1748
  </section>
1638
- ${resources.length === 0
1749
+ ${ownHoursOnly
1750
+ ? html `<p class="notice" role="status">${String(resources.length)} ${resources.length === 1 ? 'resource keeps hours of its own' : 'resources keep hours of their own'}, so <a href="${root}/hours">your hours</a> do not reach ${resources.length === 1 ? 'it' : 'them'}. <a href="${root}/resources">Show every resource</a></p>`
1751
+ : html ``}${resources.length === 0 && !ownHoursOnly
1639
1752
  ? emptyState(ICONS.resources, 'No resources yet. Agents can only book what is listed here.', { href: '#reference', label: 'Add a resource' })
1640
- : html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.name && resource.externalRef ? html `<span class="meta" title="Reference ${resource.externalRef}">${resource.externalRef}</span>` : html ``}${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${hoursSummary(resource.availabilityRules)}</span><a class="meta-link" href="${root}/hours?resource=${resource.id}">Set hours</a></span></td><td data-label="Resource id"><code class="muted truncate id" title="${resource.id}">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
1753
+ : html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.name && resource.externalRef ? html `<span class="meta" title="Reference ${resource.externalRef}">${resource.externalRef}</span>` : html ``}${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${resourceHours(resource, options.availability ?? [])}</span><a class="meta-link" href="${root}/hours?resource=${resource.id}">Set hours</a></span></td><td data-label="Resource id"><code class="muted truncate id" title="${resource.id}">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
1641
1754
  return respond(status, layout('Resources', content, session, 'resources'));
1642
1755
  }
1643
1756
  /** The tenant's locations and groups (RFC 0003 G2, G3): what they are, and adding and changing them. */
@@ -1663,7 +1776,7 @@ ${resources.length === 0
1663
1776
  <td data-label="Location" class="strong"><span class="cell"><span class="truncate" title="${paths.get(location.ref) ?? location.name}">${location.name}</span><span class="meta">${location.ref}${parent ? ` · in ${parent}` : ''}</span></span></td>
1664
1777
  <td data-label="Time zone">${zone ? (zone.from === location ? zone.timezone : html `<span class="cell"><span>${zone.timezone}</span><span class="meta">from ${zone.from.name}</span></span>`) : html `<span class="muted">None</span>`}</td>
1665
1778
  <td data-label="Resources">${String(location.resources)}</td>
1666
- <td class="actions"><details class="act"><summary>Edit</summary><div class="pop"><form method="post" action="${root}/locations">${sent()}<input type="hidden" name="ref" value="${location.ref}">
1779
+ <td class="actions">${options.resources.hours ? html `<a class="button" href="${root}/hours?location=${location.ref}">Hours</a>` : html ``}<details class="act"><summary>Edit</summary><div class="pop"><form method="post" action="${root}/locations">${sent()}<input type="hidden" name="ref" value="${location.ref}">
1667
1780
  <label>Name<input name="name" required maxlength="${nameLimit}" value="${location.name}"></label>
1668
1781
  <label>Inside<select name="parent">${insideChoices(location.parentRef, location.ref)}</select></label>
1669
1782
  <label>Time zone<input name="timezone" maxlength="100" value="${location.timezone ?? ''}" placeholder="As where it is"></label>
@@ -1713,6 +1826,8 @@ ${groups.length === 0
1713
1826
  </section>`;
1714
1827
  return respond(status, layout('Locations and groups', content, session, 'resources'));
1715
1828
  }
1829
+ /** Resources with hours of their own the tenant's hours page names; the rest are counted. */
1830
+ const OWN_HOURS_NAMED = 8;
1716
1831
  /** Settings' sections, each its own page: the assistant's form apart from what is only read. */
1717
1832
  const SETTINGS_SECTIONS = Object.freeze([
1718
1833
  { id: 'assistant', label: 'Assistant', path: '/settings' },
@@ -1736,8 +1851,8 @@ ${groups.length === 0
1736
1851
  else if (shown === 'hours') {
1737
1852
  const rules = options.availability ?? [];
1738
1853
  body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Default hours</span><div class="row-value${rules.length === 0 ? ' muted' : ''}">${rules.length === 0
1739
- ? 'None. Agents find free time only on resources with hours of their own.'
1740
- : html `${rules.map((rule, index) => html `${index > 0 ? html `<br>` : html ``}${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}`)}`}</div></div></div><p class="fine">In each resource's own time zone, for resources without hours of their own. Set by the server's operator.</p>`;
1854
+ ? "None. Agents find free time only on resources with hours of their own, their location's or yours."
1855
+ : html `${rules.map((rule, index) => html `${index > 0 ? html `<br>` : html ``}${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}`)}`}</div></div></div><p class="fine">In each resource's own time zone, for resources with no hours of their own, their location's or <a href="${root}/hours">yours</a>. Set by the server's operator.</p>`;
1741
1856
  }
1742
1857
  else {
1743
1858
  const current = session.orgs?.find((org) => org.id === session.org);
@@ -1799,7 +1914,7 @@ ${groups.length === 0
1799
1914
  <p><a class="back" href="${root}/resources">Resources</a></p>
1800
1915
  ${pageHead(resourceName(resource, resource.id))}
1801
1916
  ${resource.description ? html `<p>${resource.description}</p>` : html ``}
1802
- <dl class="facts">${resource.name && resource.externalRef ? html `<dt>Reference</dt><dd>${resource.externalRef}</dd>` : html ``}${resource.kind ? html `<dt>Kind</dt><dd>${KIND_NAMES[resource.kind]}</dd>` : html ``}${resource.capacity !== undefined ? html `<dt>Capacity</dt><dd>${String(resource.capacity)}</dd>` : html ``}${resource.labels ? html `<dt>Labels</dt><dd>${resource.labels.join(', ')}</dd>` : html ``}${resource.location ? html `<dt>Location</dt><dd>${paths.get(resource.location) ?? resource.locationName ?? resource.location}</dd>` : html ``}${resource.groups ? html `<dt>Groups</dt><dd>${resource.groups.map((ref) => groupNames.get(ref) ?? ref).join(', ')}</dd>` : html ``}<dt>Time zone</dt><dd>${resource.timezone}</dd><dt>Bookable hours</dt><dd>${hoursSummary(resource.availabilityRules)} <a href="${root}/hours?resource=${resource.id}">Set hours</a></dd><dt>Resource id</dt><dd><code class="muted">${resource.id}</code></dd></dl>
1917
+ <dl class="facts">${resource.name && resource.externalRef ? html `<dt>Reference</dt><dd>${resource.externalRef}</dd>` : html ``}${resource.kind ? html `<dt>Kind</dt><dd>${KIND_NAMES[resource.kind]}</dd>` : html ``}${resource.capacity !== undefined ? html `<dt>Capacity</dt><dd>${String(resource.capacity)}</dd>` : html ``}${resource.labels ? html `<dt>Labels</dt><dd>${resource.labels.join(', ')}</dd>` : html ``}${resource.location ? html `<dt>Location</dt><dd>${paths.get(resource.location) ?? resource.locationName ?? resource.location}</dd>` : html ``}${resource.groups ? html `<dt>Groups</dt><dd>${resource.groups.map((ref) => groupNames.get(ref) ?? ref).join(', ')}</dd>` : html ``}<dt>Time zone</dt><dd>${resource.timezone}</dd><dt>Bookable hours</dt><dd>${resourceHours(resource, options.availability ?? [])} <a href="${root}/hours?resource=${resource.id}">Set hours</a></dd><dt>Resource id</dt><dd><code class="muted">${resource.id}</code></dd></dl>
1803
1918
  <div class="split">
1804
1919
  <div class="stack">
1805
1920
  ${options.resources.setRules
@@ -1817,7 +1932,18 @@ ${field('stepOffsetMinutes', 'Steps start past each step by', 'min')}
1817
1932
  ${field('bufferBeforeMinutes', 'Kept free before each booking', 'min')}
1818
1933
  ${field('turnaroundMinutes', 'Turnaround after each booking', 'min')}
1819
1934
  ${field('checkInMinutes', 'Release if not checked in after', 'min')}
1935
+ ${field('minDurationMinutes', 'Shortest booking', 'min')}
1936
+ ${field('maxDurationMinutes', 'Longest booking', 'min')}
1937
+ ${field('defaultDurationMinutes', 'Usual length, offered when none is asked', 'min')}
1938
+ ${field('cancelCutoffMinutes', 'Cancel up to, before the start', 'min')}
1939
+ ${field('rescheduleCutoffMinutes', 'Move up to, before the start', 'min')}
1820
1940
  </div>
1941
+ ${resource.bookingRules?.allowedDurations || resource.bookingRules?.pickupTimes || resource.bookingRules?.returnTimes || resource.bookingRules?.cancellationFee ? html `<p class="fine">Set through the API, and kept when you save: ${[
1942
+ resource.bookingRules.allowedDurations ? `lengths ${ruleValueText(resource.bookingRules.allowedDurations)} min` : '',
1943
+ resource.bookingRules.pickupTimes ? `pick-up ${ruleValueText(resource.bookingRules.pickupTimes)}` : '',
1944
+ resource.bookingRules.returnTimes ? `return ${ruleValueText(resource.bookingRules.returnTimes)}` : '',
1945
+ resource.bookingRules.cancellationFee ? `cancellation fee ${ruleValueText(resource.bookingRules.cancellationFee)}` : '',
1946
+ ].filter(Boolean).join('; ')}.</p>` : html ``}
1821
1947
  ${toggle('enforceHours', 'enforce_hours', 'Only inside bookable hours')}
1822
1948
  ${toggle('requireCertain', 'require_certain', 'Only in time its calendar syncs have read today')}
1823
1949
  ${inheritedNote(inherited)}
@@ -1926,6 +2052,12 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1926
2052
  const requireCertain = choice('require_certain');
1927
2053
  if (requireCertain !== undefined)
1928
2054
  rules.requireCertain = requireCertain;
2055
+ // Rules the form does not edit (set through the API) are kept.
2056
+ for (const name of ['allowedDurations', 'pickupTimes', 'returnTimes', 'cancellationFee']) {
2057
+ const kept = resource.bookingRules?.[name];
2058
+ if (kept !== undefined)
2059
+ rules[name] = kept;
2060
+ }
1929
2061
  try {
1930
2062
  await options.resources.setRules(tenantOf(session), id, Object.keys(rules).length === 0 ? null : rules, personOf(session));
1931
2063
  }
@@ -2360,7 +2492,35 @@ ${decided.length === 0
2360
2492
  const OWN_HOST_REFUSAL = 'That URL is this Slotlock deployment’s own address. Give the address of the model’s API.';
2361
2493
  const assistantTimeout = assistantOptions?.timeoutMs ?? 30_000;
2362
2494
  const assistantTurnTimeout = assistantOptions?.turnTimeoutMs ?? 90_000;
2363
- const conversationTtl = assistantOptions?.conversationTtlMs ?? 2 * 3_600_000;
2495
+ // Kept until the member deletes a conversation, unless the deployment gives them a lifetime.
2496
+ const conversationTtl = assistantOptions?.conversationTtlMs ?? null;
2497
+ /** How recently a conversation was used for the assistant page to open on it rather than a new one. */
2498
+ const CHAT_RESUME_MS = 2 * 3_600_000;
2499
+ const newChatId = () => randomBytes(12).toString('base64url');
2500
+ /** The conversation a page or form names, or null for a new one. */
2501
+ const chatOf = (value) => value && SLOTLOCK_ASSISTANT_CHAT_ID.test(value) ? value : null;
2502
+ const chatUrl = (id) => `${root}/assistant?c=${id ?? 'new'}`;
2503
+ /**
2504
+ * The conversation a form goes to: the one its `chat` names, a new one when it is empty, and with
2505
+ * no `chat` at all (a page of an earlier release), the one the page would open: the member's most
2506
+ * recent, used in the past two hours (`anyAge`: however long ago).
2507
+ */
2508
+ async function chatOfForm(store, session, form, anyAge = false) {
2509
+ if (form.has('chat'))
2510
+ return chatOf(form.get('chat'));
2511
+ const [latest] = await store.listChats(tenantOf(session), personOf(session), 1);
2512
+ return latest && (anyAge || now() - latest.updatedAt.getTime() < CHAT_RESUME_MS) ? latest.id : null;
2513
+ }
2514
+ /** A conversation's first title: its first message on one line, cut at a word near 60 characters. */
2515
+ const chatTitle = (text) => {
2516
+ const line = text.replace(/\s+/gu, ' ').trim();
2517
+ const chars = [...line];
2518
+ if (chars.length <= 60)
2519
+ return line || 'New chat';
2520
+ const cut = chars.slice(0, 60).join('');
2521
+ const space = cut.lastIndexOf(' ');
2522
+ return `${(space > 30 ? cut.slice(0, space) : cut).replace(/[\s.,;:!?-]+$/u, '')}…`;
2523
+ };
2364
2524
  if (assistantOptions && Buffer.byteLength(assistantOptions.secret, 'utf8') < 32) {
2365
2525
  throw new Error('Slotlock dashboard assistant secret must be at least 32 bytes');
2366
2526
  }
@@ -2584,6 +2744,7 @@ ${decided.length === 0
2584
2744
  external_ref: 'Reference',
2585
2745
  event_id: 'Booking',
2586
2746
  hold_id: 'Hold',
2747
+ hold_group_id: 'Held together',
2587
2748
  ttl_seconds: 'Held for',
2588
2749
  notes: 'Note',
2589
2750
  description: 'Note',
@@ -2604,7 +2765,7 @@ ${decided.length === 0
2604
2765
  if (name === 'ttl_seconds' && typeof value === 'number') {
2605
2766
  return html `${value >= 60 ? `${Math.round(value / 60)} min` : `${value} s`}`;
2606
2767
  }
2607
- if ((name === 'event_id' || name === 'hold_id') && typeof value === 'string')
2768
+ if ((name === 'event_id' || name === 'hold_id' || name === 'hold_group_id') && typeof value === 'string')
2608
2769
  return html `<code>${value}</code>`;
2609
2770
  if (typeof value === 'string')
2610
2771
  return html `<span class="wrap">${value}</span>`;
@@ -2634,7 +2795,7 @@ ${decided.length === 0
2634
2795
  approval_pending: 'it is waiting for an approval',
2635
2796
  });
2636
2797
  const refusal = (code) => REFUSALS[code] ?? code.replaceAll('_', ' ');
2637
- function actionCard(session, action, names, zones) {
2798
+ function actionCard(session, action, names, zones, chatId) {
2638
2799
  const verb = slotlockAssistantActionVerb(action.operation);
2639
2800
  const title = action.summary || `${verb.verb}: ${toolTitles.get(action.operation) ?? action.operation}`;
2640
2801
  if (action.status !== 'pending') {
@@ -2666,7 +2827,7 @@ ${decided.length === 0
2666
2827
  const label = FACT_NAMES[name] ?? name.charAt(0).toUpperCase() + name.slice(1).replaceAll('_', ' ');
2667
2828
  (name in FACT_NAMES || name === 'attendees' ? facts : more).push(html `<dt>${label}</dt><dd>${fieldValue(name, value, names)}</dd>`);
2668
2829
  }
2669
- const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${id}">`;
2830
+ const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${chatId ?? ''}"><input type="hidden" name="action" value="${id}">`;
2670
2831
  return html `<li class="turn action" id="action-${action.id}"><div class="change"><p class="change-title">${title}</p>${facts.length > 0 ? html `<dl class="facts">${facts}</dl>` : html ``}${more.length > 0
2671
2832
  ? html `<details class="more"><summary>All details</summary><dl class="facts">${more}</dl></details>`
2672
2833
  : html ``}<div class="change-buttons"><form method="post" action="${root}/assistant/approve">${hidden(action.id)}<button class="${verb.danger ? 'danger' : 'primary'}" type="submit" data-busy="${verb.busy}">${verb.verb}</button></form><form method="post" action="${root}/assistant/dismiss">${hidden(action.id)}<button class="ghost" type="submit">Decline</button></form></div></div></li>`;
@@ -2701,7 +2862,7 @@ ${decided.length === 0
2701
2862
  * rendered Markdown, each run of tool steps folded into one line, proposed changes as cards, and
2702
2863
  * a failure that ends it with a way to send the last message again.
2703
2864
  */
2704
- function assistantItems(session, entries, actions, names, zones) {
2865
+ function assistantItems(session, entries, actions, names, zones, chatId) {
2705
2866
  const items = [];
2706
2867
  let steps = [];
2707
2868
  let cards = [];
@@ -2736,7 +2897,7 @@ ${decided.length === 0
2736
2897
  const write = SLOTLOCK_ASSISTANT_TOOL_USE[result.name] === 'propose';
2737
2898
  const action = result.action ? readAction(result.action, actions[result.action]) : null;
2738
2899
  if (action) {
2739
- cards.push(actionCard(session, action, names, zones));
2900
+ cards.push(actionCard(session, action, names, zones, chatId));
2740
2901
  continue;
2741
2902
  }
2742
2903
  const title = toolTitles.get(result.name) ?? result.name;
@@ -2752,7 +2913,7 @@ ${decided.length === 0
2752
2913
  flush();
2753
2914
  const last = index === entries.length - 1;
2754
2915
  if (last && !quietNote(entry.text) && lastUser?.role === 'user') {
2755
- items.push(html `<li class="turn error" role="alert"><span>${entry.text}</span><form method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="message" value="${lastUser.text}"><button class="text-button" type="submit">Retry</button></form></li>`);
2916
+ items.push(html `<li class="turn error" role="alert"><span>${entry.text}</span><form method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${chatId ?? ''}"><input type="hidden" name="message" value="${lastUser.text}"><button class="text-button" type="submit">Retry</button></form></li>`);
2756
2917
  }
2757
2918
  else {
2758
2919
  items.push(html `<li class="turn note">${entry.text}</li>`);
@@ -2776,20 +2937,90 @@ ${decided.length === 0
2776
2937
  stop: new Html('<svg class="stop" viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor"/></svg>'),
2777
2938
  down: new Html('<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v10M3.5 8.5 8 13l4.5-4.5"/></svg>'),
2778
2939
  plus: new Html('<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>'),
2940
+ more: new Html('<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.5" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12.5" cy="8" r="1.3"/></svg>'),
2941
+ chats: new Html('<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h6"/></svg>'),
2779
2942
  };
2780
- async function assistantPage(session, status = 200, notice) {
2943
+ /** How long ago a chat was used, as briefly as the list has room for: 4m, 3h, 5d, then the date. */
2944
+ function chatSince(at) {
2945
+ const minutes = Math.max(0, Math.floor((now() - at.getTime()) / 60_000));
2946
+ if (minutes < 1)
2947
+ return 'now';
2948
+ if (minutes < 60)
2949
+ return `${String(minutes)}m`;
2950
+ if (minutes < 24 * 60)
2951
+ return `${String(Math.floor(minutes / 60))}h`;
2952
+ if (minutes < 7 * 24 * 60)
2953
+ return `${String(Math.floor(minutes / (24 * 60)))}d`;
2954
+ return zoned('UTC', { day: 'numeric', month: 'short' }).format(at);
2955
+ }
2956
+ /** When a conversation was last used, as the Chats list groups them: by day, in UTC. */
2957
+ function chatGroup(at) {
2958
+ const day = (ms) => Math.floor(ms / DAY_MS);
2959
+ const daysAgo = day(now()) - day(at.getTime());
2960
+ if (daysAgo <= 0)
2961
+ return 'Today';
2962
+ if (daysAgo === 1)
2963
+ return 'Yesterday';
2964
+ if (daysAgo < 7)
2965
+ return 'Previous 7 days';
2966
+ if (daysAgo < 30)
2967
+ return 'Previous 30 days';
2968
+ return 'Older';
2969
+ }
2970
+ /**
2971
+ * A member's conversations, the most recent first, grouped by when they were last used: each
2972
+ * opens with a link, and has Rename and Delete behind its menu. `current` is the one shown.
2973
+ */
2974
+ function chatsPanel(session, chats, current) {
2975
+ const token = csrfToken(session);
2976
+ const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${id}">`;
2977
+ const groups = [];
2978
+ for (const chat of chats) {
2979
+ const label = chatGroup(chat.updatedAt);
2980
+ let group = groups.at(-1);
2981
+ if (!group || group.label !== label) {
2982
+ group = { label, rows: [] };
2983
+ groups.push(group);
2984
+ }
2985
+ const here = chat.id === current;
2986
+ group.rows.push(html `<li class="chat-row${here ? ' current' : ''}"><a class="chat-link" href="${chatUrl(chat.id)}"${here ? html ` aria-current="page"` : html ``}><span class="chat-title">${chat.title}</span><time class="chat-when" datetime="${chat.updatedAt.toISOString()}" title="${formatDate(chat.updatedAt)}">${chatSince(chat.updatedAt)}</time></a><details class="chat-menu"><summary aria-label="Options for ${chat.title}">${ASSISTANT_ICONS.more}</summary><div class="pop"><form class="chat-rename" method="post" action="${root}/assistant/rename">${hidden(chat.id)}<label class="visually-hidden" for="title-${chat.id}">Title</label><input id="title-${chat.id}" name="title" value="${chat.title}" maxlength="120" required><button type="submit">Rename</button></form><form method="post" action="${root}/assistant/delete">${hidden(chat.id)}<button class="danger" type="submit">Delete chat</button></form></div></details></li>`);
2987
+ }
2988
+ const list = groups.length === 0
2989
+ ? html `<p class="empty chats-empty">No chats yet.</p>`
2990
+ : html `${groups.map((group) => html `<h3 class="chat-group">${group.label}</h3><ul class="chat-rows">${group.rows}</ul>`)}`;
2991
+ return html `<aside class="chats" aria-label="Chats"><div class="chats-head"><h2>Chats</h2><a class="icon-button" href="${chatUrl(null)}" aria-label="New chat" title="New chat">${ASSISTANT_ICONS.plus}</a></div><label class="visually-hidden" for="chats-filter">Search chats</label><input id="chats-filter" class="chats-filter" type="search" placeholder="Search chats" autocomplete="off" hidden><nav class="chat-list" aria-label="Your chats">${list}</nav></aside>`;
2992
+ }
2993
+ /**
2994
+ * The assistant: a conversation, with the member's others beside it. `requested` is the page's
2995
+ * `c`: a conversation's id, `new` for a new one, or nothing for the last one used in the past two
2996
+ * hours (else a new one).
2997
+ */
2998
+ async function assistantPage(session, status = 200, notice, requested) {
2781
2999
  const store = assistantStore();
2782
3000
  if (!store)
2783
3001
  return notFound(session);
2784
3002
  const tenantRef = tenantOf(session);
2785
- const [settings, conversation] = await Promise.all([
2786
- store.getSettings(tenantRef),
2787
- store.getConversation(tenantRef, personOf(session)),
2788
- ]);
3003
+ const member = personOf(session);
3004
+ const [settings, chats] = await Promise.all([store.getSettings(tenantRef), store.listChats(tenantRef, member)]);
2789
3005
  if (!settings) {
2790
3006
  const content = html `${noticeOf(notice, status)}${pageHead('Assistant')}${emptyState(ICONS.assistant, isAdmin(session) ? 'Connect a model to use the assistant.' : 'An admin of this organization connects a model in Settings.', isAdmin(session) ? { href: `${root}/settings#assistant`, label: 'Connect a model' } : undefined)}`;
2791
3007
  return respond(status, layout('Assistant', content, session, 'assistant'));
2792
3008
  }
3009
+ let chatId = chatOf(requested);
3010
+ if (requested === undefined) {
3011
+ const latest = chats[0];
3012
+ if (latest && now() - latest.updatedAt.getTime() < CHAT_RESUME_MS)
3013
+ chatId = latest.id;
3014
+ }
3015
+ const conversation = chatId ? await store.getChat(tenantRef, member, chatId) : null;
3016
+ if (chatId && !conversation) {
3017
+ // Deleted, or never this member's: a new one instead, saying so.
3018
+ chatId = null;
3019
+ if (requested !== undefined && notice === undefined) {
3020
+ notice = 'That chat is not here: it was deleted.';
3021
+ status = 404;
3022
+ }
3023
+ }
2793
3024
  const entries = readSlotlockAssistantEntries(conversation?.messages);
2794
3025
  const actions = conversation?.actions ?? {};
2795
3026
  const empty = entries.length === 0;
@@ -2797,7 +3028,7 @@ ${decided.length === 0
2797
3028
  ? await resourceLookups(tenantRef)
2798
3029
  : { resources: [], names: new Map(), zones: new Map() };
2799
3030
  const token = csrfToken(session);
2800
- const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
3031
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${chatId ?? ''}">`;
2801
3032
  const first = resources.find((resource) => !resource.archivedAt);
2802
3033
  const named = first ? resourceName(first, first.id) : null;
2803
3034
  // A read chip asks at once; a write chip fills the box to finish (app.js), or asks (no script).
@@ -2810,11 +3041,20 @@ ${decided.length === 0
2810
3041
  ];
2811
3042
  const chips = html `<div class="assistant-start"><div class="chips">${prompts.map((prompt) => html `<form method="post" action="${root}/assistant">${hidden()}<button class="chip" type="submit" name="message" value="${prompt.message}"${prompt.fill ? html ` data-fill="${prompt.message}"` : html ``}>${prompt.label}</button></form>`)}</div></div>`;
2812
3043
  const composer = html `<div class="dock"><button class="jump" type="button" hidden aria-label="Jump to the latest message">${ASSISTANT_ICONS.down}</button><form class="composer" method="post" action="${root}/assistant">${hidden()}<label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="2" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="${empty ? 'Ask about bookings' : 'Reply…'}"></textarea><div class="composer-bar"><span class="count" hidden></span><button class="send" type="submit" aria-label="Send">${ASSISTANT_ICONS.send}${ASSISTANT_ICONS.stop}</button></div></form></div>`;
2813
- const reset = html `<form class="inline new-chat" method="post" action="${root}/assistant/reset">${hidden()}<button class="ghost" type="submit">${ASSISTANT_ICONS.plus}New conversation</button></form>`;
3044
+ const actionsHead = html `<div class="assistant-actions"><a class="button ghost chats-link" href="${root}/assistant/chats">${ASSISTANT_ICONS.chats}Chats</a><a class="button ghost new-chat" href="${chatUrl(null)}">${ASSISTANT_ICONS.plus}New chat</a></div>`;
2814
3045
  const flag = empty ? new Html(' data-empty') : html ``;
2815
- const content = html `${noticeOf(notice, status)}<div class="assistant-page"${flag}>${pageHead('Assistant', reset)}<div class="assistant" data-assistant data-stream="${root}/assistant/stream" data-page="${root}/assistant"${flag}><ol class="thread">${assistantItems(session, entries, actions, names, zones)}</ol><span id="latest"></span>${chips}${composer}</div></div>`;
3046
+ const content = html `${noticeOf(notice, status)}<div class="assistant-shell">${chatsPanel(session, chats, chatId)}<div class="assistant-page"${flag}>${pageHead(conversation?.title ?? 'Assistant', actionsHead)}<div class="assistant" data-assistant data-stream="${root}/assistant/stream" data-page="${root}/assistant"${flag}><ol class="thread">${assistantItems(session, entries, actions, names, zones, chatId)}</ol><span id="latest"></span>${chips}${composer}</div></div></div>`;
2816
3047
  return respond(status, layout('Assistant', content, session, 'assistant'));
2817
3048
  }
3049
+ /** The member's conversations on a page of their own: the Chats list where the page is narrow. */
3050
+ async function chatsPage(session) {
3051
+ const store = assistantStore();
3052
+ if (!store)
3053
+ return notFound(session);
3054
+ const chats = await store.listChats(tenantOf(session), personOf(session));
3055
+ const content = html `<div class="chats-page">${pageHead('Chats', html `<a class="button ghost" href="${chatUrl(null)}">${ASSISTANT_ICONS.plus}New chat</a>`)}${chatsPanel(session, chats, null)}</div>`;
3056
+ return respond(200, layout('Chats', content, session, 'assistant'));
3057
+ }
2818
3058
  /** The named provider a provider and base URL are, or none (Custom). */
2819
3059
  const presetOf = (provider, baseUrl) => SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id !== 'custom' && preset.provider === provider && preset.baseUrl === baseUrl);
2820
3060
  /**
@@ -3065,7 +3305,16 @@ ${decided.length === 0
3065
3305
  catch {
3066
3306
  // Metering never changes an answer.
3067
3307
  }
3068
- const stored = await store.getConversation(tenantRef, member);
3308
+ // The conversation the form continues (a new one when it names none, or one deleted meanwhile),
3309
+ // and the resources agents may see, so a name is matched to its id without a lookup.
3310
+ const asked = await chatOfForm(store, session, form);
3311
+ const [stored, listed] = await Promise.all([
3312
+ asked ? store.getChat(tenantRef, member, asked) : Promise.resolve(null),
3313
+ options.resources.list(tenantRef).catch(() => []),
3314
+ ]);
3315
+ const chatId = stored?.id ?? newChatId();
3316
+ const title = stored?.title ?? chatTitle(text);
3317
+ const bookable = listed.filter((resource) => resource.visibility !== 'private' && resource.archivedAt === undefined);
3069
3318
  const entries = boundSlotlockAssistantEntries([
3070
3319
  ...readSlotlockAssistantEntries(stored?.messages),
3071
3320
  { role: 'user', text },
@@ -3082,6 +3331,17 @@ ${decided.length === 0
3082
3331
  workspace: current?.login ?? session.login,
3083
3332
  login: session.login,
3084
3333
  now: new Date(now()),
3334
+ resources: bookable.slice(0, SLOTLOCK_ASSISTANT_DIRECTORY_SIZE).map((resource) => ({
3335
+ id: resource.id,
3336
+ ...(resource.name !== undefined ? { name: resource.name } : {}),
3337
+ ...(resource.externalRef ? { ref: resource.externalRef } : {}),
3338
+ ...(resource.kind !== undefined ? { kind: resource.kind } : {}),
3339
+ ...(resource.locationName !== undefined || resource.location !== undefined
3340
+ ? { location: (resource.locationName ?? resource.location) }
3341
+ : {}),
3342
+ timezone: resource.timezone,
3343
+ })),
3344
+ resourceCount: bookable.length,
3085
3345
  }),
3086
3346
  entries,
3087
3347
  fetch: assistantFetch,
@@ -3141,19 +3401,25 @@ ${decided.length === 0
3141
3401
  // Only this turn's proposals are added; the rest are kept as the row holds them now, so one
3142
3402
  // approved (or a conversation reset) while the turn ran is never put back as it stood.
3143
3403
  const kept = pruneSlotlockAssistantActions(messages, { ...(stored?.actions ?? {}), ...proposed });
3144
- await store.putConversation(tenantRef, member, {
3404
+ await store.putChat(tenantRef, member, chatId, {
3405
+ title,
3145
3406
  messages,
3146
3407
  added: Object.fromEntries(Object.entries(proposed).filter(([id]) => id in kept)),
3147
3408
  keep: Object.keys(kept),
3409
+ // A chat it continued, only while it is still there: deleted during the turn, it stays so.
3410
+ create: stored === null,
3148
3411
  }, conversationTtl);
3149
- return { ok: true, messages, actions: kept };
3412
+ return { ok: true, messages, actions: kept, chatId, title };
3150
3413
  }
3151
3414
  /** The form's way (no script): run the turn, then show the conversation at its latest message. */
3152
3415
  async function sendToAssistant(session, form) {
3153
3416
  const run = await runAssistantMessage(session, form);
3154
- if (!run.ok)
3155
- return run.status === 404 ? notFound(session) : assistantPage(session, run.status, run.message);
3156
- return redirect(303, `${root}/assistant#latest`);
3417
+ if (!run.ok) {
3418
+ return run.status === 404
3419
+ ? notFound(session)
3420
+ : assistantPage(session, run.status, run.message, chatOf(form.get('chat')) ?? 'new');
3421
+ }
3422
+ return redirect(303, `${chatUrl(run.chatId)}#latest`);
3157
3423
  }
3158
3424
  /**
3159
3425
  * app.js's way: the same turn, answered as it happens, one JSON object a line. `text` carries a
@@ -3163,15 +3429,32 @@ ${decided.length === 0
3163
3429
  */
3164
3430
  async function streamToAssistant(session, form, request) {
3165
3431
  const encoder = new TextEncoder();
3166
- const pipe = new TransformStream();
3167
- const writer = pipe.writable.getWriter();
3432
+ // Stop, or a client gone, cancels the body. That stops the turn, which keeps what it had, and
3433
+ // the cancel settles only once it has: a Worker closes the request's connection then, and a
3434
+ // runtime that never aborts request.signal on a disconnect stops the turn all the same.
3435
+ const stop = new AbortController();
3168
3436
  let open = true;
3437
+ let sink;
3438
+ let work = Promise.resolve();
3439
+ const body = new ReadableStream({
3440
+ start(controller) {
3441
+ sink = controller;
3442
+ },
3443
+ cancel() {
3444
+ open = false;
3445
+ stop.abort();
3446
+ return work;
3447
+ },
3448
+ });
3169
3449
  const write = (event) => {
3170
3450
  if (!open)
3171
3451
  return;
3172
- writer.write(encoder.encode(`${JSON.stringify(event)}\n`)).catch(() => {
3452
+ try {
3453
+ sink?.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
3454
+ }
3455
+ catch {
3173
3456
  open = false;
3174
- });
3457
+ }
3175
3458
  };
3176
3459
  const label = (name, state) => slotlockAssistantStepLabel(name, toolTitles.get(name) ?? name, state, SLOTLOCK_ASSISTANT_TOOL_USE[name] === 'propose');
3177
3460
  const failed = (message) => write({
@@ -3179,10 +3462,10 @@ ${decided.length === 0
3179
3462
  html: html `<li class="turn error" role="alert"><span>${message}</span></li>`.value,
3180
3463
  once: formNonce(session),
3181
3464
  });
3182
- const work = (async () => {
3465
+ work = (async () => {
3183
3466
  try {
3184
3467
  const run = await runAssistantMessage(session, form, {
3185
- signal: request.signal,
3468
+ signal: AbortSignal.any([request.signal, stop.signal]),
3186
3469
  onEvent: (event) => {
3187
3470
  if (event.type === 'text')
3188
3471
  write({ t: 'text', d: event.delta });
@@ -3202,10 +3485,16 @@ ${decided.length === 0
3202
3485
  const lookups = Object.keys(run.actions).length > 0
3203
3486
  ? await resourceLookups(tenantOf(session))
3204
3487
  : { names: new Map(), zones: new Map() };
3488
+ const chats = await assistantStore()?.listChats(tenantOf(session), personOf(session));
3205
3489
  write({
3206
3490
  t: 'done',
3207
- html: assistantItems(session, turn, run.actions, lookups.names, lookups.zones).join(''),
3491
+ html: assistantItems(session, turn, run.actions, lookups.names, lookups.zones, run.chatId).join(''),
3208
3492
  once: formNonce(session),
3493
+ // The conversation it went to (a new one has its id now), its address, and the list again.
3494
+ chat: run.chatId,
3495
+ title: run.title,
3496
+ url: chatUrl(run.chatId),
3497
+ chats: chats ? chatsPanel(session, chats, run.chatId).value : undefined,
3209
3498
  });
3210
3499
  }
3211
3500
  catch (error) {
@@ -3213,8 +3502,15 @@ ${decided.length === 0
3213
3502
  failed('Something went wrong. Reload the page and try again.');
3214
3503
  }
3215
3504
  finally {
3216
- await writer.close().catch(() => undefined);
3217
- open = false;
3505
+ if (open) {
3506
+ open = false;
3507
+ try {
3508
+ sink?.close();
3509
+ }
3510
+ catch {
3511
+ // Cancelled meanwhile.
3512
+ }
3513
+ }
3218
3514
  }
3219
3515
  })();
3220
3516
  ignoreRejection(work);
@@ -3223,54 +3519,88 @@ ${decided.length === 0
3223
3519
  headers.set('Content-Type', 'application/x-ndjson; charset=utf-8');
3224
3520
  // Proxies that buffer (nginx) send each line as it is written.
3225
3521
  headers.set('X-Accel-Buffering', 'no');
3226
- return new Response(pipe.readable, { status: 200, headers });
3522
+ return new Response(body, { status: 200, headers });
3227
3523
  }
3228
3524
  async function settleAction(session, form, decision) {
3229
3525
  const store = assistantStore();
3230
3526
  if (!store || !assistantOptions)
3231
3527
  return notFound(session);
3232
3528
  const id = form.get('action') ?? '';
3529
+ const chatId = await chatOfForm(store, session, form, true);
3233
3530
  const tenantRef = tenantOf(session);
3234
3531
  const member = personOf(session);
3235
- const claimed = ACTION_ID.test(id)
3236
- ? await store.settleAction(tenantRef, member, id, 'pending', decision === 'approve' ? { status: 'approved' } : { status: 'dismissed', input: {} })
3532
+ const claimed = ACTION_ID.test(id) && chatId
3533
+ ? await store.settleAction(tenantRef, member, chatId, id, 'pending', decision === 'approve' ? { status: 'approved' } : { status: 'dismissed', input: {} })
3237
3534
  : null;
3238
3535
  const action = claimed ? readAction(id, claimed) : null;
3239
- if (!action)
3240
- return assistantPage(session, 409, 'That change was already approved or dismissed.');
3536
+ if (!action || !chatId) {
3537
+ return assistantPage(session, 409, 'That change was already approved or dismissed.', chatId ?? undefined);
3538
+ }
3241
3539
  const title = toolTitles.get(action.operation) ?? action.operation;
3242
3540
  let event;
3243
3541
  if (decision === 'dismiss') {
3244
3542
  event = `The member dismissed the proposed change ${id} (${title}); it did not run.`;
3245
3543
  }
3246
3544
  else if (SLOTLOCK_ASSISTANT_TOOL_USE[action.operation] !== 'propose') {
3247
- await store.settleAction(tenantRef, member, id, 'approved', { status: 'failed', outcome: 'tool_not_available', input: {} });
3545
+ await store.settleAction(tenantRef, member, chatId, id, 'approved', { status: 'failed', outcome: 'tool_not_available', input: {} });
3248
3546
  event = `The proposed change ${id} (${title}) could not run: tool_not_available.`;
3249
3547
  }
3250
3548
  else {
3251
3549
  const outcome = await dispatch(session, action.operation, action.input, 'run');
3252
3550
  // Settled, the card keeps what it said, not the arguments: the actions stay small.
3253
- await store.settleAction(tenantRef, member, id, 'approved', outcome.ok ? { status: 'done', input: {} } : { status: 'failed', outcome: outcome.code, input: {} });
3551
+ await store.settleAction(tenantRef, member, chatId, id, 'approved', outcome.ok ? { status: 'done', input: {} } : { status: 'failed', outcome: outcome.code, input: {} });
3254
3552
  const result = JSON.stringify(outcome.ok ? outcome.data : { error: { code: outcome.code } });
3255
3553
  event = `The member approved the proposed change ${id} (${title}). ${outcome.ok ? 'It ran' : 'It was refused'}: ${result.length > 4_000 ? `${result.slice(0, 4_000)} …[truncated]` : result}`;
3256
3554
  }
3257
- const stored = await store.getConversation(tenantRef, member);
3555
+ const stored = await store.getChat(tenantRef, member, chatId);
3258
3556
  if (stored) {
3259
3557
  const entries = [
3260
3558
  ...readSlotlockAssistantEntries(stored.messages),
3261
3559
  { role: 'event', text: event },
3262
3560
  ];
3263
3561
  const messages = boundSlotlockAssistantEntries(entries);
3264
- await store.putConversation(tenantRef, member, { messages, added: {}, keep: Object.keys(pruneSlotlockAssistantActions(messages, stored.actions)) }, conversationTtl);
3265
- }
3266
- return redirect(303, `${root}/assistant#action-${id}`);
3562
+ await store.putChat(tenantRef, member, chatId, {
3563
+ title: stored.title,
3564
+ messages,
3565
+ added: {},
3566
+ keep: Object.keys(pruneSlotlockAssistantActions(messages, stored.actions)),
3567
+ create: false,
3568
+ }, conversationTtl);
3569
+ }
3570
+ return redirect(303, `${chatUrl(chatId)}#action-${id}`);
3267
3571
  }
3572
+ /** A new conversation: the others are kept, in Chats. (A form of an earlier release posts here.) */
3268
3573
  async function resetAssistant(session) {
3574
+ if (!assistantStore())
3575
+ return notFound(session);
3576
+ return redirect(303, chatUrl(null));
3577
+ }
3578
+ /** Give one of the member's conversations a title of their own. */
3579
+ async function renameChat(session, form) {
3580
+ const store = assistantStore();
3581
+ if (!store)
3582
+ return notFound(session);
3583
+ const chatId = chatOf(form.get('chat'));
3584
+ const title = (form.get('title') ?? '').replace(/\s+/gu, ' ').trim();
3585
+ if (!chatId)
3586
+ return assistantPage(session, 404, 'That chat is not here: it was deleted.', 'new');
3587
+ if ([...title].length < 1 || [...title].length > 120) {
3588
+ return assistantPage(session, 400, 'Give the chat a title of 1 to 120 characters.', chatId);
3589
+ }
3590
+ const renamed = await store.renameChat(tenantOf(session), personOf(session), chatId, title);
3591
+ if (!renamed)
3592
+ return assistantPage(session, 404, 'That chat is not here: it was deleted.', 'new');
3593
+ return redirect(303, chatUrl(chatId));
3594
+ }
3595
+ /** Delete one of the member's conversations, at once and for good. */
3596
+ async function deleteChat(session, form) {
3269
3597
  const store = assistantStore();
3270
3598
  if (!store)
3271
3599
  return notFound(session);
3272
- await store.deleteConversation(tenantOf(session), personOf(session));
3273
- return redirect(303, `${root}/assistant`);
3600
+ const chatId = chatOf(form.get('chat'));
3601
+ if (chatId)
3602
+ await store.deleteChat(tenantOf(session), personOf(session), chatId);
3603
+ return redirect(303, chatUrl(null));
3274
3604
  }
3275
3605
  /**
3276
3606
  * A workspace's plan as both the Usage page and the overview show it: the plan (or the default it
@@ -3535,6 +3865,10 @@ ${contact
3535
3865
  if ((route.startsWith('/locations') || route.startsWith('/groups')) && options.resources.grouping) {
3536
3866
  return locationsPage;
3537
3867
  }
3868
+ if (route.startsWith('/closures') && options.resources.hours)
3869
+ return closuresPage;
3870
+ if (route.startsWith('/contacts') && options.resources.contacts)
3871
+ return contactsPage;
3538
3872
  if (route.startsWith('/resources') || route === '/hours')
3539
3873
  return resourcesPage;
3540
3874
  if (route.startsWith('/assistant') && assistantStore())
@@ -3547,13 +3881,38 @@ ${contact
3547
3881
  return teamsPage;
3548
3882
  return overviewPage;
3549
3883
  }
3884
+ const isLocationTarget = (target) => typeof target === 'object' && 'location' in target && typeof target.location === 'object';
3885
+ /** How many resources keep hours of their own, a few by name, and what saving does to them. */
3886
+ function ownHoursChoice(own) {
3887
+ if (own.length === 0)
3888
+ return html ``;
3889
+ const named = own.slice(0, OWN_HOURS_NAMED);
3890
+ const word = own.length === 1 ? 'resource keeps hours of its own' : 'resources keep hours of their own';
3891
+ return html `<fieldset class="modes"><legend>${String(own.length)} ${word}</legend>
3892
+ <p class="fine">${named.map((resource, index) => html `${index > 0 ? ', ' : ''}<a href="${root}/hours?resource=${resource.id}">${resourceName(resource, resource.id)}</a>`)}${own.length > named.length ? html ` and ${String(own.length - named.length)} more` : html ``}. <a href="${root}/resources?hours=own">See them all</a></p>
3893
+ <label class="choice"><input type="radio" name="override" value="none" checked> Keep them</label>
3894
+ <label class="choice"><input type="radio" name="override" value="matching"> Clear those with the same hours as yours were, or as these: copies an earlier version made when your hours were set</label>
3895
+ <label class="choice"><input type="radio" name="override" value="all"> Clear them all, so every resource uses these</label>
3896
+ </fieldset>`;
3897
+ }
3550
3898
  function hoursPage(session, state) {
3551
3899
  const { target, mode } = state;
3552
3900
  const checked = (value) => (mode === value ? html ` checked` : html ``);
3553
3901
  const server = options.availability ?? [];
3902
+ const place = isLocationTarget(target) ? target.location : null;
3903
+ const resource = target !== 'all' && !place ? target : null;
3554
3904
  const title = target === 'all'
3555
- ? 'Hours for every resource'
3556
- : `Hours for ${target.externalRef ?? target.id}`;
3905
+ ? 'Your hours'
3906
+ : place
3907
+ ? `Hours at ${place.name}`
3908
+ : `Hours for ${resource?.externalRef ?? resource?.id ?? ''}`;
3909
+ const inheritLabel = target === 'all'
3910
+ ? html `The server's hours (${server.length === 0 ? 'none' : hoursSummary(server)})`
3911
+ : place
3912
+ ? html `Inherit: a location it is in, else your hours, else the server's`
3913
+ : resource && resource.availabilityRules === undefined
3914
+ ? html `Inherit (${resourceHours(resource, server)})`
3915
+ : html `Inherit: its location's hours, else yours, else the server's`;
3557
3916
  const rows = WEEKDAYS.map((day) => {
3558
3917
  const cells = state.week[day].flatMap(({ from, to }, index) => {
3559
3918
  const which = index + 1;
@@ -3567,30 +3926,50 @@ ${contact
3567
3926
  });
3568
3927
  const content = html `${state.notice ? html `<p class="notice" role="alert">${state.notice}</p>` : html ``}
3569
3928
  <section>
3570
- <p><a class="back" href="${root}/resources">Resources</a></p>
3929
+ <p><a class="back" href="${root}/${place ? 'locations' : 'resources'}">${place ? 'Locations' : 'Resources'}</a></p>
3571
3930
  <h1>${title}</h1>
3572
3931
  <p>${target === 'all'
3573
- ? 'The same hours for every resource, each in its own time zone. Saving replaces their hours.'
3574
- : `When agents may book it, in its time zone: ${target.timezone}.`}</p>
3932
+ ? 'Every resource without hours of its own uses these, each in its own time zone, unless its location has hours. Resources you add later too.'
3933
+ : place
3934
+ ? `Resources at ${place.name}, or at a location inside it, without hours of their own use these, each in its own time zone, unless a location nearer them has hours.`
3935
+ : `When agents may book it, in its time zone: ${resource?.timezone ?? ''}.`}${options.resources.hours ? html ` <a href="${root}/closures">Closures and special days</a> change single days.` : html ``}</p>
3575
3936
  ${state.unshown
3576
3937
  ? html `<div class="notice warn" role="alert"><p>These hours were set outside the dashboard, so the week below cannot show them. Saving replaces them.</p><ul class="hours">${state.unshown.map((rule) => html `<li><code>${rule.rrule}</code> from ${formatMinutes(rule.startMinutes)} for ${String(rule.durationMinutes)} min</li>`)}</ul></div>`
3577
3938
  : html ``}
3578
3939
  <form method="post" action="${root}/hours">
3579
3940
  <input type="hidden" name="csrf" value="${csrfToken(session)}">
3580
3941
  <input type="hidden" name="once" value="${formNonce(session)}">
3581
- <input type="hidden" name="resource" value="${target === 'all' ? 'all' : target.id}">
3942
+ ${place ? html `<input type="hidden" name="location" value="${place.ref}">` : html `<input type="hidden" name="resource" value="${target === 'all' ? 'all' : (resource?.id ?? '')}">`}
3582
3943
  <fieldset class="modes"><legend>Bookable hours</legend>
3583
- <label class="choice"><input type="radio" name="mode" value="server"${checked('server')}> The server's hours (${server.length === 0 ? 'none' : hoursSummary(server)})</label>
3944
+ <label class="choice"><input type="radio" name="mode" value="server"${checked('server')}> ${inheritLabel}</label>
3584
3945
  <label class="choice"><input type="radio" name="mode" value="closed"${checked('closed')}> Closed: agents find no free slot</label>
3585
3946
  <label class="choice"><input type="radio" name="mode" value="custom"${checked('custom')}> These hours</label>
3586
3947
  </fieldset>
3587
3948
  <div class="table"><table class="week"><thead><tr><th>Day</th><th>Opens</th><th>Closes</th><th>Opens</th><th>Closes</th></tr></thead><tbody>${rows}</tbody></table></div>
3588
3949
  <p class="fine">A closing time at or before the opening time closes the next day; 00:00 to 00:00 is the whole day. Leave a window empty for none.</p>
3950
+ ${target === 'all' ? ownHoursChoice(state.own ?? []) : html ``}
3589
3951
  <button class="primary" type="submit">Save hours</button>
3590
3952
  </form>
3591
3953
  </section>`;
3592
3954
  return respond(state.status ?? 200, layout('Bookable hours', content, session, 'resources'));
3593
3955
  }
3956
+ /** The editor's state for stored rules: inherit (null), closed ([]), or a week. */
3957
+ function hoursState(rules) {
3958
+ if (rules === undefined || rules === null) {
3959
+ return { mode: 'server', week: weekFormOf(rulesToWeeklyHours(options.availability ?? [])) };
3960
+ }
3961
+ if (rules.length === 0)
3962
+ return { mode: 'closed', week: emptyWeekForm() };
3963
+ const week = rulesToWeeklyHours(rules);
3964
+ return { mode: 'custom', week: weekFormOf(week), ...(week === null ? { unshown: rules } : {}) };
3965
+ }
3966
+ /** The tenant's location with that reference, or null. */
3967
+ async function ownLocation(session, ref) {
3968
+ const grouping = options.resources.grouping;
3969
+ if (!grouping || !/^[a-z0-9][a-z0-9._-]{0,99}$/.test(ref))
3970
+ return null;
3971
+ return (await grouping.locations(tenantOf(session))).find((location) => location.ref === ref) ?? null;
3972
+ }
3594
3973
  /** The tenant's resource with that id, or null for anything else. */
3595
3974
  async function ownResource(session, id) {
3596
3975
  if (!UUID_PATTERN.test(id))
@@ -3600,38 +3979,51 @@ ${state.unshown
3600
3979
  }
3601
3980
  const notFound = (session) => respond(404, layout('Not found', html `<p class="notice">That resource is not one of yours.</p>`, session));
3602
3981
  const nothingHere = (session) => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
3982
+ /** The tenant's resources in use that keep hours of their own, by name. */
3983
+ async function ownHoursOf(session) {
3984
+ const resources = await options.resources.list(tenantOf(session));
3985
+ return resources
3986
+ .filter((resource) => resource.archivedAt === undefined && resource.availabilityRules !== undefined)
3987
+ .sort((one, other) => resourceName(one, one.id).localeCompare(resourceName(other, other.id)));
3988
+ }
3603
3989
  async function hoursEditor(session, url) {
3990
+ const ref = url.searchParams.get('location');
3991
+ if (ref !== null) {
3992
+ const location = await ownLocation(session, ref);
3993
+ if (!location || !options.resources.hours)
3994
+ return nothingHere(session);
3995
+ const defaults = await options.resources.hours.defaults(tenantOf(session));
3996
+ const rules = defaults.locations.find((entry) => entry.location === ref)?.rules ?? null;
3997
+ return hoursPage(session, { target: { location }, ...hoursState(rules) });
3998
+ }
3604
3999
  const id = url.searchParams.get('resource');
3605
4000
  if (id === null) {
3606
- const server = rulesToWeeklyHours(options.availability ?? []);
3607
- return hoursPage(session, { target: 'all', mode: 'server', week: weekFormOf(server) });
4001
+ const [defaults, own] = await Promise.all([options.resources.hours?.defaults(tenantOf(session)), ownHoursOf(session)]);
4002
+ return hoursPage(session, { target: 'all', ...hoursState(defaults?.tenant ?? null), own });
3608
4003
  }
3609
4004
  const resource = await ownResource(session, id);
3610
4005
  if (!resource)
3611
4006
  return notFound(session);
3612
- const rules = resource.availabilityRules;
3613
- if (rules === undefined) {
3614
- const server = rulesToWeeklyHours(options.availability ?? []);
3615
- return hoursPage(session, { target: resource, mode: 'server', week: weekFormOf(server) });
3616
- }
3617
- if (rules.length === 0) {
3618
- return hoursPage(session, { target: resource, mode: 'closed', week: emptyWeekForm() });
3619
- }
3620
- const week = rulesToWeeklyHours(rules);
3621
- return hoursPage(session, {
3622
- target: resource,
3623
- mode: 'custom',
3624
- week: weekFormOf(week),
3625
- ...(week === null ? { unshown: rules } : {}),
3626
- });
4007
+ return hoursPage(session, { target: resource, ...hoursState(resource.availabilityRules) });
3627
4008
  }
4009
+ /** The form's choice for resources' own hours (`yes`: the checkbox of a page from before); anything else keeps them. */
4010
+ const overrideOf = (value) => value === 'all' || value === 'yes' ? 'all' : value === 'matching' ? 'matching' : 'none';
3628
4011
  async function saveHours(session, form) {
4012
+ const ref = form.get('location');
3629
4013
  const id = form.get('resource') ?? '';
3630
- const target = id === 'all' ? 'all' : await ownResource(session, id);
4014
+ let target;
4015
+ if (ref !== null) {
4016
+ const location = await ownLocation(session, ref);
4017
+ target = location && options.resources.hours ? { location } : null;
4018
+ }
4019
+ else {
4020
+ target = id === 'all' ? 'all' : await ownResource(session, id);
4021
+ }
3631
4022
  if (!target)
3632
4023
  return notFound(session);
3633
4024
  const mode = form.get('mode');
3634
4025
  const week = readWeekForm(form);
4026
+ const own = target === 'all' ? await ownHoursOf(session) : undefined;
3635
4027
  if (mode !== 'server' && mode !== 'closed' && mode !== 'custom') {
3636
4028
  return hoursPage(session, {
3637
4029
  target,
@@ -3639,6 +4031,7 @@ ${state.unshown
3639
4031
  week,
3640
4032
  status: 400,
3641
4033
  notice: 'Choose which hours to use.',
4034
+ own,
3642
4035
  });
3643
4036
  }
3644
4037
  let rules = mode === 'server' ? null : [];
@@ -3660,16 +4053,254 @@ ${state.unshown
3660
4053
  week,
3661
4054
  status: 400,
3662
4055
  notice: error.message,
4056
+ own,
3663
4057
  });
3664
4058
  }
3665
4059
  }
3666
- const changed = await options.resources.setHours(tenantOf(session), target === 'all' ? 'all' : { id: target.id }, rules, personOf(session));
3667
- if (changed === 0) {
3668
- return target === 'all'
3669
- ? resourcesPage(session, 409, 'Add a resource first, then set its hours.')
3670
- : notFound(session);
4060
+ const place = isLocationTarget(target) ? target.location : null;
4061
+ const changed = target === 'all'
4062
+ ? await options.resources.setHours(tenantOf(session), 'all', rules, personOf(session), overrideOf(form.get('override')))
4063
+ : await options.resources.setHours(tenantOf(session), place ? { location: place.ref } : { id: target.id }, rules, personOf(session));
4064
+ if (changed === 0 && target !== 'all')
4065
+ return notFound(session);
4066
+ return redirect(303, `${root}/${place ? 'locations' : 'resources'}`);
4067
+ }
4068
+ /** A subscription's address as the page names it: its host, never its path or query (which may hold a token). */
4069
+ const hostOfUrl = (url) => {
4070
+ try {
4071
+ return new URL(url).host;
4072
+ }
4073
+ catch {
4074
+ return 'its address';
3671
4075
  }
3672
- return redirect(303, `${root}/resources`);
4076
+ };
4077
+ /** The tenant's closures and special days from yesterday on, with forms to add and import more. */
4078
+ async function closuresPage(session, status = 200, notice) {
4079
+ const hours = options.resources.hours;
4080
+ if (!hours)
4081
+ return nothingHere(session);
4082
+ const tenantRef = tenantOf(session);
4083
+ const [exceptions, locations, resources, calendars] = await Promise.all([
4084
+ hours.exceptions(tenantRef, SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS + 1),
4085
+ options.resources.grouping?.locations(tenantRef) ?? Promise.resolve([]),
4086
+ options.resources.list(tenantRef),
4087
+ hours.calendars?.(tenantRef) ?? Promise.resolve([]),
4088
+ ]);
4089
+ const token = csrfToken(session);
4090
+ const sent = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
4091
+ const names = new Map(resources.map((resource) => [resource.id, resourceName(resource, resource.id)]));
4092
+ const places = new Map(locations.map((location) => [location.ref, location.name]));
4093
+ const scopeOf = (exception) => exception.resourceId
4094
+ ? names.get(exception.resourceId) ?? exception.resourceId
4095
+ : exception.location
4096
+ ? `${places.get(exception.location) ?? exception.location} (location)`
4097
+ : 'Everywhere';
4098
+ const days = (exception) => exception.startsOn === exception.endsOn ? exception.startsOn : `${exception.startsOn} to ${exception.endsOn}`;
4099
+ const what = (exception) => exception.outcome === 'closed'
4100
+ ? 'Closed'
4101
+ : `Open ${(exception.hours ?? []).map((window) => hoursOf(window.startMinutes, window.durationMinutes)).join(', ')}`;
4102
+ const scopeOptions = html `<option value="">Everywhere</option>${locations.map((location) => html `<option value="location:${location.ref}">${location.name} (location)</option>`)}${resources.slice(0, SLOTLOCK_DASHBOARD_MAX_RESOURCES).map((resource) => html `<option value="resource:${resource.id}">${resourceName(resource, resource.id)}</option>`)}`;
4103
+ const shown = exceptions.slice(0, SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS);
4104
+ const content = html `${noticeOf(notice)}
4105
+ <section>
4106
+ <p><a class="back" href="${root}/resources">Resources</a></p>
4107
+ <h1>Closures and special days</h1>
4108
+ <p>Each closes whole days, or gives them hours of their own instead of the weekly hours, read in each resource's time zone. Where several apply to a day, the nearest wins: a resource's own, then its location's, then everywhere's.</p>
4109
+ <div class="table"><table><thead><tr><th>Days</th><th>Applies to</th><th>Hours</th><th>Label</th><th></th></tr></thead><tbody>${shown.length === 0
4110
+ ? html `<tr><td colspan="5" class="muted">No closures from today on.</td></tr>`
4111
+ : shown.map((exception) => html `<tr><td data-label="Days">${days(exception)}</td><td data-label="Applies to">${scopeOf(exception)}</td><td data-label="Hours">${what(exception)}</td><td data-label="Label" class="wrap">${exception.label}${exception.source === 'ics' ? html ` <span class="meta">imported</span>` : html ``}</td><td class="actions"><form method="post" action="${root}/closures/delete">${sent()}<input type="hidden" name="id" value="${exception.id}"><button type="submit">Delete</button></form></td></tr>`)}</tbody></table></div>
4112
+ ${exceptions.length > SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS ? html `<p class="fine">The first ${String(SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS)} are shown; the API lists them all.</p>` : html ``}
4113
+ <h2>Add a closure or special day</h2>
4114
+ <form method="post" action="${root}/closures" class="stack">${sent()}
4115
+ <label>Applies to <select name="scope">${scopeOptions}</select></label>
4116
+ <label>First day <input type="date" name="starts_on" required></label>
4117
+ <label>Last day <input type="date" name="ends_on"></label>
4118
+ <fieldset class="modes"><legend>Those days</legend>
4119
+ <label class="choice"><input type="radio" name="outcome" value="closed" checked> Closed</label>
4120
+ <label class="choice"><input type="radio" name="outcome" value="open"> Open only from <input type="time" name="opens" aria-label="Opens"> to <input type="time" name="closes" aria-label="Closes"></label>
4121
+ </fieldset>
4122
+ <label>Label <input name="label" maxlength="200" required placeholder="Christmas Day"></label>
4123
+ <button class="primary" type="submit">Add</button>
4124
+ </form>
4125
+ ${calendars.length > 0 ? html `<h2>Holiday calendars</h2>
4126
+ <div class="table"><table><thead><tr><th>Calendar</th><th>Applies to</th><th>From</th><th>Closures</th><th></th></tr></thead><tbody>${calendars.map((calendar) => html `<tr><td data-label="Calendar" class="wrap">${calendar.name}${calendar.lastError ? html `<span class="meta bad" role="status">The last import failed: ${calendar.lastError}</span>` : html ``}</td><td data-label="Applies to">${calendar.resourceId
4127
+ ? names.get(calendar.resourceId) ?? calendar.resourceId
4128
+ : calendar.location
4129
+ ? `${places.get(calendar.location) ?? calendar.location} (location)`
4130
+ : 'Everywhere'}</td><td data-label="From" class="wrap">${calendar.url
4131
+ ? html `${hostOfUrl(calendar.url)}<span class="meta">Imported again every 30 days${calendar.nextImportAt ? html `, next ${calendar.nextImportAt.toISOString().slice(0, 10)}` : html ``}</span>`
4132
+ : html `Uploaded or pasted${calendar.importedAt ? html `<span class="meta">${calendar.importedAt.toISOString().slice(0, 10)}</span>` : html ``}`}</td><td data-label="Closures">${String(calendar.closures)}</td><td class="actions">${hours.deleteCalendar
4133
+ ? html `<form method="post" action="${root}/closures/calendars/delete">${sent()}<input type="hidden" name="id" value="${calendar.id}"><button type="submit" class="danger">Delete</button></form>`
4134
+ : html ``}</td></tr>`)}</tbody></table></div>
4135
+ <p class="fine">Deleting a calendar deletes its closures too.</p>` : html ``}
4136
+ <h2>Import a holiday calendar</h2>
4137
+ <p>Upload or paste an iCalendar file of days off${hours.byUrl ? html `, or give its address` : html ``}: each all-day event closes its days for the next two years. A calendar is kept by its name where it applies: importing it again updates its closures and removes the holidays it no longer has.</p>
4138
+ <form method="post" action="${root}/closures/import" class="stack" enctype="multipart/form-data">${sent()}
4139
+ <label>Applies to <select name="scope">${scopeOptions}</select></label>
4140
+ <label>Name <input name="name" maxlength="200" placeholder="Its own name, else its address, else Holidays"></label>
4141
+ <label>File <input type="file" name="icalendar_file" accept=".ics,text/calendar"></label>
4142
+ <label>Or paste it <textarea name="icalendar" rows="6" placeholder="BEGIN:VCALENDAR"></textarea></label>
4143
+ ${hours.byUrl ? html `<label>Or its address <input type="url" name="url" maxlength="2000" placeholder="https://example.com/holidays.ics"></label>
4144
+ <label class="choice"><input type="checkbox" name="subscribe" value="on"> Import it again every 30 days</label>` : html ``}
4145
+ <button type="submit">Import</button>
4146
+ </form>
4147
+ </section>`;
4148
+ return respond(status, layout('Closures', content, session, 'resources'));
4149
+ }
4150
+ /**
4151
+ * The people the tenant keeps to tell about bookings (RFC 0004 P3): how to reach each, what they
4152
+ * agreed to and when, and a button that forgets one (their reference leaves every booking too).
4153
+ * Contacts are added by the tenant's own systems, through the API.
4154
+ */
4155
+ async function contactsPage(session, status = 200, notice, after) {
4156
+ const contacts = options.resources.contacts;
4157
+ if (!contacts)
4158
+ return nothingHere(session);
4159
+ const listed = await contacts.list(tenantOf(session), SLOTLOCK_DASHBOARD_CONTACTS_PAGE + 1, after);
4160
+ const shown = listed.slice(0, SLOTLOCK_DASHBOARD_CONTACTS_PAGE);
4161
+ const token = csrfToken(session);
4162
+ const reach = (contact) => [contact.email, contact.phone ? `SMS ${contact.phone}` : null, contact.whatsapp ? `WhatsApp ${contact.whatsapp}` : null]
4163
+ .filter(Boolean)
4164
+ .join(', ') || 'No way to reach them';
4165
+ const agreed = (contact) => ['email', 'sms', 'whatsapp']
4166
+ .filter((channel) => contact.consent[channel] !== undefined)
4167
+ .map((channel) => `${channel === 'sms' ? 'SMS' : channel === 'whatsapp' ? 'WhatsApp' : 'Email'}: ${contact.consent[channel] ? 'yes' : 'no'}${contact.consentAt[channel] ? ` (${contact.consentAt[channel]?.toISOString().slice(0, 10)})` : ''}`)
4168
+ .join('; ') || 'Not asked';
4169
+ const content = html `${noticeOf(notice)}
4170
+ <section>
4171
+ <p><a class="back" href="${root}/resources">Resources</a></p>
4172
+ <h1>Contacts</h1>
4173
+ <p>The people your bookings name to be told about them, kept by your own systems through the API (<code>PUT /v1/contacts/{contact_ref}</code>). Forgetting one deletes them and takes their reference off every booking, for an erasure request.</p>
4174
+ <table><thead><tr><th>Reference</th><th>Name</th><th>Reach by</th><th>Agreed to</th><th></th></tr></thead><tbody>${shown.length === 0
4175
+ ? html `<tr><td colspan="5" class="muted">No contacts yet.</td></tr>`
4176
+ : shown.map((contact) => html `<tr><td data-label="Reference"><code>${contact.ref}</code></td><td data-label="Name">${contact.name ?? ''}</td><td data-label="Reach by" class="wrap">${reach(contact)}</td><td data-label="Agreed to" class="wrap">${agreed(contact)}</td><td class="actions"><form method="post" action="${root}/contacts/forget"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="ref" value="${contact.ref}"><button type="submit" class="danger">Forget</button></form></td></tr>`)}</tbody></table>
4177
+ ${listed.length > SLOTLOCK_DASHBOARD_CONTACTS_PAGE ? html `<p><a class="button" href="${root}/contacts?after=${encodeURIComponent(shown.at(-1)?.ref ?? '')}">More</a></p>` : html ``}
4178
+ </section>`;
4179
+ return respond(status, layout('Contacts', content, session, 'resources'));
4180
+ }
4181
+ async function forgetContact(session, form) {
4182
+ const contacts = options.resources.contacts;
4183
+ if (!contacts)
4184
+ return nothingHere(session);
4185
+ const ref = form.get('ref') ?? '';
4186
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(ref))
4187
+ return contactsPage(session, 400, 'That is not a contact reference.');
4188
+ const forgotten = await contacts.forget(tenantOf(session), ref, personOf(session));
4189
+ if (!forgotten.deleted)
4190
+ return contactsPage(session, 404, 'That contact is not one of yours.');
4191
+ return contactsPage(session, 200, `Forgotten. ${forgotten.bookings} booking${forgotten.bookings === 1 ? '' : 's'} no longer name them.`);
4192
+ }
4193
+ /** `location:<ref>` or `resource:<id>` from a closures form, checked against the tenant's. */
4194
+ async function closureScope(session, value) {
4195
+ if (value === '')
4196
+ return {};
4197
+ if (value.startsWith('location:')) {
4198
+ const location = await ownLocation(session, value.slice(9));
4199
+ return location ? { location: location.ref } : null;
4200
+ }
4201
+ if (value.startsWith('resource:')) {
4202
+ const resource = await ownResource(session, value.slice(9));
4203
+ return resource ? { resourceId: resource.id } : null;
4204
+ }
4205
+ return null;
4206
+ }
4207
+ async function saveClosure(session, form) {
4208
+ const hours = options.resources.hours;
4209
+ if (!hours)
4210
+ return nothingHere(session);
4211
+ const scope = await closureScope(session, form.get('scope') ?? '');
4212
+ if (!scope)
4213
+ return closuresPage(session, 400, 'Choose where it applies.');
4214
+ const outcome = form.get('outcome') === 'open' ? 'open' : 'closed';
4215
+ const startsOn = form.get('starts_on') ?? '';
4216
+ const endsOn = form.get('ends_on') || startsOn;
4217
+ let windows;
4218
+ if (outcome === 'open') {
4219
+ const opens = minutesOf(form.get('opens') ?? '');
4220
+ const closes = minutesOf(form.get('closes') ?? '');
4221
+ if (opens === null || closes === null)
4222
+ return closuresPage(session, 400, 'Give the hours those days open.');
4223
+ windows = [{ startMinutes: opens, durationMinutes: closes > opens ? closes - opens : closes + 1_440 - opens }];
4224
+ }
4225
+ try {
4226
+ await hours.addException(tenantOf(session), {
4227
+ ...scope,
4228
+ startsOn,
4229
+ endsOn,
4230
+ outcome,
4231
+ ...(windows ? { hours: windows } : {}),
4232
+ label: form.get('label') ?? '',
4233
+ }, personOf(session));
4234
+ }
4235
+ catch (error) {
4236
+ const code = error.code;
4237
+ if (code === 'invalid_availability_exception' || code === 'availability_exceptions_limit_reached') {
4238
+ return closuresPage(session, code === 'invalid_availability_exception' ? 400 : 409, error.message);
4239
+ }
4240
+ throw error;
4241
+ }
4242
+ return redirect(303, `${root}/closures`);
4243
+ }
4244
+ async function deleteClosure(session, form) {
4245
+ const hours = options.resources.hours;
4246
+ if (!hours)
4247
+ return nothingHere(session);
4248
+ const id = form.get('id') ?? '';
4249
+ if (!UUID_PATTERN.test(id) || !(await hours.deleteException(tenantOf(session), id, personOf(session)))) {
4250
+ return closuresPage(session, 404, 'That closure is not one of yours.');
4251
+ }
4252
+ return redirect(303, `${root}/closures`);
4253
+ }
4254
+ async function importClosures(session, form) {
4255
+ const hours = options.resources.hours;
4256
+ if (!hours)
4257
+ return nothingHere(session);
4258
+ const scope = await closureScope(session, form.get('scope') ?? '');
4259
+ if (!scope)
4260
+ return closuresPage(session, 400, 'Choose where it applies.');
4261
+ // An uploaded file, else what was pasted, else (where the server reads them) an address.
4262
+ const text = (form.get('icalendar_file') ?? '').trim() || (form.get('icalendar') ?? '').trim();
4263
+ const url = hours.byUrl ? (form.get('url') ?? '').trim() : '';
4264
+ if ((text === '') === (url === '')) {
4265
+ return closuresPage(session, 400, hours.byUrl ? 'Upload or paste a calendar, or give its address: one of them.' : 'Upload or paste a calendar.');
4266
+ }
4267
+ const name = (form.get('name') ?? '').trim();
4268
+ const source = {
4269
+ ...(text !== '' ? { icalendar: text } : { url }),
4270
+ ...(name !== '' ? { name } : {}),
4271
+ ...(url !== '' && form.get('subscribe') === 'on' ? { subscribe: true } : {}),
4272
+ };
4273
+ try {
4274
+ const result = await hours.importHolidays(tenantOf(session), source, scope, personOf(session));
4275
+ const plural = (count, word) => `${String(count)} ${word}${count === 1 ? '' : 's'}`;
4276
+ return closuresPage(session, 200, `Imported ${plural(result.imported, 'closure')}${result.removed > 0 ? `; removed ${plural(result.removed, 'holiday')} the calendar no longer has` : ''}${result.skipped > 0 ? `; skipped ${plural(result.skipped, 'event')} that were not all-day or not in the next two years` : ''}.`);
4277
+ }
4278
+ catch (error) {
4279
+ const code = error.code;
4280
+ if (code === 'invalid_holiday_calendar' ||
4281
+ code === 'invalid_availability_exception' ||
4282
+ code === 'holiday_calendar_unreachable' ||
4283
+ code === 'holiday_url_not_served' ||
4284
+ code === 'location_not_found' ||
4285
+ code === 'resource_not_found') {
4286
+ return closuresPage(session, 400, error.message);
4287
+ }
4288
+ if (code === 'availability_exceptions_limit_reached' || code === 'holiday_calendars_limit_reached') {
4289
+ return closuresPage(session, 409, error.message);
4290
+ }
4291
+ throw error;
4292
+ }
4293
+ }
4294
+ /** Delete a holiday calendar and the closures it made. */
4295
+ async function deleteHolidayCalendar(session, form) {
4296
+ const hours = options.resources.hours;
4297
+ if (!hours?.deleteCalendar)
4298
+ return nothingHere(session);
4299
+ const id = form.get('id') ?? '';
4300
+ if (!(await hours.deleteCalendar(tenantOf(session), id, personOf(session)))) {
4301
+ return closuresPage(session, 404, 'That calendar is not one of yours.');
4302
+ }
4303
+ return redirect(303, `${root}/closures`);
3673
4304
  }
3674
4305
  function shownOncePage(session, key, apiKey, rotated) {
3675
4306
  const org = session.orgs?.find((candidate) => candidate.id === session.org);
@@ -4120,9 +4751,11 @@ ${rows.length === 0
4120
4751
  ]);
4121
4752
  }
4122
4753
  /** The form, `'too_large'` past MAX_FORM_BYTES (read no further), or null for another type. */
4123
- async function readForm(request, maxBytes = MAX_FORM_BYTES) {
4124
- const type = request.headers.get('content-type')?.split(';')[0]?.trim();
4125
- if (type !== 'application/x-www-form-urlencoded')
4754
+ async function readForm(request, maxBytes = MAX_FORM_BYTES, multipart = false) {
4755
+ const header = request.headers.get('content-type') ?? '';
4756
+ const type = header.split(';')[0]?.trim();
4757
+ const isMultipart = multipart && type === 'multipart/form-data';
4758
+ if (type !== 'application/x-www-form-urlencoded' && !isMultipart)
4126
4759
  return null;
4127
4760
  const declared = Number(request.headers.get('content-length'));
4128
4761
  if (Number.isFinite(declared) && declared > maxBytes)
@@ -4143,7 +4776,24 @@ ${rows.length === 0
4143
4776
  }
4144
4777
  chunks.push(value);
4145
4778
  }
4146
- return new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
4779
+ if (!isMultipart)
4780
+ return new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
4781
+ // A file field is read as text under its name; nothing else a form sends is kept apart.
4782
+ let data;
4783
+ try {
4784
+ data = await new Response(Buffer.concat(chunks), { headers: { 'content-type': header } }).formData();
4785
+ }
4786
+ catch {
4787
+ return null;
4788
+ }
4789
+ const form = new URLSearchParams();
4790
+ for (const [name, value] of data.entries()) {
4791
+ if (typeof value === 'string')
4792
+ form.append(name, value);
4793
+ else if (value.size > 0)
4794
+ form.append(name, await value.text());
4795
+ }
4796
+ return form;
4147
4797
  }
4148
4798
  async function createKey(session, form) {
4149
4799
  const scopes = KEY_ACCESS[form.get('access') ?? ''];
@@ -4290,7 +4940,13 @@ ${rows.length === 0
4290
4940
  const session = await readSession(request);
4291
4941
  if (!session)
4292
4942
  return redirect(303, root);
4293
- const form = await readForm(request, route === '/assistant' || route === '/assistant/stream' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
4943
+ // A holiday calendar may be uploaded, or pasted, up to its own limit.
4944
+ const importing = route === '/closures/import';
4945
+ const form = await readForm(request, importing
4946
+ ? MAX_IMPORT_FORM_BYTES
4947
+ : route === '/assistant' || route === '/assistant/stream'
4948
+ ? MAX_ASSISTANT_FORM_BYTES
4949
+ : MAX_FORM_BYTES, importing);
4294
4950
  if (form === 'too_large') {
4295
4951
  return respond(413, layout('Refused', html `<p class="notice">That form is too large.</p>`, session));
4296
4952
  }
@@ -4329,8 +4985,10 @@ ${rows.length === 0
4329
4985
  }
4330
4986
  if (!first) {
4331
4987
  // The first one was taken: a second click, a reload or a resend shows what it did.
4332
- if (route.startsWith('/assistant'))
4333
- return redirect(303, `${root}/assistant#latest`);
4988
+ if (route.startsWith('/assistant')) {
4989
+ const chat = chatOf(form.get('chat'));
4990
+ return redirect(303, chat ? `${chatUrl(chat)}#latest` : `${root}/assistant#latest`);
4991
+ }
4334
4992
  return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
4335
4993
  }
4336
4994
  }
@@ -4354,6 +5012,16 @@ ${rows.length === 0
4354
5012
  return addResource(session, form);
4355
5013
  case '/hours':
4356
5014
  return saveHours(session, form);
5015
+ case '/closures':
5016
+ return saveClosure(session, form);
5017
+ case '/closures/delete':
5018
+ return deleteClosure(session, form);
5019
+ case '/closures/import':
5020
+ return importClosures(session, form);
5021
+ case '/closures/calendars/delete':
5022
+ return deleteHolidayCalendar(session, form);
5023
+ case '/contacts/forget':
5024
+ return forgetContact(session, form);
4357
5025
  case '/organization':
4358
5026
  return switchOrganization(session, form);
4359
5027
  case '/environment':
@@ -4402,6 +5070,10 @@ ${rows.length === 0
4402
5070
  return settleAction(session, form, 'dismiss');
4403
5071
  case '/assistant/reset':
4404
5072
  return resetAssistant(session);
5073
+ case '/assistant/rename':
5074
+ return renameChat(session, form);
5075
+ case '/assistant/delete':
5076
+ return deleteChat(session, form);
4405
5077
  case '/settings/assistant':
4406
5078
  return saveAssistantSettings(session, form);
4407
5079
  case '/settings/assistant/models':
@@ -4443,6 +5115,7 @@ ${rows.length === 0
4443
5115
  case '/waitlist':
4444
5116
  case '/approvals':
4445
5117
  case '/assistant':
5118
+ case '/assistant/chats':
4446
5119
  case '/plan':
4447
5120
  case '/teams':
4448
5121
  case '/settings/endpoints':
@@ -4461,8 +5134,9 @@ ${rows.length === 0
4461
5134
  }
4462
5135
  if (route === '/keys')
4463
5136
  return await keysPage(session);
4464
- if (route === '/resources')
4465
- return await resourcesPage(session);
5137
+ if (route === '/resources') {
5138
+ return await resourcesPage(session, 200, undefined, url.searchParams.get('hours') === 'own');
5139
+ }
4466
5140
  if (route === '/webhooks')
4467
5141
  return await webhooksPage(session);
4468
5142
  if (route === '/waitlist')
@@ -4470,7 +5144,9 @@ ${rows.length === 0
4470
5144
  if (route === '/approvals')
4471
5145
  return await approvalsPage(session);
4472
5146
  if (route === '/assistant')
4473
- return await assistantPage(session);
5147
+ return await assistantPage(session, 200, undefined, url.searchParams.get('c') ?? undefined);
5148
+ if (route === '/assistant/chats')
5149
+ return await chatsPage(session);
4474
5150
  if (route === '/settings/endpoints')
4475
5151
  return await settingsPage(session, 200, undefined, undefined, 'endpoints');
4476
5152
  if (route === '/settings/hours')
@@ -4497,6 +5173,15 @@ ${rows.length === 0
4497
5173
  const session = await readSession(request);
4498
5174
  return session ? await hoursEditor(session, url) : redirect(303, root);
4499
5175
  }
5176
+ case '/closures': {
5177
+ const session = await readSession(request);
5178
+ return session ? await closuresPage(session) : redirect(303, root);
5179
+ }
5180
+ case '/contacts': {
5181
+ const session = await readSession(request);
5182
+ const after = url.searchParams.get('after') ?? undefined;
5183
+ return session ? await contactsPage(session, 200, undefined, after) : redirect(303, root);
5184
+ }
4500
5185
  case '/locations': {
4501
5186
  const session = await readSession(request);
4502
5187
  return session ? await locationsPage(session) : redirect(303, root);