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/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
- const by = (actor) => (actor === undefined ? {} : { actor });
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('<svg class="mark" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path d="M8 46 L26 14 L56 44"></path></svg>'),
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 field = (name, label, unit) => 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])}"><span>${unit}</span></span></label>`;
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
- <label class="choice"><input type="checkbox" name="enforce_hours" value="1"${rules.enforceHours ? html ` checked` : html ``}> Only inside bookable hours</label>
1046
- <label class="choice"><input type="checkbox" name="require_certain" value="1"${rules.requireCertain ? html ` checked` : html ``}> Only in time its calendar syncs have read today</label>
1047
- <p class="fine">Leave a field empty for no rule.</p>
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
- if (form.get('enforce_hours') === '1')
1090
- rules.enforceHours = true;
1091
- if (form.get('require_certain') === '1')
1092
- rules.requireCertain = true;
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({ tenantRef: tenantOf(session), id });
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({ tenantRef: tenantOf(session), id });
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: 18px; height: 18px; flex: none; fill: none; stroke: var(--accent); stroke-width: 7; stroke-linecap: round; stroke-linejoin: round; }
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,