@semics-tech/emdash-events-suite 1.0.0-rc.0 → 1.0.0-rc.2
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/README.md +11 -2
- package/dist/ts/index.d.ts +2 -0
- package/dist/ts/index.js +7 -0
- package/package.json +4 -3
- package/src/astro/BookingWithWaiver.astro +212 -3
- package/src/astro/PairPage.astro +154 -0
- package/src/astro/TemplatesPage.astro +9 -1
- package/src/astro/WaiverPage.astro +22 -0
- package/src/astro/lib/flow.ts +81 -6
- package/src/astro/lib/form.ts +1 -0
- package/src/astro/lib/pair.ts +203 -0
- package/src/astro/lib/reconcile.ts +34 -1
- package/src/astro/lib/slot.ts +98 -0
- package/src/astro/lib/templates.ts +38 -0
package/README.md
CHANGED
|
@@ -50,8 +50,12 @@ import { eventsSuite } from "@semics-tech/emdash-events-suite";
|
|
|
50
50
|
export const suite = eventsSuite({ events: { pluginId: "r_events_id" }, esign: { pluginId: "r_esign_id" } });
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
Pair the two cores in the admin
|
|
54
|
-
|
|
53
|
+
Pair the two cores: sign in to the admin as an administrator, open `/_events/pair` on your site and
|
|
54
|
+
press **Pair with E-sign on this site**. The suite creates the E-sign integration client (and the master
|
|
55
|
+
key, if none is set) and stores the pairing string in the Events settings for you; it is never shown.
|
|
56
|
+
The site needs `EMDASH_ENCRYPTION_KEY` set, as for any secret setting. Pairing by hand still works:
|
|
57
|
+
create an integration client in E-sign (Integrations) and paste its pairing string into the Events
|
|
58
|
+
settings.
|
|
55
59
|
|
|
56
60
|
## Book with a waiver
|
|
57
61
|
|
|
@@ -80,4 +84,9 @@ scoped to that page.
|
|
|
80
84
|
Paid waiver events: when continue answers with a payment step, the browser goes to the
|
|
81
85
|
provider's checkout.
|
|
82
86
|
|
|
87
|
+
After booking (Events Pro), `/booking` lists every attendee as Signed or Waiting, with **Sign now**
|
|
88
|
+
(that attendee signs on this device), **Resend invitation** for emailed adults, and a form to correct the
|
|
89
|
+
names and emails of attendees who have not signed until `attendeeEditCutoffHours` before the start. It
|
|
90
|
+
needs the integration's `esignPluginId`, which `eventsSuite()` sets.
|
|
91
|
+
|
|
83
92
|
The suite's own pages are English only for now.
|
package/dist/ts/index.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export interface EventsSuite {
|
|
|
49
49
|
export declare const WAIVER_ROUTE = "/_events/waiver";
|
|
50
50
|
/** The suite's catalogue page: where the Events panel's Refresh templates link lands (T066). */
|
|
51
51
|
export declare const TEMPLATES_ROUTE = "/_events/templates";
|
|
52
|
+
/** The suite's pairing page: pairs Events with E-sign on this site in one click (T145). */
|
|
53
|
+
export declare const PAIR_ROUTE = "/_events/pair";
|
|
52
54
|
/**
|
|
53
55
|
* Injects `/_events/waiver` (the booking and signing steps of `<BookingWithWaiver>`) and gives it,
|
|
54
56
|
* through a virtual module, the id of each core. `eventsSuite()` adds it for you.
|
package/dist/ts/index.js
CHANGED
|
@@ -20,6 +20,8 @@ const RESOLVED = `\0${VIRTUAL}`;
|
|
|
20
20
|
export const WAIVER_ROUTE = "/_events/waiver";
|
|
21
21
|
/** The suite's catalogue page: where the Events panel's Refresh templates link lands (T066). */
|
|
22
22
|
export const TEMPLATES_ROUTE = "/_events/templates";
|
|
23
|
+
/** The suite's pairing page: pairs Events with E-sign on this site in one click (T145). */
|
|
24
|
+
export const PAIR_ROUTE = "/_events/pair";
|
|
23
25
|
/** What Events' booking page runs for a booking waiting for signatures (T066). */
|
|
24
26
|
const RECONCILE_MODULE = "@semics-tech/emdash-events-suite/astro/lib/reconcile";
|
|
25
27
|
/**
|
|
@@ -45,6 +47,11 @@ export function suiteIntegration(ids) {
|
|
|
45
47
|
entrypoint: "@semics-tech/emdash-events-suite/astro/TemplatesPage.astro",
|
|
46
48
|
prerender: false,
|
|
47
49
|
});
|
|
50
|
+
injectRoute({
|
|
51
|
+
pattern: PAIR_ROUTE,
|
|
52
|
+
entrypoint: "@semics-tech/emdash-events-suite/astro/PairPage.astro",
|
|
53
|
+
prerender: false,
|
|
54
|
+
});
|
|
48
55
|
updateConfig({
|
|
49
56
|
vite: {
|
|
50
57
|
plugins: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@semics-tech/emdash-events-suite",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Links semics Events and E-sign on one EmDash site: one config call and a booking flow with inline waiver signing",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"./astro/BookingWithWaiver.astro": "./src/astro/BookingWithWaiver.astro",
|
|
15
15
|
"./astro/WaiverPage.astro": "./src/astro/WaiverPage.astro",
|
|
16
16
|
"./astro/TemplatesPage.astro": "./src/astro/TemplatesPage.astro",
|
|
17
|
+
"./astro/PairPage.astro": "./src/astro/PairPage.astro",
|
|
17
18
|
"./astro/lib/*": "./src/astro/lib/*.ts"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
@@ -21,8 +22,8 @@
|
|
|
21
22
|
"src/astro"
|
|
22
23
|
],
|
|
23
24
|
"dependencies": {
|
|
24
|
-
"@semics-tech/emdash-esign": "1.0.0-rc.
|
|
25
|
-
"@semics-tech/emdash-events": "1.0.0-rc.
|
|
25
|
+
"@semics-tech/emdash-esign": "1.0.0-rc.2",
|
|
26
|
+
"@semics-tech/emdash-events": "1.0.0-rc.2"
|
|
26
27
|
},
|
|
27
28
|
"peerDependencies": {
|
|
28
29
|
"emdash": ">=1.2.0 <2"
|
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
// Events Pro (waivers are Pro) the same form makes a plain booking. All text is rendered as text.
|
|
9
9
|
import { eventsPluginId } from "virtual:semics-events-suite/config";
|
|
10
10
|
import { callPublicRoute } from "@semics-tech/emdash-events/astro/lib/call";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
pickerFetchFrom,
|
|
13
|
+
pickerHref,
|
|
14
|
+
pickerParam,
|
|
15
|
+
pickerView,
|
|
16
|
+
usesPicker,
|
|
17
|
+
} from "@semics-tech/emdash-events/astro/lib/picker";
|
|
18
|
+
import { fieldMessage, formatMoney, newIdempotencyKey, type PublicEvent } from "@semics-tech/emdash-events/astro/lib/widget";
|
|
19
|
+
import { translator } from "@semics-tech/emdash-events/i18n";
|
|
12
20
|
|
|
13
21
|
import {
|
|
14
22
|
DEFAULT_SEATS,
|
|
@@ -35,10 +43,18 @@ interface Props {
|
|
|
35
43
|
const { event: eventId, heading = "Book with waiver", locale = "en-GB" } = Astro.props;
|
|
36
44
|
const seats = Math.min(MAX_SEATS, Math.max(1, Math.trunc(Astro.props.seats ?? DEFAULT_SEATS)));
|
|
37
45
|
let data = Astro.props.data;
|
|
46
|
+
// The day-then-time picker's page (T130, T134): this widget's own `booking_from.<eventId>`, as Events' widget reads it.
|
|
47
|
+
const pickerStart = Astro.url.searchParams.get(pickerParam(eventId));
|
|
38
48
|
if (!data) {
|
|
39
|
-
const
|
|
49
|
+
const from = pickerFetchFrom(pickerStart);
|
|
50
|
+
const result = await callPublicRoute<PublicEvent>(Astro, {
|
|
51
|
+
path: "public/event",
|
|
52
|
+
pluginId: eventsPluginId,
|
|
53
|
+
body: { eventId, ...(from ? { from } : {}) },
|
|
54
|
+
});
|
|
40
55
|
if (result.ok) data = result.data;
|
|
41
56
|
}
|
|
57
|
+
const { t } = translator(locale);
|
|
42
58
|
|
|
43
59
|
const id = waiverAnchor(eventId);
|
|
44
60
|
const state = readWaiverState(Astro.url, eventId);
|
|
@@ -50,6 +66,14 @@ const occurrences = data?.occurrences ?? [];
|
|
|
50
66
|
const onSale = data?.status === "on_sale" && ticketTypes.length > 0;
|
|
51
67
|
const siteKey = data?.turnstileSiteKey ?? "";
|
|
52
68
|
const price = (minor: number) => formatMoney(minor, data?.currency ?? "GBP", locale);
|
|
69
|
+
// A slot schedule (or two starts a day): a month calendar of days, then the day's times. Without
|
|
70
|
+
// JavaScript the same month's dates show as a select grouped by day. Dated events keep the plain select.
|
|
71
|
+
const zone = data?.timezone ?? "UTC";
|
|
72
|
+
const picker = usesPicker(data, zone)
|
|
73
|
+
? pickerView(occurrences, { zone, locale, start: pickerStart, now: Date.now(), more: data?.more === true })
|
|
74
|
+
: null;
|
|
75
|
+
const monthId = `${id}-month`;
|
|
76
|
+
const errId = `${id}-occurrence-error`;
|
|
53
77
|
const when = (iso: string) => {
|
|
54
78
|
const options = { dateStyle: "full", timeStyle: "short" } as const;
|
|
55
79
|
try {
|
|
@@ -77,7 +101,103 @@ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
|
|
|
77
101
|
<input type="hidden" name="returnTo" value={returnTo} />
|
|
78
102
|
<input type="hidden" name="idempotencyKey" value={newIdempotencyKey()} />
|
|
79
103
|
|
|
80
|
-
{
|
|
104
|
+
{picker && (
|
|
105
|
+
<div class="semics-field semics-picker-field">
|
|
106
|
+
<input type="hidden" name="needsOccurrence" value="1" />
|
|
107
|
+
<div class="semics-field" data-picker-fallback>
|
|
108
|
+
<label for={`${id}-occurrence`}>{t("widget.date")}</label>
|
|
109
|
+
<select
|
|
110
|
+
id={`${id}-occurrence`}
|
|
111
|
+
name="occurrenceId"
|
|
112
|
+
required
|
|
113
|
+
aria-invalid={bad("occurrence")}
|
|
114
|
+
aria-describedby={[monthId, errId].join(" ")}
|
|
115
|
+
>
|
|
116
|
+
<option value="">{t("widget.chooseDate")}</option>
|
|
117
|
+
{picker.days.map((day) => (
|
|
118
|
+
<optgroup label={day.label}>
|
|
119
|
+
{day.slots.map((s) => (
|
|
120
|
+
<option value={s.id} disabled={s.full}>{s.label}</option>
|
|
121
|
+
))}
|
|
122
|
+
</optgroup>
|
|
123
|
+
))}
|
|
124
|
+
</select>
|
|
125
|
+
</div>
|
|
126
|
+
<nav class="semics-picker-nav" aria-labelledby={monthId}>
|
|
127
|
+
<p id={monthId} class="semics-picker-month">{picker.month.label}</p>
|
|
128
|
+
{picker.prev && <a href={pickerHref(Astro.url, picker.prev, eventId)} rel="nofollow">{t("widget.picker.earlier")}</a>}
|
|
129
|
+
{picker.next && <a href={pickerHref(Astro.url, picker.next, eventId)} rel="nofollow">{t("widget.picker.later")}</a>}
|
|
130
|
+
</nav>
|
|
131
|
+
{picker.days.length === 0 && <p data-testid="picker-none">{t("widget.picker.none")}</p>}
|
|
132
|
+
<fieldset
|
|
133
|
+
class="semics-picker"
|
|
134
|
+
data-picker
|
|
135
|
+
data-field="occurrence"
|
|
136
|
+
data-testid="booking-picker"
|
|
137
|
+
hidden
|
|
138
|
+
aria-invalid={bad("occurrence")}
|
|
139
|
+
aria-describedby={errId}
|
|
140
|
+
>
|
|
141
|
+
<legend>{t("widget.date")}</legend>
|
|
142
|
+
<table role="grid" class="semics-calendar" aria-labelledby={monthId}>
|
|
143
|
+
<thead>
|
|
144
|
+
<tr>
|
|
145
|
+
{picker.weekdays.map((w) => (
|
|
146
|
+
<th scope="col" abbr={w.long}>{w.short}</th>
|
|
147
|
+
))}
|
|
148
|
+
</tr>
|
|
149
|
+
</thead>
|
|
150
|
+
<tbody>
|
|
151
|
+
{picker.weeks.map((week) => (
|
|
152
|
+
<tr>
|
|
153
|
+
{week.map((cell) => (
|
|
154
|
+
<td>
|
|
155
|
+
{cell && cell.slots.length > 0 ? (
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
class={cell.open ? undefined : "semics-day-full"}
|
|
159
|
+
data-day={cell.key}
|
|
160
|
+
data-n={String(cell.day)}
|
|
161
|
+
aria-label={cell.buttonLabel}
|
|
162
|
+
aria-pressed="false"
|
|
163
|
+
tabindex={cell.key === picker.focusDay ? "0" : "-1"}
|
|
164
|
+
>
|
|
165
|
+
{cell.day}
|
|
166
|
+
</button>
|
|
167
|
+
) : cell ? (
|
|
168
|
+
<span class="semics-day-none">{cell.day}</span>
|
|
169
|
+
) : null}
|
|
170
|
+
</td>
|
|
171
|
+
))}
|
|
172
|
+
</tr>
|
|
173
|
+
))}
|
|
174
|
+
</tbody>
|
|
175
|
+
</table>
|
|
176
|
+
<p class="semics-hint" data-picker-hint>{t("widget.picker.hint")}</p>
|
|
177
|
+
{picker.days.map((day) => (
|
|
178
|
+
<div class="semics-times" role="radiogroup" data-times={day.key} aria-label={t("widget.picker.times", { date: day.label })} hidden>
|
|
179
|
+
{day.slots.map((s, i) => (
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
role="radio"
|
|
183
|
+
aria-checked="false"
|
|
184
|
+
aria-disabled={s.full ? "true" : undefined}
|
|
185
|
+
aria-label={s.label}
|
|
186
|
+
data-occurrence={s.id}
|
|
187
|
+
tabindex={i === day.slots.findIndex((x) => !x.full) ? "0" : "-1"}
|
|
188
|
+
>
|
|
189
|
+
<span>{s.time}</span>
|
|
190
|
+
{s.places && <span class="semics-places">{s.places}</span>}
|
|
191
|
+
</button>
|
|
192
|
+
))}
|
|
193
|
+
</div>
|
|
194
|
+
))}
|
|
195
|
+
</fieldset>
|
|
196
|
+
{bad("occurrence") && <p class="semics-field-error" id={errId}>{fieldMessage("occurrence", locale)}</p>}
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
|
|
200
|
+
{!picker && occurrences.length > 0 && (
|
|
81
201
|
<div class="semics-field">
|
|
82
202
|
<input type="hidden" name="needsOccurrence" value="1" />
|
|
83
203
|
<label for={`${id}-occurrence`}>Date</label>
|
|
@@ -180,6 +300,10 @@ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
|
|
|
180
300
|
}
|
|
181
301
|
</section>
|
|
182
302
|
|
|
303
|
+
<script>
|
|
304
|
+
import "@semics-tech/emdash-events/client/picker";
|
|
305
|
+
</script>
|
|
306
|
+
|
|
183
307
|
<style>
|
|
184
308
|
.semics-waiver-widget {
|
|
185
309
|
--_fg: var(--events-fg, #1a1a1a);
|
|
@@ -210,6 +334,7 @@ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
|
|
|
210
334
|
}
|
|
211
335
|
.semics-waiver-widget form {
|
|
212
336
|
display: grid;
|
|
337
|
+
grid-template-columns: minmax(0, 1fr);
|
|
213
338
|
gap: 0.75rem;
|
|
214
339
|
}
|
|
215
340
|
.semics-waiver-widget fieldset {
|
|
@@ -254,6 +379,90 @@ const single = ticketTypes.length === 1 ? ticketTypes[0] : undefined;
|
|
|
254
379
|
border-color: var(--_error);
|
|
255
380
|
outline: 1px solid var(--_error);
|
|
256
381
|
}
|
|
382
|
+
.semics-hint {
|
|
383
|
+
color: var(--_muted);
|
|
384
|
+
}
|
|
385
|
+
.semics-picker-nav {
|
|
386
|
+
display: flex;
|
|
387
|
+
flex-wrap: wrap;
|
|
388
|
+
align-items: baseline;
|
|
389
|
+
gap: 0.25rem 1rem;
|
|
390
|
+
}
|
|
391
|
+
.semics-picker-month {
|
|
392
|
+
margin: 0;
|
|
393
|
+
margin-inline-end: auto;
|
|
394
|
+
font-weight: 600;
|
|
395
|
+
}
|
|
396
|
+
.semics-picker-nav a {
|
|
397
|
+
color: var(--_accent);
|
|
398
|
+
min-block-size: 1.5rem;
|
|
399
|
+
}
|
|
400
|
+
.semics-picker {
|
|
401
|
+
display: grid;
|
|
402
|
+
gap: 0.5rem;
|
|
403
|
+
margin: 0;
|
|
404
|
+
padding: 0.5rem 0.75rem 0.75rem;
|
|
405
|
+
border: 1px solid var(--_border);
|
|
406
|
+
border-radius: var(--_radius);
|
|
407
|
+
min-inline-size: 0;
|
|
408
|
+
}
|
|
409
|
+
.semics-picker p {
|
|
410
|
+
margin: 0;
|
|
411
|
+
}
|
|
412
|
+
.semics-calendar {
|
|
413
|
+
inline-size: 100%;
|
|
414
|
+
border-collapse: collapse;
|
|
415
|
+
table-layout: fixed;
|
|
416
|
+
text-align: center;
|
|
417
|
+
}
|
|
418
|
+
.semics-calendar th {
|
|
419
|
+
padding-block: 0.25rem;
|
|
420
|
+
font-weight: 400;
|
|
421
|
+
font-size: 0.875rem;
|
|
422
|
+
color: var(--_muted);
|
|
423
|
+
}
|
|
424
|
+
.semics-calendar td {
|
|
425
|
+
padding: 1px;
|
|
426
|
+
}
|
|
427
|
+
.semics-waiver-widget .semics-calendar button,
|
|
428
|
+
.semics-waiver-widget .semics-times button {
|
|
429
|
+
inline-size: 100%;
|
|
430
|
+
padding: 0.25rem;
|
|
431
|
+
border: 1px solid var(--_accent);
|
|
432
|
+
background: var(--_bg);
|
|
433
|
+
color: var(--_accent);
|
|
434
|
+
}
|
|
435
|
+
.semics-waiver-widget .semics-calendar button.semics-day-full,
|
|
436
|
+
.semics-waiver-widget .semics-times button[aria-disabled="true"] {
|
|
437
|
+
border-color: var(--_border);
|
|
438
|
+
color: var(--_muted);
|
|
439
|
+
text-decoration: line-through;
|
|
440
|
+
}
|
|
441
|
+
.semics-waiver-widget .semics-times button[aria-disabled="true"] {
|
|
442
|
+
cursor: not-allowed;
|
|
443
|
+
}
|
|
444
|
+
.semics-waiver-widget .semics-calendar button[aria-pressed="true"],
|
|
445
|
+
.semics-waiver-widget .semics-times button[aria-checked="true"] {
|
|
446
|
+
background: var(--_accent);
|
|
447
|
+
color: var(--_accent-fg);
|
|
448
|
+
}
|
|
449
|
+
.semics-day-none {
|
|
450
|
+
display: block;
|
|
451
|
+
padding-block: 0.75rem;
|
|
452
|
+
color: var(--_muted);
|
|
453
|
+
}
|
|
454
|
+
.semics-times {
|
|
455
|
+
display: grid;
|
|
456
|
+
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
|
|
457
|
+
gap: 0.5rem;
|
|
458
|
+
}
|
|
459
|
+
.semics-waiver-widget .semics-times button {
|
|
460
|
+
display: grid;
|
|
461
|
+
justify-items: center;
|
|
462
|
+
}
|
|
463
|
+
.semics-places {
|
|
464
|
+
font-size: 0.875rem;
|
|
465
|
+
}
|
|
257
466
|
.semics-note {
|
|
258
467
|
margin: 0;
|
|
259
468
|
color: var(--_muted);
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Injected at `/_events/pair` by the suite (T145): pairs Events with E-sign on this site in one click.
|
|
3
|
+
// Administrators only (the host's own plugin-settings handler checks `plugins:manage`). The pairing
|
|
4
|
+
// string goes from E-sign to Events' encrypted setting inside this request and is never sent to the
|
|
5
|
+
// browser. The logic is in `lib/pair.ts`; this page only renders the state and posts the button.
|
|
6
|
+
import { esignPluginId, eventsPluginId } from "virtual:semics-events-suite/config";
|
|
7
|
+
import { crossSiteResponse, isCrossSiteRequest } from "@semics-tech/emdash-events/astro/lib/checkout-form";
|
|
8
|
+
import { getPluginSetting } from "emdash";
|
|
9
|
+
import { GET as readSettings, PUT as writeSettings } from "emdash/internal/routes/api/admin/plugins/_id_/settings";
|
|
10
|
+
|
|
11
|
+
import { PAIR_PATH, type PairContext, type PairView, pairAction, pairingStatus } from "./lib/pair";
|
|
12
|
+
|
|
13
|
+
export const prerender = false;
|
|
14
|
+
|
|
15
|
+
Astro.response.headers.set("Referrer-Policy", "no-referrer");
|
|
16
|
+
Astro.response.headers.set("Cache-Control", "private, no-store");
|
|
17
|
+
|
|
18
|
+
const ids = { eventsPluginId, esignPluginId };
|
|
19
|
+
// The host's handlers for `/_emdash/api/admin/plugins/<id>/settings`, run with this visitor's locals.
|
|
20
|
+
const settingsUrl = (id: string) => new URL(`/_emdash/api/admin/plugins/${encodeURIComponent(id)}/settings`, Astro.url);
|
|
21
|
+
const ctx: PairContext = {
|
|
22
|
+
locals: Astro.locals,
|
|
23
|
+
url: Astro.url,
|
|
24
|
+
// T149: the host's server-side read of Events' pairing (decrypted). `lib/pair.ts` calls it only after
|
|
25
|
+
// the admin check and keeps just the client id, so E-sign can report a hand-made pairing as it is.
|
|
26
|
+
readPairing: () => getPluginSetting(eventsPluginId, "esignPairing"),
|
|
27
|
+
settings: {
|
|
28
|
+
read: (id) =>
|
|
29
|
+
readSettings({ params: { id }, locals: Astro.locals, request: new Request(settingsUrl(id)) } as never) as Promise<Response>,
|
|
30
|
+
write: (id, values) =>
|
|
31
|
+
writeSettings({
|
|
32
|
+
params: { id },
|
|
33
|
+
locals: Astro.locals,
|
|
34
|
+
request: new Request(settingsUrl(id), {
|
|
35
|
+
method: "PUT",
|
|
36
|
+
headers: { "content-type": "application/json" },
|
|
37
|
+
body: JSON.stringify({ values }),
|
|
38
|
+
}),
|
|
39
|
+
} as never) as Promise<Response>,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (isCrossSiteRequest(Astro.request, Astro.url)) return crossSiteResponse();
|
|
44
|
+
|
|
45
|
+
const CODE = /^[A-Z0-9_]{1,64}$/;
|
|
46
|
+
const STATES = new Set(["paired", "unpaired", "revoked", "esign-missing", "forbidden", "error"]);
|
|
47
|
+
|
|
48
|
+
if (Astro.request.method === "POST") {
|
|
49
|
+
const form = await Astro.request.formData().catch(() => new FormData());
|
|
50
|
+
const action = form.get("action");
|
|
51
|
+
const result = await pairAction(ctx, ids, action);
|
|
52
|
+
const next = new URLSearchParams({ result: result.state });
|
|
53
|
+
if (result.code) next.set("code", result.code);
|
|
54
|
+
// T150: how many templates the action loaded for the panel (a count or an error code, nothing else).
|
|
55
|
+
if (action === "templates") next.set("did", "templates");
|
|
56
|
+
if (result.templates !== undefined) next.set("templates", String(result.templates));
|
|
57
|
+
if (result.templatesCode) next.set("tcode", result.templatesCode);
|
|
58
|
+
return Astro.redirect(`${PAIR_PATH}?${next}`, 303);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const view: PairView = await pairingStatus(ctx, ids);
|
|
62
|
+
const query = Astro.url.searchParams;
|
|
63
|
+
const result = query.get("result") ?? "";
|
|
64
|
+
const resultCode = query.get("code") ?? "";
|
|
65
|
+
const done = STATES.has(result) ? result : "";
|
|
66
|
+
const doneCode = CODE.test(resultCode) ? resultCode : "";
|
|
67
|
+
const refreshed = query.get("did") === "templates";
|
|
68
|
+
const loaded = /^\d{1,3}$/.test(query.get("templates") ?? "") ? Number(query.get("templates")) : -1;
|
|
69
|
+
const loadCode = CODE.test(query.get("tcode") ?? "") ? (query.get("tcode") as string) : "";
|
|
70
|
+
if (view.state === "forbidden") Astro.response.status = 403;
|
|
71
|
+
|
|
72
|
+
const templatesText =
|
|
73
|
+
loaded === 0
|
|
74
|
+
? "E-sign has no templates Available to integrations yet. Mark one in E-sign, then press Refresh templates here."
|
|
75
|
+
: loaded > 0
|
|
76
|
+
? `${loaded === 1 ? "One waiver template" : `${loaded} waiver templates`} loaded. The event panel offers ${loaded === 1 ? "it" : "them"} now (reload it if it is open).`
|
|
77
|
+
: loadCode === "NOT_FOUND"
|
|
78
|
+
? "Waiver templates need Events Pro, so there is nothing to load on this site."
|
|
79
|
+
: loadCode
|
|
80
|
+
? "The templates could not be loaded. Press Refresh templates to try again."
|
|
81
|
+
: "";
|
|
82
|
+
|
|
83
|
+
const ERRORS: Record<string, string> = {
|
|
84
|
+
ENCRYPTION_KEY_MISSING: "Set EMDASH_ENCRYPTION_KEY in the site environment and restart, then try again.",
|
|
85
|
+
PLUGIN_SETTING_ENCRYPTION_KEY_MISSING: "Set EMDASH_ENCRYPTION_KEY in the site environment and restart, then try again.",
|
|
86
|
+
MASTER_KEY_INVALID:
|
|
87
|
+
"E-sign's integration master key is unreadable or too short. Fix it in E-sign's settings, then try again.",
|
|
88
|
+
};
|
|
89
|
+
const errorText = (code?: string) =>
|
|
90
|
+
(code && ERRORS[code]) || "Pairing did not finish. Please try again; if it keeps failing, check that E-sign is up to date.";
|
|
91
|
+
---
|
|
92
|
+
<!doctype html>
|
|
93
|
+
<html lang="en">
|
|
94
|
+
<head>
|
|
95
|
+
<meta charset="utf-8" />
|
|
96
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
97
|
+
<meta name="robots" content="noindex" />
|
|
98
|
+
<meta name="referrer" content="no-referrer" />
|
|
99
|
+
<title>Pair Events with E-sign</title>
|
|
100
|
+
</head>
|
|
101
|
+
<body>
|
|
102
|
+
<main data-testid="pair-page" data-state={view.state}>
|
|
103
|
+
<h1>Pair Events with E-sign</h1>
|
|
104
|
+
{done === "paired" && !refreshed && <p role="status" data-testid="pair-done">Paired. Events can now ask for signed waivers.</p>}
|
|
105
|
+
{templatesText && <p role="status" data-testid="pair-templates">{templatesText}</p>}
|
|
106
|
+
{done === "unpaired" && <p role="status" data-testid="pair-done">Unpaired. Events no longer sends waivers to E-sign.</p>}
|
|
107
|
+
{done === "error" && <p role="alert" data-testid="pair-error">{errorText(doneCode)}</p>}
|
|
108
|
+
{view.state === "forbidden" && (
|
|
109
|
+
<p data-testid="pair-forbidden">
|
|
110
|
+
Sign in to the admin as an administrator, then open this page again. <a href="/_emdash/admin">Sign in</a>
|
|
111
|
+
</p>
|
|
112
|
+
)}
|
|
113
|
+
{view.state === "error" && <p role="alert" data-testid="pair-error">{errorText(view.code)}</p>}
|
|
114
|
+
{view.state === "esign-missing" && (
|
|
115
|
+
<p data-testid="pair-status">
|
|
116
|
+
E-sign is not installed or not enabled on this site.
|
|
117
|
+
{view.cleared && " The old pairing was removed, so Events asks for a pairing again."}
|
|
118
|
+
</p>
|
|
119
|
+
)}
|
|
120
|
+
{view.state === "unpaired" && (
|
|
121
|
+
<>
|
|
122
|
+
<p data-testid="pair-status">Events and E-sign are both installed but not paired yet.</p>
|
|
123
|
+
<form method="post" action={PAIR_PATH}>
|
|
124
|
+
<input type="hidden" name="action" value="pair" />
|
|
125
|
+
<button type="submit" data-testid="pair-button">Pair with E-sign on this site</button>
|
|
126
|
+
</form>
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
{view.state === "revoked" && (
|
|
130
|
+
<>
|
|
131
|
+
<p data-testid="pair-status">E-sign revoked this pairing. Pair again to keep using waivers.</p>
|
|
132
|
+
<form method="post" action={PAIR_PATH}>
|
|
133
|
+
<input type="hidden" name="action" value="pair" />
|
|
134
|
+
<button type="submit" data-testid="pair-button">Pair with E-sign on this site</button>
|
|
135
|
+
</form>
|
|
136
|
+
</>
|
|
137
|
+
)}
|
|
138
|
+
{view.state === "paired" && (
|
|
139
|
+
<>
|
|
140
|
+
<p data-testid="pair-status">Events is paired with E-sign on this site.</p>
|
|
141
|
+
<form method="post" action={PAIR_PATH}>
|
|
142
|
+
<input type="hidden" name="action" value="templates" />
|
|
143
|
+
<button type="submit" data-testid="templates-button">Refresh templates</button>
|
|
144
|
+
<span>Loads templates added in E-sign since, for the event panel's waiver select.</span>
|
|
145
|
+
</form>
|
|
146
|
+
<form method="post" action={PAIR_PATH}>
|
|
147
|
+
<input type="hidden" name="action" value="unpair" />
|
|
148
|
+
<button type="submit" data-testid="unpair-button">Unpair</button>
|
|
149
|
+
</form>
|
|
150
|
+
</>
|
|
151
|
+
)}
|
|
152
|
+
</main>
|
|
153
|
+
</body>
|
|
154
|
+
</html>
|
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
import { esignPluginId, eventsPluginId } from "virtual:semics-events-suite/config";
|
|
9
9
|
import { crossSiteResponse, isCrossSiteRequest } from "@semics-tech/emdash-events/astro/lib/checkout-form";
|
|
10
10
|
|
|
11
|
+
import { PAIR_PATH } from "./lib/pair";
|
|
11
12
|
import { refreshCatalogue, TEMPLATES_PATH } from "./lib/templates";
|
|
12
13
|
|
|
14
|
+
/** The admin's list of events, where the editor panel lives (it is scoped to the `events` collection). */
|
|
15
|
+
const BACK_PATH = "/_emdash/admin/content/events";
|
|
16
|
+
|
|
13
17
|
export const prerender = false;
|
|
14
18
|
|
|
15
19
|
Astro.response.headers.set("Referrer-Policy", "no-referrer");
|
|
@@ -57,11 +61,15 @@ if (Astro.request.method === "POST") {
|
|
|
57
61
|
: count === 1
|
|
58
62
|
? "One template loaded."
|
|
59
63
|
: `${count} templates loaded.`}{" "}
|
|
60
|
-
|
|
64
|
+
Step 2: go back to the event and reload its panel to choose one.
|
|
61
65
|
</p>
|
|
62
66
|
)
|
|
63
67
|
}
|
|
64
68
|
{count < 0 && !error && <p>Fetching the templates…</p>}
|
|
69
|
+
<p>
|
|
70
|
+
<a href={BACK_PATH} data-testid="templates-back">Back to your events</a>
|
|
71
|
+
{" "}(Refresh templates on <a href={PAIR_PATH}>{PAIR_PATH}</a> loads them in one step.)
|
|
72
|
+
</p>
|
|
65
73
|
<form method="post" action={TEMPLATES_PATH} data-semics-fragment="catalog" hidden>
|
|
66
74
|
<input type="hidden" name="catalog" value="" />
|
|
67
75
|
</form>
|
|
@@ -12,6 +12,7 @@ import { crossSiteResponse } from "@semics-tech/emdash-events/astro/lib/checkout
|
|
|
12
12
|
|
|
13
13
|
import { handleWaiverRequest } from "./lib/flow";
|
|
14
14
|
import { WAIVER_MESSAGES, WAIVER_PATH } from "./lib/form";
|
|
15
|
+
import { waiverSlot } from "./lib/slot";
|
|
15
16
|
|
|
16
17
|
export const prerender = false;
|
|
17
18
|
|
|
@@ -31,6 +32,19 @@ Astro.response.headers.set("Content-Security-Policy", await ceremonyCsp());
|
|
|
31
32
|
const render = outcome.type === "render" ? outcome : undefined;
|
|
32
33
|
const ref = outcome.type === "render" || outcome.type === "error" ? (outcome.ref ?? "") : "";
|
|
33
34
|
const error = outcome.type === "error" ? (WAIVER_MESSAGES[outcome.code] ?? WAIVER_MESSAGES.INTERNAL) : "";
|
|
35
|
+
// The event and the booked date and time, so the signer can confirm what they sign for (T148).
|
|
36
|
+
const slot = render
|
|
37
|
+
? await waiverSlot(
|
|
38
|
+
{ locals: Astro.locals, url: Astro.url, request: Astro.request, cookie: (name) => Astro.cookies.get(name)?.value },
|
|
39
|
+
{ eventsPluginId, esignPluginId },
|
|
40
|
+
ref,
|
|
41
|
+
undefined,
|
|
42
|
+
render.booking,
|
|
43
|
+
)
|
|
44
|
+
: undefined;
|
|
45
|
+
// An expired or cancelled booking (T151): the message, a link back to the event, and no form.
|
|
46
|
+
const expired = outcome.type === "error" && outcome.code === "BOOKING_EXPIRED";
|
|
47
|
+
const eventLink = outcome.type === "error" ? outcome.href : undefined;
|
|
34
48
|
const signer = render?.view.kind === "ready" ? render.view.session.signer : undefined;
|
|
35
49
|
---
|
|
36
50
|
<!doctype html>
|
|
@@ -46,6 +60,7 @@ const signer = render?.view.kind === "ready" ? render.view.session.signer : unde
|
|
|
46
60
|
<main class="semics-esign semics-sign" data-testid="waiver-page" data-step={render ? "sign" : "error"}>
|
|
47
61
|
<h1>Sign the waiver</h1>
|
|
48
62
|
{error && <p role="alert" data-testid="waiver-error">{error}</p>}
|
|
63
|
+
{expired && eventLink && <p><a href={eventLink} data-testid="waiver-book-again">Book again</a></p>}
|
|
49
64
|
{
|
|
50
65
|
outcome.type === "error" && outcome.code === "SIGNING_UNAVAILABLE" && ref && (
|
|
51
66
|
<form method="post" action={`${WAIVER_PATH}?ref=${ref}`} data-testid="waiver-retry">
|
|
@@ -56,6 +71,13 @@ const signer = render?.view.kind === "ready" ? render.view.session.signer : unde
|
|
|
56
71
|
}
|
|
57
72
|
{render && (
|
|
58
73
|
<>
|
|
74
|
+
{slot && (
|
|
75
|
+
<p data-testid="waiver-slot">
|
|
76
|
+
{slot.title && <strong data-testid="waiver-event">{slot.title}</strong>}
|
|
77
|
+
{slot.title && slot.when && <br />}
|
|
78
|
+
{slot.when && <time datetime={slot.startsAt} data-testid="waiver-when">{slot.when}</time>}
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
59
81
|
{signer?.participant && <p data-testid="waiver-for">Signing as guardian for <strong>{signer.participant}</strong>.</p>}
|
|
60
82
|
{render.remaining > 0 && (
|
|
61
83
|
<p data-testid="waiver-remaining">
|
package/src/astro/lib/flow.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
bookingCookieOptions,
|
|
27
27
|
isCrossSiteRequest,
|
|
28
28
|
} from "@semics-tech/emdash-events/astro/lib/checkout-form";
|
|
29
|
+
import { eventHref } from "@semics-tech/emdash-events/astro/lib/list";
|
|
29
30
|
import { isPayUrl } from "@semics-tech/emdash-events/astro/lib/payment";
|
|
30
31
|
import { forwardReceipt } from "@semics-tech/emdash-events/astro/lib/receipt";
|
|
31
32
|
import { newIdempotencyKey } from "@semics-tech/emdash-events/astro/lib/widget";
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
type WaiverOrder,
|
|
40
41
|
waiverErrorLocation,
|
|
41
42
|
} from "./form.ts";
|
|
43
|
+
import { voidEnvelopes } from "./reconcile.ts";
|
|
42
44
|
|
|
43
45
|
/** The steps of the flow. `pay` is the Phase 4 slot: continue's `pay` answer is followed as it is. */
|
|
44
46
|
export const FLOW_STEPS = ["book", "sign", "pay", "done"] as const;
|
|
@@ -65,13 +67,63 @@ export interface CookieOp {
|
|
|
65
67
|
export type WaiverOutcome =
|
|
66
68
|
| { type: "redirect"; location: string; cookies: CookieOp[] }
|
|
67
69
|
/** Show `view` in `<SigningCeremony>`; `remaining` signers follow this one. */
|
|
68
|
-
| {
|
|
69
|
-
|
|
70
|
+
| {
|
|
71
|
+
type: "render";
|
|
72
|
+
ref: string;
|
|
73
|
+
token: string;
|
|
74
|
+
remaining: number;
|
|
75
|
+
view: SignView;
|
|
76
|
+
cookies: CookieOp[];
|
|
77
|
+
/** The booking as `public/booking` answered it, when this page could read it (T151). */
|
|
78
|
+
booking?: BookingAnswer;
|
|
79
|
+
}
|
|
80
|
+
/** `href`: the event's page, for "book again" (`BOOKING_EXPIRED`, T151). */
|
|
81
|
+
| { type: "error"; code: string; ref?: string; href?: string }
|
|
70
82
|
| { type: "forbidden" };
|
|
71
83
|
|
|
72
84
|
export interface FlowDeps {
|
|
73
85
|
/** Injected for tests: the idempotency key when the form carries none. */
|
|
74
86
|
newKey?: () => string;
|
|
87
|
+
/** Injected for tests: epoch milliseconds. */
|
|
88
|
+
now?: () => number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What this page reads from Events `public/booking` (only the event's public details, T148/T151). */
|
|
92
|
+
export interface BookingAnswer {
|
|
93
|
+
status?: string;
|
|
94
|
+
holdExpiresAt?: string;
|
|
95
|
+
eventId?: string;
|
|
96
|
+
occurrenceId?: string;
|
|
97
|
+
/** The event's public info; `startsAt` is the booked date's. Kept after the event leaves sale. */
|
|
98
|
+
event?: { title?: string; startsAt?: string; timezone?: string | null; slug?: string | null };
|
|
99
|
+
/** Pro, once the booking has ended: a void intent per seat not signed (T152). */
|
|
100
|
+
waiver?: { refs: string[]; voids?: Record<string, string> };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Booking statuses with nothing left to sign for: the places are gone (T151). */
|
|
104
|
+
const ENDED = ["expired", "cancelled", "declined", "refunded", "refunded_unavailable"];
|
|
105
|
+
|
|
106
|
+
/** The booking `ref`, read with this page's access token; undefined without one or when Events refuses. */
|
|
107
|
+
export async function readBooking(
|
|
108
|
+
ctx: WaiverRequestContext,
|
|
109
|
+
ids: SuiteIds,
|
|
110
|
+
ref: string,
|
|
111
|
+
): Promise<BookingAnswer | undefined> {
|
|
112
|
+
const accessToken = ctx.cookie(retryCookieName(ref)) || ctx.cookie(bookingCookieName(ref));
|
|
113
|
+
if (!accessToken) return undefined;
|
|
114
|
+
const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
|
|
115
|
+
const answer = await post<BookingAnswer>(call, ids.eventsPluginId, "public/booking", {
|
|
116
|
+
bookingRef: ref,
|
|
117
|
+
accessToken,
|
|
118
|
+
}).catch(() => undefined);
|
|
119
|
+
return answer?.ok ? answer.data : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Expired, cancelled, or a hold that lapsed before the sweeper got to it. */
|
|
123
|
+
function ended(booking: BookingAnswer, now: number): boolean {
|
|
124
|
+
if (booking.status && ENDED.includes(booking.status)) return true;
|
|
125
|
+
const until = booking.holdExpiresAt ? Date.parse(booking.holdExpiresAt) : Number.NaN;
|
|
126
|
+
return Number.isFinite(until) && until <= now;
|
|
75
127
|
}
|
|
76
128
|
|
|
77
129
|
const BOOKING_REF = /^[0-9A-HJKMNP-TV-Z]{10}$/;
|
|
@@ -330,7 +382,12 @@ async function payOrLand(ctx: WaiverRequestContext, ids: SuiteIds, ref: string,
|
|
|
330
382
|
return next?.type === "pay" && isPayUrl(next.url) ? next.url : landed;
|
|
331
383
|
}
|
|
332
384
|
|
|
333
|
-
async function sign(
|
|
385
|
+
async function sign(
|
|
386
|
+
ctx: WaiverRequestContext,
|
|
387
|
+
ids: SuiteIds,
|
|
388
|
+
form: FormData | null,
|
|
389
|
+
deps: FlowDeps,
|
|
390
|
+
): Promise<WaiverOutcome> {
|
|
334
391
|
const ref = ctx.url.searchParams.get("ref") ?? "";
|
|
335
392
|
if (!BOOKING_REF.test(ref)) return redirect(BOOKING_PATH);
|
|
336
393
|
const error = ctx.url.searchParams.get("error");
|
|
@@ -340,6 +397,24 @@ async function sign(ctx: WaiverRequestContext, ids: SuiteIds, form: FormData | n
|
|
|
340
397
|
if (tokens.length === 0 || !tokens.every((t) => SIGNING_TOKEN.test(t))) {
|
|
341
398
|
return redirect(bookingPage(ref), tokens.length ? [flowCookie(ctx.url, ref, [])] : []);
|
|
342
399
|
}
|
|
400
|
+
// A booking whose places are gone has nothing to sign for (T151): no ceremony, and a post is refused
|
|
401
|
+
// before e-sign is asked, so no signature is captured for it. When Events cannot answer, signing goes on
|
|
402
|
+
// (its receipt step still confirms nothing for an ended booking).
|
|
403
|
+
const booking = await readBooking(ctx, ids, ref);
|
|
404
|
+
if (booking && ended(booking, (deps.now ?? Date.now)())) {
|
|
405
|
+
// T152: with Pro the answer carries void intents for the seats not signed; e-sign voids their
|
|
406
|
+
// envelopes, so the cookie's tokens sign nothing. A failure never changes the page.
|
|
407
|
+
if (booking.waiver?.voids) {
|
|
408
|
+
const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
|
|
409
|
+
await voidEnvelopes(call, {
|
|
410
|
+
booking: { bookingRef: ref, waiver: booking.waiver },
|
|
411
|
+
esignPluginId: ids.esignPluginId,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const href = booking.event?.slug ? eventHref({ slug: booking.event.slug }) : undefined;
|
|
415
|
+
return { type: "error", ref, code: "BOOKING_EXPIRED", ...(href ? { href } : {}) };
|
|
416
|
+
}
|
|
417
|
+
const withBooking = booking ? { booking } : {};
|
|
343
418
|
const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
|
|
344
419
|
const signCtx = { ...call, pluginId: ids.esignPluginId };
|
|
345
420
|
const forward = (receipt: string) =>
|
|
@@ -350,7 +425,7 @@ async function sign(ctx: WaiverRequestContext, ids: SuiteIds, form: FormData | n
|
|
|
350
425
|
const [token = "", ...after] = rest;
|
|
351
426
|
const view = await handleSign(signCtx, token, form);
|
|
352
427
|
if (view.kind === "ready" || view.kind === "error") {
|
|
353
|
-
return { type: "render", ref, token, remaining: after.length, view, cookies: [] };
|
|
428
|
+
return { type: "render", ref, token, remaining: after.length, view, cookies: [], ...withBooking };
|
|
354
429
|
}
|
|
355
430
|
// Signed, declined, or closed meanwhile (another tab): record the outcome with Events.
|
|
356
431
|
const receipt = receiptOf(view);
|
|
@@ -371,7 +446,7 @@ async function sign(ctx: WaiverRequestContext, ids: SuiteIds, form: FormData | n
|
|
|
371
446
|
const view = await handleSign(signCtx, token, null);
|
|
372
447
|
if (view.kind !== "closed") {
|
|
373
448
|
const cookies = rest.length === tokens.length ? [] : [flowCookie(ctx.url, ref, rest)];
|
|
374
|
-
return { type: "render", ref, token, remaining: after.length, view, cookies };
|
|
449
|
+
return { type: "render", ref, token, remaining: after.length, view, cookies, ...withBooking };
|
|
375
450
|
}
|
|
376
451
|
const receipt = receiptOf(view);
|
|
377
452
|
if (receipt) landed = (await forward(receipt)).location;
|
|
@@ -394,5 +469,5 @@ export async function handleWaiverRequest(
|
|
|
394
469
|
const form = ctx.request.method === "POST" ? await ctx.request.formData().catch(() => new FormData()) : null;
|
|
395
470
|
if (form?.get("intent") === "book") return book(ctx, form, ids, deps);
|
|
396
471
|
if (form?.get("intent") === "retry") return retry(ctx, ids);
|
|
397
|
-
return sign(ctx, ids, form);
|
|
472
|
+
return sign(ctx, ids, form, deps);
|
|
398
473
|
}
|
package/src/astro/lib/form.ts
CHANGED
|
@@ -133,6 +133,7 @@ export const WAIVER_MESSAGES: Record<string, string> = {
|
|
|
133
133
|
RATE_LIMITED: "Too many bookings from this connection. Please wait a few minutes and try again.",
|
|
134
134
|
TURNSTILE_FAILED: "The security check did not pass. Please try again.",
|
|
135
135
|
HOLD_EXPIRED: "Your hold on these tickets expired. Please start again.",
|
|
136
|
+
BOOKING_EXPIRED: "This booking has expired. Please book again.",
|
|
136
137
|
NOT_FOUND: "This event is not taking bookings.",
|
|
137
138
|
SIGNING_UNAVAILABLE:
|
|
138
139
|
"The waiver could not be opened for signing just now. Your tickets are still held for a short time: try again, or contact the organiser.",
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic pairing of Events with E-sign on one site (T145), behind `/_events/pair`.
|
|
3
|
+
*
|
|
4
|
+
* Sandbox facts this rests on (EmDash 1.2):
|
|
5
|
+
* - A sandboxed core reaches only its own settings, storage and KV (all scoped by
|
|
6
|
+
* plugin id) and cannot call another plugin's routes, so neither core can pair the other.
|
|
7
|
+
* - Site pages run as native code. For a signed-in visitor `locals.emdash` carries
|
|
8
|
+
* `handlePluginApiRoute`, which runs any route of a plugin (private ones too) in process with the
|
|
9
|
+
* caller we pass; anonymous visitors only get `handlePublicPluginApiRoute`.
|
|
10
|
+
* - Plugin settings are written by the host's own handler (`PUT /_emdash/api/admin/plugins/<id>/settings`),
|
|
11
|
+
* which checks `plugins:manage` on `locals.user`, validates against the manifest, encrypts secrets
|
|
12
|
+
* and merges (only the keys sent; `null` deletes). Its answers mask secrets (`secretsSet`).
|
|
13
|
+
*
|
|
14
|
+
* So: the host's settings read checks the visitor is an administrator; E-sign's private
|
|
15
|
+
* `integrations/pair` (called as that administrator, with a fresh server-side `Request`) returns the
|
|
16
|
+
* pairing string for one reusable site client; the host's settings write stores it in Events
|
|
17
|
+
* (`esignPairing`, encrypted). The string lives only in this function's memory: it is never in a
|
|
18
|
+
* response, URL, cookie or log. Glue only: no rules, no tokens, no `@semics-tech/shared` (R35).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { loadCatalogue } from "./templates";
|
|
22
|
+
|
|
23
|
+
/** The page (injected by `suiteIntegration`). */
|
|
24
|
+
export const PAIR_PATH = "/_events/pair";
|
|
25
|
+
const ESIGN_ROUTE = "integrations/pair";
|
|
26
|
+
const SETTING = "esignPairing";
|
|
27
|
+
|
|
28
|
+
export interface PairIds {
|
|
29
|
+
eventsPluginId: string;
|
|
30
|
+
esignPluginId: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The host's plugin-settings handlers, run in process with the visitor's `locals`. */
|
|
34
|
+
export interface SettingsHandlers {
|
|
35
|
+
read(pluginId: string): Promise<Response>;
|
|
36
|
+
write(pluginId: string, values: Record<string, unknown>): Promise<Response>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PairContext {
|
|
40
|
+
locals: unknown;
|
|
41
|
+
url: URL;
|
|
42
|
+
settings: SettingsHandlers;
|
|
43
|
+
/**
|
|
44
|
+
* Events' stored pairing string, decrypted on the server (`getPluginSetting` in the page). Only its
|
|
45
|
+
* client id is used, so the page can tell E-sign which client Events holds (T149); the secret half
|
|
46
|
+
* is dropped here and never leaves this process.
|
|
47
|
+
*/
|
|
48
|
+
readPairing?: () => Promise<unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type PairState = "paired" | "unpaired" | "revoked" | "esign-missing" | "forbidden" | "error";
|
|
52
|
+
export interface PairView {
|
|
53
|
+
state: PairState;
|
|
54
|
+
/** T150: templates loaded into Events Pro's catalogue by this action; absent when none could be. */
|
|
55
|
+
templates?: number;
|
|
56
|
+
/** T150: why the templates could not be loaded (an error code; `NOT_FOUND` on Free Events). */
|
|
57
|
+
templatesCode?: string;
|
|
58
|
+
/** An error code (never a message that could carry a value). */
|
|
59
|
+
code?: string;
|
|
60
|
+
/** The page removed a pairing left behind by an uninstalled E-sign. */
|
|
61
|
+
cleared?: true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface HandlerResponse {
|
|
65
|
+
success?: boolean;
|
|
66
|
+
data?: unknown;
|
|
67
|
+
error?: { code?: unknown; message?: unknown };
|
|
68
|
+
}
|
|
69
|
+
type RouteHandler = (
|
|
70
|
+
pluginId: string,
|
|
71
|
+
method: string,
|
|
72
|
+
path: string,
|
|
73
|
+
request: Request,
|
|
74
|
+
user?: unknown,
|
|
75
|
+
) => Promise<HandlerResponse>;
|
|
76
|
+
|
|
77
|
+
interface Admin {
|
|
78
|
+
call: RouteHandler;
|
|
79
|
+
user: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function adminOf(locals: unknown): Admin | null {
|
|
83
|
+
const l = (locals ?? {}) as { user?: unknown; emdash?: { handlePluginApiRoute?: unknown } };
|
|
84
|
+
const call = l.emdash?.handlePluginApiRoute;
|
|
85
|
+
return typeof call === "function" && l.user ? { call: call as RouteHandler, user: l.user } : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type EsignAnswer =
|
|
89
|
+
| { kind: "missing" }
|
|
90
|
+
| { kind: "error"; code: string }
|
|
91
|
+
| { kind: "ok"; data: Record<string, unknown> };
|
|
92
|
+
|
|
93
|
+
const PAIRING_ID = /^esign1\.([A-Za-z0-9_-]{4,64})\.[A-Za-z0-9_-]{43}$/;
|
|
94
|
+
|
|
95
|
+
/** The client id of the pairing Events holds (a hand-made one too), or undefined. Never the secret. */
|
|
96
|
+
async function heldClientId(ctx: PairContext): Promise<string | undefined> {
|
|
97
|
+
const value = await ctx.readPairing?.().catch(() => undefined);
|
|
98
|
+
return typeof value === "string" ? PAIRING_ID.exec(value)?.[1] : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** E-sign's `integrations/pair`, in process. "missing" only when the host says the plugin is not there. */
|
|
102
|
+
async function esign(
|
|
103
|
+
ctx: PairContext,
|
|
104
|
+
admin: Admin,
|
|
105
|
+
ids: PairIds,
|
|
106
|
+
action: string,
|
|
107
|
+
clientId?: string,
|
|
108
|
+
): Promise<EsignAnswer> {
|
|
109
|
+
const request = new Request(new URL(`/_emdash/api/plugins/${ids.esignPluginId}/${ESIGN_ROUTE}`, ctx.url), {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: { "content-type": "application/json" },
|
|
112
|
+
body: JSON.stringify(clientId ? { action, clientId } : { action }),
|
|
113
|
+
});
|
|
114
|
+
const res = await admin.call(ids.esignPluginId, "POST", ESIGN_ROUTE, request, admin.user).catch(() => null);
|
|
115
|
+
if (!res) return { kind: "error", code: "INTERNAL" };
|
|
116
|
+
if (!res.success) {
|
|
117
|
+
const message = typeof res.error?.message === "string" ? res.error.message : "";
|
|
118
|
+
if (res.error?.code === "NOT_FOUND" && /^Plugin not (enabled|found)/.test(message)) return { kind: "missing" };
|
|
119
|
+
return { kind: "error", code: typeof res.error?.code === "string" ? res.error.code : "INTERNAL" };
|
|
120
|
+
}
|
|
121
|
+
const data = (res.data ?? {}) as Record<string, unknown>;
|
|
122
|
+
if (data.ok !== true) {
|
|
123
|
+
const code = (data.error as { code?: unknown } | undefined)?.code;
|
|
124
|
+
return { kind: "error", code: typeof code === "string" ? code : "INTERNAL" };
|
|
125
|
+
}
|
|
126
|
+
return { kind: "ok", data };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether Events holds a pairing string, or null when the visitor may not manage plugins. */
|
|
130
|
+
async function eventsPaired(ctx: PairContext, ids: PairIds): Promise<boolean | null> {
|
|
131
|
+
const res = await ctx.settings.read(ids.eventsPluginId).catch(() => null);
|
|
132
|
+
if (!res?.ok) return null;
|
|
133
|
+
const body = (await res.json().catch(() => ({}))) as { data?: { secretsSet?: Record<string, unknown> } };
|
|
134
|
+
return body.data?.secretsSet?.[SETTING] === true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Writes Events' pairing setting (`null` removes it); the error code on failure. */
|
|
138
|
+
async function writePairing(ctx: PairContext, ids: PairIds, value: string | null): Promise<string | null> {
|
|
139
|
+
const res = await ctx.settings.write(ids.eventsPluginId, { [SETTING]: value }).catch(() => null);
|
|
140
|
+
if (res?.ok) return null;
|
|
141
|
+
const body = (await res?.json().catch(() => ({}))) as { error?: { code?: unknown } } | undefined;
|
|
142
|
+
return typeof body?.error?.code === "string" ? body.error.code : "INTERNAL";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The page's state for a GET. A pairing left in Events after E-sign was removed is cleared here, so
|
|
147
|
+
* Events goes back to asking for one (the "pairing" waiver state) instead of minting intents nobody
|
|
148
|
+
* can redeem; that write only ever removes a pairing that cannot work.
|
|
149
|
+
*/
|
|
150
|
+
export async function pairingStatus(ctx: PairContext, ids: PairIds): Promise<PairView> {
|
|
151
|
+
const admin = adminOf(ctx.locals);
|
|
152
|
+
if (!admin) return { state: "forbidden" };
|
|
153
|
+
const paired = await eventsPaired(ctx, ids);
|
|
154
|
+
if (paired === null) return { state: "forbidden" };
|
|
155
|
+
const status = await esign(ctx, admin, ids, "status", paired ? await heldClientId(ctx) : undefined);
|
|
156
|
+
if (status.kind === "missing") {
|
|
157
|
+
if (!paired) return { state: "esign-missing" };
|
|
158
|
+
const failed = await writePairing(ctx, ids, null);
|
|
159
|
+
return failed ? { state: "error", code: failed } : { state: "esign-missing", cleared: true };
|
|
160
|
+
}
|
|
161
|
+
if (status.kind === "error") return { state: "error", code: status.code };
|
|
162
|
+
if (!paired) return { state: "unpaired" };
|
|
163
|
+
return { state: status.data.client === "active" ? "paired" : "revoked" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* T150: loads E-sign's templates into Events Pro's catalogue (`loadCatalogue`, in process), so the
|
|
168
|
+
* panel offers them without its Refresh link. Free has no catalogue: no count, the paired state stands.
|
|
169
|
+
*/
|
|
170
|
+
async function withTemplates(ctx: PairContext, ids: PairIds, view: PairView, report: boolean): Promise<PairView> {
|
|
171
|
+
const loaded = await loadCatalogue({ locals: ctx.locals as never, url: ctx.url }, ids);
|
|
172
|
+
if (loaded.ok) return { ...view, templates: loaded.count };
|
|
173
|
+
return report ? { ...view, templatesCode: loaded.code } : view;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The page's POST: `pair` (idempotent), `unpair`, or `templates` (fetch the catalogue again, T150). */
|
|
177
|
+
export async function pairAction(ctx: PairContext, ids: PairIds, action: unknown): Promise<PairView> {
|
|
178
|
+
if (action !== "pair" && action !== "unpair" && action !== "templates")
|
|
179
|
+
return { state: "error", code: "BAD_REQUEST" };
|
|
180
|
+
const admin = adminOf(ctx.locals);
|
|
181
|
+
if (!admin) return { state: "forbidden" };
|
|
182
|
+
const paired = await eventsPaired(ctx, ids);
|
|
183
|
+
if (paired === null) return { state: "forbidden" };
|
|
184
|
+
const held = paired ? await heldClientId(ctx) : undefined;
|
|
185
|
+
|
|
186
|
+
if (action === "templates") {
|
|
187
|
+
return paired ? withTemplates(ctx, ids, { state: "paired" }, true) : { state: "unpaired" };
|
|
188
|
+
}
|
|
189
|
+
if (action === "unpair") {
|
|
190
|
+
const revoked = await esign(ctx, admin, ids, "unpair", held);
|
|
191
|
+
if (revoked.kind === "error") return { state: "error", code: revoked.code };
|
|
192
|
+
const failed = await writePairing(ctx, ids, null);
|
|
193
|
+
return failed ? { state: "error", code: failed } : { state: "unpaired" };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const made = await esign(ctx, admin, ids, "pair", held);
|
|
197
|
+
if (made.kind === "missing") return { state: "esign-missing" };
|
|
198
|
+
if (made.kind === "error") return { state: "error", code: made.code };
|
|
199
|
+
const pairing = made.data.pairing;
|
|
200
|
+
if (typeof pairing !== "string" || !pairing.startsWith("esign1.")) return { state: "error", code: "INTERNAL" };
|
|
201
|
+
const failed = await writePairing(ctx, ids, pairing);
|
|
202
|
+
return failed ? { state: "error", code: failed } : withTemplates(ctx, ids, { state: "paired" }, false);
|
|
203
|
+
}
|
|
@@ -33,8 +33,41 @@ const RECEIPT_MAX = 2048;
|
|
|
33
33
|
const tokenOf = (value: unknown, max: number): string | null =>
|
|
34
34
|
typeof value === "string" && value.length <= max && TOKEN.test(value) ? value : null;
|
|
35
35
|
|
|
36
|
+
const INTENT_MAX = 4096;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* T152: an ended booking (expired, cancelled) carries, with Pro, a `replace` intent per seat not signed.
|
|
40
|
+
* Each goes to e-sign's `from-intent`, which voids the seat's open envelope and creates nothing, so a
|
|
41
|
+
* signing token lifted from the waiver cookie signs nothing any more. Idempotent (a voided or completed
|
|
42
|
+
* envelope is left alone), so every visit may send them again. Resolves with how many e-sign accepted.
|
|
43
|
+
*/
|
|
44
|
+
export async function voidEnvelopes(
|
|
45
|
+
ctx: PublicRouteContext,
|
|
46
|
+
input: Pick<ReconcileInput, "booking" | "esignPluginId">,
|
|
47
|
+
): Promise<number> {
|
|
48
|
+
const intents = Object.values(input.booking.waiver?.voids ?? {})
|
|
49
|
+
.slice(0, MAX_LOOKUPS)
|
|
50
|
+
.map((intent) => tokenOf(intent, INTENT_MAX))
|
|
51
|
+
.filter((token): token is string => token !== null);
|
|
52
|
+
const answers = await Promise.all(
|
|
53
|
+
intents.map((intent) =>
|
|
54
|
+
callPublicRoute(ctx, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
path: "public/envelopes/from-intent",
|
|
57
|
+
pluginId: input.esignPluginId,
|
|
58
|
+
body: { intent },
|
|
59
|
+
}).catch(() => null),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
return answers.filter((a) => a?.ok).length;
|
|
63
|
+
}
|
|
64
|
+
|
|
36
65
|
/** Forwards the receipts e-sign already holds for the booking's unsigned refs; resolves with how many Events took. */
|
|
37
|
-
export async function reconcileBooking(
|
|
66
|
+
export async function reconcileBooking(
|
|
67
|
+
ctx: PublicRouteContext,
|
|
68
|
+
input: ReconcileInput,
|
|
69
|
+
): Promise<{ forwarded: number; voided?: number }> {
|
|
70
|
+
if (input.booking.waiver?.voids) return { forwarded: 0, voided: await voidEnvelopes(ctx, input) };
|
|
38
71
|
const { refs = [], lookups = {} } = input.booking.waiver ?? {};
|
|
39
72
|
const requests = refs
|
|
40
73
|
.slice(0, MAX_LOOKUPS)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the waiver page says about the booking it is signing for (T148): the event's title and the
|
|
3
|
+
* booked date and time, in the event's own zone and in the format of the booking widget's date list.
|
|
4
|
+
* Read through Events' public routes: `public/booking` carries the event's public info with the
|
|
5
|
+
* booked date's start (T151), so a booking made while on sale keeps its line after the event moves
|
|
6
|
+
* to draft or paused, when `public/event` answers nothing. An Events build without it falls back to
|
|
7
|
+
* `public/event`. Only the event's own public details: no personal data. Any failure shows nothing extra.
|
|
8
|
+
*/
|
|
9
|
+
import { callPublicRoute, type PublicRouteContext } from "@semics-tech/emdash-events/astro/lib/call";
|
|
10
|
+
|
|
11
|
+
import { type BookingAnswer, readBooking, type SuiteIds } from "./flow.ts";
|
|
12
|
+
|
|
13
|
+
export interface WaiverSlot {
|
|
14
|
+
title?: string;
|
|
15
|
+
/** "Wednesday 4 November 2026 at 13:30" in the event's zone; absent when the date is unknown. */
|
|
16
|
+
when?: string;
|
|
17
|
+
/** The ISO start, for a `<time datetime>`. */
|
|
18
|
+
startsAt?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface EventAnswer {
|
|
22
|
+
title?: string;
|
|
23
|
+
timezone?: string;
|
|
24
|
+
startsAt?: string;
|
|
25
|
+
occurrences?: Array<{ id: string; startsAt: string }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The widget's date format (full date, short time) in `zone`; UTC when the zone is unknown. */
|
|
29
|
+
export function formatSlot(iso: string, zone: string | undefined, locale = "en-GB"): string {
|
|
30
|
+
const options = { dateStyle: "full", timeStyle: "short" } as const;
|
|
31
|
+
const date = new Date(iso);
|
|
32
|
+
try {
|
|
33
|
+
return new Intl.DateTimeFormat(locale, { ...options, timeZone: zone ?? "UTC" }).format(date);
|
|
34
|
+
} catch {
|
|
35
|
+
return new Intl.DateTimeFormat(locale, { ...options, timeZone: "UTC" }).format(date);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The booked start and the event's title and zone, from the booking or else from `public/event`. */
|
|
40
|
+
async function slotInfo(
|
|
41
|
+
ctx: PublicRouteContext,
|
|
42
|
+
ids: SuiteIds,
|
|
43
|
+
booking: BookingAnswer,
|
|
44
|
+
): Promise<{ title?: string; startsAt?: string; timezone?: string } | undefined> {
|
|
45
|
+
const own = booking.event;
|
|
46
|
+
if (own) {
|
|
47
|
+
return {
|
|
48
|
+
...(own.title ? { title: own.title } : {}),
|
|
49
|
+
...(own.startsAt ? { startsAt: own.startsAt } : {}),
|
|
50
|
+
...(own.timezone ? { timezone: own.timezone } : {}),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (!booking.eventId) return undefined;
|
|
54
|
+
const event = await callPublicRoute<EventAnswer>(ctx, {
|
|
55
|
+
path: "public/event",
|
|
56
|
+
pluginId: ids.eventsPluginId,
|
|
57
|
+
body: { eventId: booking.eventId },
|
|
58
|
+
});
|
|
59
|
+
if (!event.ok) return undefined;
|
|
60
|
+
const { occurrenceId } = booking;
|
|
61
|
+
const startsAt = occurrenceId
|
|
62
|
+
? event.data.occurrences?.find((o) => o.id === occurrenceId)?.startsAt
|
|
63
|
+
: event.data.startsAt;
|
|
64
|
+
return {
|
|
65
|
+
...(event.data.title ? { title: event.data.title } : {}),
|
|
66
|
+
...(startsAt ? { startsAt } : {}),
|
|
67
|
+
...(event.data.timezone ? { timezone: event.data.timezone } : {}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The slot line for booking `ref`. `booking` is the `public/booking` answer the page already read
|
|
73
|
+
* (the flow's render outcome carries it); without it the booking is read here.
|
|
74
|
+
*/
|
|
75
|
+
export async function waiverSlot(
|
|
76
|
+
ctx: { locals: PublicRouteContext["locals"]; url: URL; request: Request; cookie(name: string): string | undefined },
|
|
77
|
+
ids: SuiteIds,
|
|
78
|
+
ref: string,
|
|
79
|
+
locale?: string,
|
|
80
|
+
booking?: BookingAnswer,
|
|
81
|
+
): Promise<WaiverSlot | undefined> {
|
|
82
|
+
const call = { locals: ctx.locals, url: ctx.url, request: ctx.request };
|
|
83
|
+
try {
|
|
84
|
+
const answer = booking ?? (await readBooking(ctx, ids, ref));
|
|
85
|
+
const info = answer && (await slotInfo(call, ids, answer));
|
|
86
|
+
if (!info) return undefined;
|
|
87
|
+
const { startsAt } = info;
|
|
88
|
+
const slot: WaiverSlot = {
|
|
89
|
+
...(info.title ? { title: info.title } : {}),
|
|
90
|
+
...(startsAt && Number.isFinite(Date.parse(startsAt))
|
|
91
|
+
? { startsAt, when: formatSlot(startsAt, info.timezone, locale) }
|
|
92
|
+
: {}),
|
|
93
|
+
};
|
|
94
|
+
return slot.title || slot.when ? slot : undefined;
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -74,3 +74,41 @@ export async function refreshCatalogue(
|
|
|
74
74
|
if (!cached) return { ok: false, code: "INTERNAL" };
|
|
75
75
|
return cached.ok ? { ok: true, count: templates.length } : { ok: false, code: cached.error.code };
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
const REQUEST_ROUTE = "esign/catalog-request";
|
|
79
|
+
|
|
80
|
+
type PrivateRoute = (
|
|
81
|
+
pluginId: string,
|
|
82
|
+
method: string,
|
|
83
|
+
path: string,
|
|
84
|
+
request: Request,
|
|
85
|
+
user?: unknown,
|
|
86
|
+
) => Promise<{ success?: boolean; data?: unknown; error?: { code?: unknown; message?: unknown } }>;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The catalogue fetch without a link (T150): asks Events Pro's private `esign/catalog-request` in
|
|
90
|
+
* process, as the signed-in administrator in `ctx.locals`, for a fresh request, then runs
|
|
91
|
+
* `refreshCatalogue` with it. The request never leaves the server. Free Events has no such route:
|
|
92
|
+
* `NOT_FOUND`. Never throws.
|
|
93
|
+
*/
|
|
94
|
+
export async function loadCatalogue(ctx: PublicRouteContext, ids: CatalogueIds): Promise<CatalogueResult> {
|
|
95
|
+
const l = (ctx.locals ?? {}) as { user?: unknown; emdash?: { handlePluginApiRoute?: unknown } };
|
|
96
|
+
const call = l.emdash?.handlePluginApiRoute;
|
|
97
|
+
if (typeof call !== "function" || !l.user) return { ok: false, code: "FORBIDDEN" };
|
|
98
|
+
const asked = new Request(new URL(`/_emdash/api/plugins/${ids.eventsPluginId}/${REQUEST_ROUTE}`, ctx.url), {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: { "content-type": "application/json" },
|
|
101
|
+
body: "{}",
|
|
102
|
+
});
|
|
103
|
+
const res = await (call as PrivateRoute)(ids.eventsPluginId, "POST", REQUEST_ROUTE, asked, l.user).catch(() => null);
|
|
104
|
+
const data = (res?.success ? res.data : null) as {
|
|
105
|
+
ok?: unknown;
|
|
106
|
+
request?: unknown;
|
|
107
|
+
error?: { code?: unknown };
|
|
108
|
+
} | null;
|
|
109
|
+
if (data?.ok === true) return refreshCatalogue(ctx, data.request, ids);
|
|
110
|
+
// A sandboxed core without the route (Free): the host answers ROUTE_ERROR "… Route not found …".
|
|
111
|
+
const missing = typeof res?.error?.message === "string" && /\bRoute not found\b/.test(res.error.message);
|
|
112
|
+
const code = missing ? "NOT_FOUND" : (data?.error?.code ?? res?.error?.code);
|
|
113
|
+
return { ok: false, code: typeof code === "string" ? code : "INTERNAL" };
|
|
114
|
+
}
|