slotlock 0.5.2 → 0.5.3
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 +98 -0
- package/README.md +97 -18
- package/SPEC.md +10 -7
- package/agent-server.d.ts +38 -4
- package/agent-server.js +464 -145
- package/agent-store-backend.d.ts +9 -0
- package/agent-store-backend.js +83 -15
- package/api-keys.d.ts +12 -2
- package/api-keys.js +36 -7
- package/application-api.d.ts +7 -1
- package/application-api.js +67 -7
- package/assistant.js +2 -1
- package/booking-rules.d.ts +61 -9
- package/booking-rules.js +106 -16
- package/change-watch.d.ts +56 -0
- package/change-watch.js +213 -0
- package/dashboard.d.ts +3 -1
- package/dashboard.js +98 -15
- package/ddl.d.ts +1 -1
- package/ddl.js +395 -4
- package/event-feed.d.ts +37 -4
- package/event-feed.js +74 -6
- package/index.d.ts +7 -6
- package/index.js +5 -4
- package/maintenance.js +12 -10
- package/openapi.json +1284 -82
- package/package.json +1 -1
- package/self-host.js +8 -4
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +32 -3
- package/store.js +324 -124
- package/types.d.ts +14 -1
- package/webhooks.d.ts +9 -2
- package/webhooks.js +6 -1
package/dashboard.js
CHANGED
|
@@ -102,7 +102,8 @@ const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-sr
|
|
|
102
102
|
* in the database (a READ COMMITTED transaction, under a per-tenant lock).
|
|
103
103
|
*/
|
|
104
104
|
export function createSlotlockDashboardResources(store) {
|
|
105
|
-
|
|
105
|
+
// Every change here is the dashboard's, made by the person signed in (actor) when known.
|
|
106
|
+
const by = (actor) => actor === undefined ? { origin: 'dashboard' } : { origin: 'dashboard', actor };
|
|
106
107
|
return {
|
|
107
108
|
list: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listResources({ tenantRef, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES + 1 })),
|
|
108
109
|
add: (tenantRef, externalRef, timezone, actor) => store.withTenant(tenantRef, (tenant) => tenant.createResource({
|
|
@@ -120,6 +121,7 @@ export function createSlotlockDashboardResources(store) {
|
|
|
120
121
|
agenda: (tenantRef, window, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAgenda({ tenantRef, window, limit })),
|
|
121
122
|
recentChanges: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listRecentChanges({ tenantRef, limit })),
|
|
122
123
|
setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
|
|
124
|
+
ruleDefaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getBookingRuleDefaults({ tenantRef })),
|
|
123
125
|
setPool: (tenantRef, id, pool, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourcePool({ tenantRef, id, pool }), by(actor)),
|
|
124
126
|
waitlist: {
|
|
125
127
|
list: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listWaitlist({ tenantRef, limit })),
|
|
@@ -128,7 +130,9 @@ export function createSlotlockDashboardResources(store) {
|
|
|
128
130
|
// An offered entry's hold went with it: offer that time to the next, as the API does.
|
|
129
131
|
if (left) {
|
|
130
132
|
await store
|
|
131
|
-
.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 })
|
|
133
|
+
.withTenant(tenantRef, (tenant) => tenant.offerWaitlist({ tenantRef, limit: 20 }), {
|
|
134
|
+
origin: 'maintenance',
|
|
135
|
+
})
|
|
132
136
|
.catch(() => undefined);
|
|
133
137
|
}
|
|
134
138
|
return left;
|
|
@@ -311,6 +315,8 @@ const RULE_NAMES = Object.freeze({
|
|
|
311
315
|
minNoticeMinutes: 'Minimum notice',
|
|
312
316
|
maxAdvanceDays: 'Book up to',
|
|
313
317
|
slotStepMinutes: 'Start step',
|
|
318
|
+
stepOffsetMinutes: 'Step offset',
|
|
319
|
+
bufferBeforeMinutes: 'Buffer before',
|
|
314
320
|
turnaroundMinutes: 'Turnaround',
|
|
315
321
|
checkInMinutes: 'Check-in release',
|
|
316
322
|
});
|
|
@@ -319,12 +325,53 @@ const RULE_FIELDS = Object.freeze({
|
|
|
319
325
|
minNoticeMinutes: 'min_notice_minutes',
|
|
320
326
|
maxAdvanceDays: 'max_advance_days',
|
|
321
327
|
slotStepMinutes: 'slot_step_minutes',
|
|
328
|
+
stepOffsetMinutes: 'step_offset_minutes',
|
|
329
|
+
bufferBeforeMinutes: 'buffer_before_minutes',
|
|
322
330
|
turnaroundMinutes: 'turnaround_minutes',
|
|
323
331
|
checkInMinutes: 'check_in_minutes',
|
|
324
332
|
});
|
|
333
|
+
/** How the inherited-rules note names each rule, with its unit. */
|
|
334
|
+
const INHERITED_NAMES = Object.freeze({
|
|
335
|
+
...RULE_NAMES,
|
|
336
|
+
maxAdvanceDays: 'Book up to (days ahead)',
|
|
337
|
+
enforceHours: 'Only inside bookable hours',
|
|
338
|
+
requireCertain: 'Only in time its calendar syncs have read today',
|
|
339
|
+
});
|
|
340
|
+
/**
|
|
341
|
+
* The rules a resource takes from defaults, each with where it comes from: a rule its pool's
|
|
342
|
+
* defaults set, else one the tenant's set, where its own rules do not. Without the defaults, the
|
|
343
|
+
* rules that apply less its own, from "defaults".
|
|
344
|
+
*/
|
|
345
|
+
function inheritedRules(resource, defaults) {
|
|
346
|
+
const own = resource.bookingRules ?? {};
|
|
347
|
+
const levels = defaults
|
|
348
|
+
? [
|
|
349
|
+
['every resource', defaults.tenant],
|
|
350
|
+
[`pool ${resource.pool ?? ''}`, resource.pool === undefined ? undefined : defaults.pools[resource.pool]],
|
|
351
|
+
]
|
|
352
|
+
: [['defaults', resource.effectiveBookingRules]];
|
|
353
|
+
const inherited = new Map();
|
|
354
|
+
for (const [from, rules] of levels) {
|
|
355
|
+
for (const [name, value] of Object.entries(rules ?? {})) {
|
|
356
|
+
if (own[name] === undefined)
|
|
357
|
+
inherited.set(name, { value, from });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return inherited;
|
|
361
|
+
}
|
|
362
|
+
function inheritedNote(inherited) {
|
|
363
|
+
if (inherited.size === 0)
|
|
364
|
+
return html ``;
|
|
365
|
+
const parts = [...inherited].map(([name, { value, from }]) => typeof value === 'boolean'
|
|
366
|
+
? `${INHERITED_NAMES[name]}${value ? '' : ': off'} (${from})`
|
|
367
|
+
: `${INHERITED_NAMES[name]} ${value.toLocaleString('en')} (${from})`);
|
|
368
|
+
return html `<p class="fine">Inherited: ${parts.join('; ')}.</p>`;
|
|
369
|
+
}
|
|
325
370
|
const icon = (paths) => new Html(`<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`);
|
|
326
371
|
const ICONS = Object.freeze({
|
|
327
|
-
mark: new Html(
|
|
372
|
+
mark: new Html(
|
|
373
|
+
// The Slotlock mark: a padlock whose body holds one booked slot (the site's logo, without the wordmark).
|
|
374
|
+
'<svg class="mark" viewBox="0 0 85.32 77.12" aria-hidden="true" focusable="false"><path class="mark-ink" d="M13.13 34.46L72.2 34.46C79.45 34.46 85.32 40.33 85.32 47.58L85.32 63.99C85.32 71.24 79.45 77.12 72.2 77.12L13.13 77.12C5.88 77.12 0 71.24 0 63.99L0 47.58C0 40.33 5.88 34.46 13.13 34.46ZM22.97 39.38L22.97 19.69C22.97 8.82 31.79 0 42.66 0C53.54 0 62.35 8.82 62.35 19.69L62.35 39.38L56.61 39.38L56.61 19.69C56.61 11.99 50.36 5.74 42.66 5.74C34.96 5.74 28.71 11.99 28.71 19.69L28.71 39.38Z"></path><path class="mark-slot" d="M19.69 52.51L65.63 52.51C67.45 52.51 68.91 53.98 68.91 55.79C68.91 57.6 67.45 59.07 65.63 59.07L19.69 59.07C17.88 59.07 16.41 57.6 16.41 55.79C16.41 53.98 17.88 52.51 19.69 52.51Z"></path></svg>'),
|
|
328
375
|
overview: icon('<path d="M4 13h6V4H4zM14 20h6v-9h-6zM4 20h6v-3H4zM14 7h6V4h-6z"/>'),
|
|
329
376
|
resources: icon('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
|
|
330
377
|
keys: icon('<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3"/>'),
|
|
@@ -1011,17 +1058,33 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
|
|
|
1011
1058
|
return notFound(session);
|
|
1012
1059
|
const start = new Date(now());
|
|
1013
1060
|
const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
|
|
1014
|
-
const [agenda, changes, keys] = await Promise.all([
|
|
1061
|
+
const [agenda, changes, keys, defaults] = await Promise.all([
|
|
1015
1062
|
options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
|
|
1016
1063
|
options.resources.recentChanges?.(tenantRef, 100),
|
|
1017
1064
|
options.keys.list({ tenantRef }),
|
|
1065
|
+
options.resources.ruleDefaults?.(tenantRef),
|
|
1018
1066
|
]);
|
|
1019
1067
|
const byId = new Map([[resource.id, resource]]);
|
|
1020
1068
|
const mine = agenda?.filter((entry) => entry.resourceId === resource.id);
|
|
1021
1069
|
const activity = changes?.filter((change) => change.resourceId === resource.id).slice(0, 10);
|
|
1022
1070
|
const token = csrfToken(session);
|
|
1023
1071
|
const rules = resource.bookingRules ?? {};
|
|
1024
|
-
const
|
|
1072
|
+
const inherited = inheritedRules(resource, defaults);
|
|
1073
|
+
const field = (name, label, unit) => {
|
|
1074
|
+
const from = inherited.get(name);
|
|
1075
|
+
return html `<label>${label}<span class="unit-input"><input type="number" inputmode="numeric" min="${String(RULE_LIMITS[name][0])}" max="${String(RULE_LIMITS[name][1])}" name="${RULE_FIELDS[name]}" value="${rules[name] === undefined ? '' : String(rules[name])}"${from ? html ` placeholder="${String(from.value)}"` : html ``}><span>${unit}</span></span></label>`;
|
|
1076
|
+
};
|
|
1077
|
+
// On or off: a checkbox, or, once a default sets it (or the resource turned it off), a choice
|
|
1078
|
+
// of the default, on or off, so a resource can turn an inherited rule off.
|
|
1079
|
+
const toggle = (name, key, label) => {
|
|
1080
|
+
const from = inherited.get(name);
|
|
1081
|
+
if (from === undefined && rules[name] !== false) {
|
|
1082
|
+
return html `<label class="choice"><input type="checkbox" name="${key}" value="1"${rules[name] ? html ` checked` : html ``}> ${label}</label>`;
|
|
1083
|
+
}
|
|
1084
|
+
const chosen = (value) => (rules[name] === value ? html ` selected` : html ``);
|
|
1085
|
+
const fallback = from ? `As its default (${from.value ? 'on' : 'off'}, ${from.from})` : 'Off';
|
|
1086
|
+
return html `<label>${label}<select name="${key}"><option value=""${chosen(undefined)}>${fallback}</option><option value="1"${chosen(true)}>On</option><option value="0"${chosen(false)}>Off</option></select></label>`;
|
|
1087
|
+
};
|
|
1025
1088
|
const content = html `${noticeOf(notice)}
|
|
1026
1089
|
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1027
1090
|
${pageHead(resourceName(resource, resource.id))}
|
|
@@ -1039,12 +1102,15 @@ ${options.resources.setRules
|
|
|
1039
1102
|
${field('minNoticeMinutes', 'Minimum notice', 'min')}
|
|
1040
1103
|
${field('maxAdvanceDays', 'Book up to', 'days ahead')}
|
|
1041
1104
|
${field('slotStepMinutes', 'Start on a step of', 'min')}
|
|
1105
|
+
${field('stepOffsetMinutes', 'Steps start past each step by', 'min')}
|
|
1106
|
+
${field('bufferBeforeMinutes', 'Kept free before each booking', 'min')}
|
|
1042
1107
|
${field('turnaroundMinutes', 'Turnaround after each booking', 'min')}
|
|
1043
1108
|
${field('checkInMinutes', 'Release if not checked in after', 'min')}
|
|
1044
1109
|
</div>
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1110
|
+
${toggle('enforceHours', 'enforce_hours', 'Only inside bookable hours')}
|
|
1111
|
+
${toggle('requireCertain', 'require_certain', 'Only in time its calendar syncs have read today')}
|
|
1112
|
+
${inheritedNote(inherited)}
|
|
1113
|
+
<p class="fine">Leave a field empty for no rule${inherited.size > 0 ? ' of its own' : ''}.</p>
|
|
1048
1114
|
<button class="primary" type="submit">Save rules</button>
|
|
1049
1115
|
</form>
|
|
1050
1116
|
</section>`
|
|
@@ -1086,10 +1152,17 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1086
1152
|
}
|
|
1087
1153
|
rules[name] = Number(value);
|
|
1088
1154
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1155
|
+
// '1' on, '0' off (over a default that is on), anything else not set.
|
|
1156
|
+
const choice = (key) => {
|
|
1157
|
+
const value = form.get(key);
|
|
1158
|
+
return value === '1' ? true : value === '0' ? false : undefined;
|
|
1159
|
+
};
|
|
1160
|
+
const enforceHours = choice('enforce_hours');
|
|
1161
|
+
if (enforceHours !== undefined)
|
|
1162
|
+
rules.enforceHours = enforceHours;
|
|
1163
|
+
const requireCertain = choice('require_certain');
|
|
1164
|
+
if (requireCertain !== undefined)
|
|
1165
|
+
rules.requireCertain = requireCertain;
|
|
1093
1166
|
try {
|
|
1094
1167
|
await options.resources.setRules(tenantOf(session), id, Object.keys(rules).length === 0 ? null : rules, personOf(session));
|
|
1095
1168
|
}
|
|
@@ -1371,6 +1444,7 @@ ${entries.length === 0
|
|
|
1371
1444
|
const principal = assistantPrincipal(session);
|
|
1372
1445
|
return invokeSlotlockAgentOperation({
|
|
1373
1446
|
operation,
|
|
1447
|
+
origin: 'dashboard-assistant',
|
|
1374
1448
|
input,
|
|
1375
1449
|
request: new Request(`${origin}${root}/assistant`, {
|
|
1376
1450
|
signal: AbortSignal.timeout(assistantTimeout),
|
|
@@ -2195,6 +2269,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2195
2269
|
name: form.get('name') ?? '',
|
|
2196
2270
|
scopes,
|
|
2197
2271
|
createdBy: personOf(session),
|
|
2272
|
+
origin: 'dashboard',
|
|
2198
2273
|
...(typeof days === 'number' ? { expiresAt: new Date(now() + days * DAY_MS) } : {}),
|
|
2199
2274
|
});
|
|
2200
2275
|
return shownOncePage(session, created.key, created.apiKey, false);
|
|
@@ -2210,17 +2285,23 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
2210
2285
|
throw error;
|
|
2211
2286
|
}
|
|
2212
2287
|
}
|
|
2288
|
+
/** A key change in the session's tenant, recorded as the person's, from the dashboard. */
|
|
2289
|
+
const asPerson = (session) => ({
|
|
2290
|
+
tenantRef: tenantOf(session),
|
|
2291
|
+
actor: personOf(session),
|
|
2292
|
+
origin: 'dashboard',
|
|
2293
|
+
});
|
|
2213
2294
|
async function changeKey(session, form, change) {
|
|
2214
2295
|
const id = form.get('id') ?? '';
|
|
2215
2296
|
if (!UUID_PATTERN.test(id))
|
|
2216
2297
|
return keysPage(session, 404, 'That key is not one of yours.');
|
|
2217
2298
|
if (change === 'rotate') {
|
|
2218
|
-
const rotated = await options.keys.rotate({
|
|
2299
|
+
const rotated = await options.keys.rotate({ ...asPerson(session), id });
|
|
2219
2300
|
if (!rotated)
|
|
2220
2301
|
return keysPage(session, 404, 'That key is not an active key of yours.');
|
|
2221
2302
|
return shownOncePage(session, rotated.key, rotated.apiKey, true);
|
|
2222
2303
|
}
|
|
2223
|
-
const revoked = await options.keys.revoke({
|
|
2304
|
+
const revoked = await options.keys.revoke({ ...asPerson(session), id });
|
|
2224
2305
|
if (!revoked)
|
|
2225
2306
|
return keysPage(session, 404, 'That key is not one of yours.');
|
|
2226
2307
|
return redirect(303, `${root}/keys`);
|
|
@@ -2467,7 +2548,9 @@ a:hover { text-decoration: underline; }
|
|
|
2467
2548
|
code, pre, input[readonly] { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
|
|
2468
2549
|
.icon { width: 16px; height: 16px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.75; stroke-linecap: round; stroke-linejoin: round; }
|
|
2469
2550
|
.icon .fill { fill: currentColor; stroke: none; }
|
|
2470
|
-
.mark { width:
|
|
2551
|
+
.mark { width: 20px; height: 18px; flex: none; }
|
|
2552
|
+
.mark-ink { fill: currentColor; }
|
|
2553
|
+
.mark-slot { fill: #FF5B2E; }
|
|
2471
2554
|
.brand { display: inline-flex; align-items: center; gap: 8px; color: var(--ink); font-weight: 650; font-size: 15px; letter-spacing: -0.01em; }
|
|
2472
2555
|
.brand:hover { text-decoration: none; }
|
|
2473
2556
|
.app { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: 100vh; background: linear-gradient(to right, var(--side) 239px, var(--line) 239px, var(--line) 240px, var(--paper) 240px); }
|
package/ddl.d.ts
CHANGED
|
@@ -76,7 +76,7 @@ export declare const SLOTLOCK_TENANT_CONTEXT_SETTING = "slotlock.tenant_ref";
|
|
|
76
76
|
* `slotlock.rls_policy_contracts` and `slotlock.deployment_lifted_row_security` are
|
|
77
77
|
* deployment-only and deliberately absent.
|
|
78
78
|
*/
|
|
79
|
-
export declare const SLOTLOCK_TENANT_TABLES: readonly ["resources", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_occurrences", "calendar_event_tombstones", "calendar_event_commands", "calendar_coverage", "hold_commands", "outbox", "outbox_pruned", "webhook_endpoints", "waitlist_entries", "assistant_settings", "assistant_conversations"];
|
|
79
|
+
export declare const SLOTLOCK_TENANT_TABLES: readonly ["resources", "reservations", "reservation_tombstones", "reservation_conflict_archive", "calendar_events", "calendar_event_attendees", "calendar_event_reminders", "calendar_event_exceptions", "calendar_event_occurrences", "calendar_event_tombstones", "calendar_event_commands", "calendar_coverage", "hold_commands", "outbox", "outbox_pruned", "webhook_endpoints", "waitlist_entries", "booking_rule_defaults", "assistant_settings", "assistant_conversations"];
|
|
80
80
|
/**
|
|
81
81
|
* Build the grants for the application role: USAGE on the `slotlock` schema, DML on the store's
|
|
82
82
|
* tables and EXECUTE on the API key and dashboard functions. Nothing else: no ownership, DDL,
|