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.
- package/CHANGELOG.md +237 -0
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +861 -55
- package/application-api.d.ts +19 -1
- package/application-api.js +578 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +34 -8
- package/assistant.js +81 -26
- package/availability-store.d.ts +193 -0
- package/availability-store.js +558 -0
- package/booking-rules.d.ts +92 -5
- package/booking-rules.js +243 -15
- package/dashboard-assistant-ui.d.ts +1 -1
- package/dashboard-assistant-ui.js +109 -8
- package/dashboard.d.ts +55 -6
- package/dashboard.js +790 -105
- package/ddl.d.ts +5 -1
- package/ddl.js +760 -9
- package/event-feed.d.ts +1 -1
- package/event-feed.js +18 -0
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +57 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +150 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +14 -3
- package/index.js +10 -2
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +19 -0
- package/maintenance.js +55 -1
- package/metrics.js +3 -0
- package/openapi.json +16237 -7795
- package/package.json +1 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/self-host.d.ts +1 -1
- package/self-host.js +221 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +367 -24
- package/store.js +1567 -97
- 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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1416
|
-
const label = CHANGE_LABELS
|
|
1417
|
-
sentence =
|
|
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
|
|
1623
|
-
const allowance = await resourceAllowance(tenantOf(session),
|
|
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.
|
|
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
|
-
${
|
|
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>${
|
|
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"
|
|
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
|
-
?
|
|
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
|
|
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>${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2786
|
-
|
|
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
|
|
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',
|
|
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
|
-
|
|
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.
|
|
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
|
|
3156
|
-
|
|
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
|
-
|
|
3167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3217
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
3265
|
-
|
|
3266
|
-
|
|
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
|
-
|
|
3273
|
-
|
|
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
|
-
? '
|
|
3556
|
-
:
|
|
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}
|
|
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
|
-
? '
|
|
3574
|
-
:
|
|
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
|
-
|
|
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')}>
|
|
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
|
|
3607
|
-
return hoursPage(session, { target: 'all',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
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
|
-
|
|
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
|
|
4125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|