@camphoric/camphoric-event-import-tools 2.0.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/CamphoricEventCreator.ts +210 -15
- package/README.md +59 -5
- package/dist/CamphoricEventCreator.d.ts +30 -4
- package/dist/CamphoricEventCreator.d.ts.map +1 -1
- package/dist/CamphoricEventCreator.js +163 -15
- package/dist/CamphoricEventCreator.js.map +1 -1
- package/dist/e2e.d.ts +17 -3
- package/dist/e2e.d.ts.map +1 -1
- package/dist/e2e.js +60 -7
- package/dist/e2e.js.map +1 -1
- package/dist/e2eAdmin.d.ts +59 -0
- package/dist/e2eAdmin.d.ts.map +1 -0
- package/dist/e2eAdmin.js +103 -0
- package/dist/e2eAdmin.js.map +1 -0
- package/dist/e2eImport.d.ts +1 -0
- package/dist/e2eImport.d.ts.map +1 -1
- package/dist/e2eImport.js +8 -4
- package/dist/e2eImport.js.map +1 -1
- package/dist/e2ePayPal.d.ts +34 -0
- package/dist/e2ePayPal.d.ts.map +1 -0
- package/dist/e2ePayPal.js +104 -0
- package/dist/e2ePayPal.js.map +1 -0
- package/dist/e2eRegistrationForm.d.ts +42 -0
- package/dist/e2eRegistrationForm.d.ts.map +1 -1
- package/dist/e2eRegistrationForm.js +99 -0
- package/dist/e2eRegistrationForm.js.map +1 -1
- package/dist/e2eServer.d.ts +2 -0
- package/dist/e2eServer.d.ts.map +1 -1
- package/dist/e2eServer.js +14 -1
- package/dist/e2eServer.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/e2e-compose.yml +38 -3
- package/e2e.ts +67 -9
- package/e2eAdmin.ts +174 -0
- package/e2eFakePayPal.py +112 -0
- package/e2eImport.ts +13 -2
- package/e2ePayPal.ts +129 -0
- package/e2eRegistrationForm.ts +108 -0
- package/e2eServer.ts +19 -1
- package/package.json +4 -1
- package/types.ts +21 -0
package/e2ePayPal.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PayPal, faked for an event's e2e tests. The throwaway server's PayPal API is
|
|
3
|
+
* e2eFakePayPal.py, and the browser's PayPal script is FAKE_PAYPAL_SDK (the
|
|
4
|
+
* `test` fixture serves it in place of PayPal's), so the payment step's
|
|
5
|
+
* PayPal buttons work without a PayPal account and nothing is paid.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** What the server asked PayPal for, as e2eFakePayPal.py recorded it. */
|
|
9
|
+
export interface PayPalOrder {
|
|
10
|
+
// PayPal's order id, as the browser was given it
|
|
11
|
+
id: string;
|
|
12
|
+
// The order's amount as the server sent it, e.g. '102.50' (a string, so
|
|
13
|
+
// its decimal places can be checked), and its currency
|
|
14
|
+
amount: string;
|
|
15
|
+
currency: string;
|
|
16
|
+
// The whole request the server sent to create the order
|
|
17
|
+
body: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface RecordedOrder {
|
|
21
|
+
id: string;
|
|
22
|
+
body: { purchase_units?: { amount?: { value?: string, currency_code?: string } }[] };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The client id an event is imported with for online payment (e2eImport.ts):
|
|
26
|
+
// only the fakes accept it
|
|
27
|
+
export const E2E_PAYPAL_CLIENT_ID = 'camphoric-e2e-fake-paypal';
|
|
28
|
+
|
|
29
|
+
// PayPal's script, wherever the client loads it from
|
|
30
|
+
export const PAYPAL_SDK_URL = /^https:\/\/www\.(sandbox\.)?paypal\.com\/sdk\/js(\?|$)/;
|
|
31
|
+
|
|
32
|
+
/** The fake PayPal API's record of the orders the server created. */
|
|
33
|
+
export class FakePayPal {
|
|
34
|
+
readonly url: string;
|
|
35
|
+
|
|
36
|
+
constructor(url: string) {
|
|
37
|
+
this.url = url.replace(/\/$/, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Every order the server created, oldest first. */
|
|
41
|
+
async orders(): Promise<PayPalOrder[]> {
|
|
42
|
+
const recorded = await this.get('/e2e/orders') as RecordedOrder[];
|
|
43
|
+
return recorded.map(order);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The order with this id, as the server asked for it. */
|
|
47
|
+
async order(id: string): Promise<PayPalOrder> {
|
|
48
|
+
return order(await this.get(`/e2e/orders/${encodeURIComponent(id)}`) as RecordedOrder);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async get(path: string): Promise<unknown> {
|
|
52
|
+
const response = await fetch(`${this.url}${path}`);
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw new Error(`the fake PayPal answered ${response.status} to ${path}: ${await response.text()}`);
|
|
55
|
+
}
|
|
56
|
+
return response.json();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function order(recorded: RecordedOrder): PayPalOrder {
|
|
61
|
+
const amount = recorded.body.purchase_units?.[0]?.amount;
|
|
62
|
+
return {
|
|
63
|
+
id: recorded.id,
|
|
64
|
+
amount: amount?.value ?? '',
|
|
65
|
+
currency: amount?.currency_code ?? '',
|
|
66
|
+
body: recorded.body as Record<string, unknown>,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* PayPal's browser script, faked: `paypal.Buttons()` draws a "PayPal" and a
|
|
72
|
+
* "Debit or Credit Card" button, as the client's PayPal library expects. A
|
|
73
|
+
* click does what PayPal's does up to its popup: onClick, then createOrder
|
|
74
|
+
* (which asks the server for the order). The popup then stays "open": the
|
|
75
|
+
* payer never approves or cancels, so nothing is captured.
|
|
76
|
+
*/
|
|
77
|
+
export const FAKE_PAYPAL_SDK = `(() => {
|
|
78
|
+
const buttons = [['paypal', 'PayPal'], ['card', 'Debit or Credit Card']];
|
|
79
|
+
window.paypal = {
|
|
80
|
+
version: 'camphoric-e2e-fake',
|
|
81
|
+
FUNDING: { PAYPAL: 'paypal', CARD: 'card' },
|
|
82
|
+
Buttons(props) {
|
|
83
|
+
let container = null;
|
|
84
|
+
const elements = [];
|
|
85
|
+
// Disabled (as the client does while it's busy) by the buttons' own
|
|
86
|
+
// attribute, so a test's click waits until they're enabled
|
|
87
|
+
const setDisabled = (disabled) => {
|
|
88
|
+
for (const button of elements) button.disabled = disabled;
|
|
89
|
+
return Promise.resolve();
|
|
90
|
+
};
|
|
91
|
+
const actions = { enable: () => setDisabled(false), disable: () => setDisabled(true) };
|
|
92
|
+
const pay = async (fundingSource) => {
|
|
93
|
+
const data = { fundingSource };
|
|
94
|
+
const clickActions = { resolve: () => Promise.resolve(), reject: () => Promise.resolve() };
|
|
95
|
+
if (props.onClick && (await props.onClick(data, clickActions)) === false) return;
|
|
96
|
+
try {
|
|
97
|
+
await props.createOrder(data, {});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (props.onError) props.onError(error);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
return {
|
|
103
|
+
isEligible: () => true,
|
|
104
|
+
render(target) {
|
|
105
|
+
container = typeof target === 'string' ? document.querySelector(target) : target;
|
|
106
|
+
const box = document.createElement('div');
|
|
107
|
+
box.setAttribute('data-camphoric-e2e-paypal', '');
|
|
108
|
+
for (const [fundingSource, label] of buttons) {
|
|
109
|
+
const button = document.createElement('button');
|
|
110
|
+
button.type = 'button';
|
|
111
|
+
button.textContent = label;
|
|
112
|
+
button.addEventListener('click', () => { pay(fundingSource); });
|
|
113
|
+
elements.push(button);
|
|
114
|
+
box.appendChild(button);
|
|
115
|
+
}
|
|
116
|
+
container.appendChild(box);
|
|
117
|
+
if (props.onInit) props.onInit({}, actions);
|
|
118
|
+
return Promise.resolve();
|
|
119
|
+
},
|
|
120
|
+
updateProps: () => Promise.resolve(),
|
|
121
|
+
close() {
|
|
122
|
+
if (container) container.replaceChildren();
|
|
123
|
+
return Promise.resolve();
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
})();
|
|
129
|
+
`;
|
package/e2eRegistrationForm.ts
CHANGED
|
@@ -83,6 +83,56 @@ export class FormSection {
|
|
|
83
83
|
message(text: string | RegExp): Locator {
|
|
84
84
|
return this.root.getByText(text);
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A list question (an array of objects in the event's schema, such as a
|
|
89
|
+
* camper's parking passes), by its title: its items, and adding and
|
|
90
|
+
* removing them. A camper's list is the camper's: `camper(n).list(title)`
|
|
91
|
+
* (on the whole form, every camper's list has that title).
|
|
92
|
+
*/
|
|
93
|
+
list(title: string): ListQuestion {
|
|
94
|
+
// The list's own title (its heading, a child of the list's box), not
|
|
95
|
+
// that of a list inside one of its items
|
|
96
|
+
const heading = this.page.locator(':scope > h3').filter({ hasText: new RegExp(`^${escapeRegExp(title)}$`) });
|
|
97
|
+
return new ListQuestion(this.page, this.root.locator('.rjsf-field-array').filter({ has: heading }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A list question's items, each with questions of its own (FormSection.list). */
|
|
102
|
+
export class ListQuestion {
|
|
103
|
+
readonly page: Page;
|
|
104
|
+
readonly root: Locator;
|
|
105
|
+
|
|
106
|
+
constructor(page: Page, root: Locator) {
|
|
107
|
+
this.page = page;
|
|
108
|
+
this.root = root;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Its items, in order: count them with `expect(list.items()).toHaveCount(n)`. */
|
|
112
|
+
items(): Locator {
|
|
113
|
+
return this.root.locator(':scope > .rjsf-array-item-list > .rjsf-array-item');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The nth item's questions (from 1). */
|
|
117
|
+
item(n: number): FormSection {
|
|
118
|
+
return new FormSection(this.page, this.items().nth(n - 1));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Adds an item (its "Add …" button), returning its questions. */
|
|
122
|
+
async add(): Promise<FormSection> {
|
|
123
|
+
const count = await this.items().count();
|
|
124
|
+
await this.root.locator(':scope > * > .rjsf-array-item-add').click();
|
|
125
|
+
await expect(this.items()).toHaveCount(count + 1);
|
|
126
|
+
return this.item(count + 1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Removes the nth item (from 1), with its × button. */
|
|
130
|
+
async remove(n: number): Promise<void> {
|
|
131
|
+
const count = await this.items().count();
|
|
132
|
+
await this.items().nth(n - 1).locator(':scope > .rjsf-array-item-toolbar')
|
|
133
|
+
.getByRole('button', { name: 'Remove', exact: true }).click();
|
|
134
|
+
await expect(this.items()).toHaveCount(count - 1);
|
|
135
|
+
}
|
|
86
136
|
}
|
|
87
137
|
|
|
88
138
|
/** One camper's questions, under its "1st Camper", "2nd Camper", … heading. */
|
|
@@ -154,6 +204,18 @@ export class RegistrationForm extends FormSection {
|
|
|
154
204
|
return this.camper(count + 1);
|
|
155
205
|
}
|
|
156
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Removes the nth camper (from 1), with the Remove button by its heading;
|
|
209
|
+
* the campers after it move up (the 3rd becomes the 2nd).
|
|
210
|
+
*/
|
|
211
|
+
async removeCamper(n: number): Promise<void> {
|
|
212
|
+
const count = await this.page.locator('.camphoric-camper').count();
|
|
213
|
+
// (The heading's row, not the Remove buttons of the camper's lists)
|
|
214
|
+
await this.camper(n).root.locator(':scope > .mantine-Group-root')
|
|
215
|
+
.getByRole('button', { name: 'Remove', exact: true }).click();
|
|
216
|
+
await expect(this.page.locator('.camphoric-camper')).toHaveCount(count - 1);
|
|
217
|
+
}
|
|
218
|
+
|
|
157
219
|
/** Waits until the price ticker shows this total (in dollars). */
|
|
158
220
|
async expectTotal(amount: number): Promise<void> {
|
|
159
221
|
await expect(this.page.getByText(/^Total: /)).toHaveText(`Total: ${money(amount)}`);
|
|
@@ -187,4 +249,50 @@ export class RegistrationForm extends FormSection {
|
|
|
187
249
|
async payByCheck(): Promise<void> {
|
|
188
250
|
await this.page.getByRole('button', { name: /by check$/ }).click();
|
|
189
251
|
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* On the payment step: what the chosen payment option costs, in dollars,
|
|
255
|
+
* as it shows it: by check (the default), or online, which adds the
|
|
256
|
+
* event's e-payment handling (shown only when the event takes payments
|
|
257
|
+
* online: import it with `onlinePayment`).
|
|
258
|
+
*/
|
|
259
|
+
async paymentTotal(how: 'check' | 'online' = 'check'): Promise<number> {
|
|
260
|
+
const due = this.page.getByRole('table', { name: 'Amount due now' });
|
|
261
|
+
const row = due.getByRole('row').filter({ hasText: how === 'check' ? /^By check/ : /^Online/ });
|
|
262
|
+
const amount = row.getByRole('cell').last();
|
|
263
|
+
await expect(amount).toBeVisible();
|
|
264
|
+
return Number(((await amount.textContent()) ?? '').replace(/[^0-9.-]/g, ''));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* On the payment step of an event imported with `onlinePayment`: presses
|
|
269
|
+
* a PayPal button ('PayPal', or 'Card' for "Debit or Credit Card"), which
|
|
270
|
+
* completes the registration and has the server create its PayPal order,
|
|
271
|
+
* and returns the order's id. The fake PayPal's window then stays open: the
|
|
272
|
+
* payer never approves, so nothing is paid. Read what the order asked for
|
|
273
|
+
* with the `payPal` fixture's `order(id)`.
|
|
274
|
+
*/
|
|
275
|
+
async startOnlinePayment(button: 'PayPal' | 'Card' = 'PayPal'): Promise<string> {
|
|
276
|
+
const name = button === 'Card' ? 'Debit or Credit Card' : 'PayPal';
|
|
277
|
+
const created = this.page.waitForResponse((response) => {
|
|
278
|
+
const request = response.request();
|
|
279
|
+
// (With an invitation, the form posts with its code in the query)
|
|
280
|
+
if (request.method() !== 'POST' || new URL(response.url()).pathname !== `/api/events/${this.eventId}/register`) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
return (request.postDataJSON() as { step?: string } | null)?.step === 'paypal-order';
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
await this.page.locator('[data-camphoric-e2e-paypal]')
|
|
290
|
+
.getByRole('button', { name, exact: true }).click();
|
|
291
|
+
const response = await created;
|
|
292
|
+
const body = await response.json() as { orderID?: string, detail?: string };
|
|
293
|
+
if (!response.ok() || !body.orderID) {
|
|
294
|
+
throw new Error(`the server didn't create a PayPal order (${response.status()}): ${body.detail ?? JSON.stringify(body)}`);
|
|
295
|
+
}
|
|
296
|
+
return body.orderID;
|
|
297
|
+
}
|
|
190
298
|
}
|
package/e2eServer.ts
CHANGED
|
@@ -18,6 +18,9 @@ export interface CamphoricServer {
|
|
|
18
18
|
// the admin to import as
|
|
19
19
|
username: string;
|
|
20
20
|
password: string;
|
|
21
|
+
// A throwaway server's fake PayPal API (e2eFakePayPal.py), which its
|
|
22
|
+
// PAYPAL_BASE_URL names, when it was asked for (none for an existing server)
|
|
23
|
+
payPalUrl?: string;
|
|
21
24
|
// Removes a throwaway server and its data (a no-op for an existing one)
|
|
22
25
|
stop: () => Promise<void>;
|
|
23
26
|
}
|
|
@@ -27,6 +30,9 @@ export interface CamphoricServerOptions {
|
|
|
27
30
|
// tag, else ghcr.io/camphoric/camphoric:<CAMPHORIC_IMAGE_TAG or latest>,
|
|
28
31
|
// pulled fresh each run)
|
|
29
32
|
image?: string;
|
|
33
|
+
// Also start the fake PayPal API (default: no), for online payment. It's
|
|
34
|
+
// mounted from this package, so Docker must see its files.
|
|
35
|
+
fakePayPal?: boolean;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
// This package's e2e-compose.yml, from its source or its build in dist/
|
|
@@ -69,6 +75,9 @@ export async function camphoricServer(options: CamphoricServerOptions = {}): Pro
|
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
const port = await freePort();
|
|
78
|
+
let payPalPort = await freePort();
|
|
79
|
+
while (payPalPort === port) payPalPort = await freePort();
|
|
80
|
+
const fakePayPal = options.fakePayPal ?? false;
|
|
72
81
|
const username = 'e2e-admin';
|
|
73
82
|
const password = randomBytes(12).toString('hex');
|
|
74
83
|
const project = `camphoric-e2e-${randomBytes(4).toString('hex')}`;
|
|
@@ -78,6 +87,9 @@ export async function camphoricServer(options: CamphoricServerOptions = {}): Pro
|
|
|
78
87
|
CAMPHORIC_E2E_IMAGE: local ?? `ghcr.io/camphoric/camphoric:${process.env.CAMPHORIC_IMAGE_TAG ?? 'latest'}`,
|
|
79
88
|
CAMPHORIC_E2E_PULL_POLICY: local ? 'missing' : 'always',
|
|
80
89
|
CAMPHORIC_E2E_PORT: String(port),
|
|
90
|
+
CAMPHORIC_E2E_PAYPAL_PORT: String(payPalPort),
|
|
91
|
+
// (for every compose command, so `down` removes the fake PayPal too)
|
|
92
|
+
COMPOSE_PROFILES: fakePayPal ? 'paypal' : '',
|
|
81
93
|
CAMPHORIC_E2E_USERNAME: username,
|
|
82
94
|
CAMPHORIC_E2E_PASSWORD: password,
|
|
83
95
|
};
|
|
@@ -110,5 +122,11 @@ export async function camphoricServer(options: CamphoricServerOptions = {}): Pro
|
|
|
110
122
|
process.off('SIGTERM', interrupted);
|
|
111
123
|
}
|
|
112
124
|
|
|
113
|
-
return {
|
|
125
|
+
return {
|
|
126
|
+
url: `http://localhost:${port}`,
|
|
127
|
+
username,
|
|
128
|
+
password,
|
|
129
|
+
payPalUrl: fakePayPal ? `http://127.0.0.1:${payPalPort}` : undefined,
|
|
130
|
+
stop,
|
|
131
|
+
};
|
|
114
132
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camphoric/camphoric-event-import-tools",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Camphoric REST client, login, the event importer (creator and organization loader), e2e scaffolding for an event's registration form, and the runner that posts generated registrations to an event.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
"types.ts",
|
|
23
23
|
"utils.ts",
|
|
24
24
|
"e2e.ts",
|
|
25
|
+
"e2eAdmin.ts",
|
|
26
|
+
"e2eFakePayPal.py",
|
|
25
27
|
"e2eImport.ts",
|
|
28
|
+
"e2ePayPal.ts",
|
|
26
29
|
"e2eRegistrationForm.ts",
|
|
27
30
|
"e2eServer.ts",
|
|
28
31
|
"e2e-compose.yml",
|
package/types.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface LodgingNode {
|
|
|
25
25
|
visible?: boolean;
|
|
26
26
|
name?: string;
|
|
27
27
|
notes?: string;
|
|
28
|
+
availability?: 'auto' | 'full' | 'open';
|
|
28
29
|
id?: number;
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -42,6 +43,11 @@ export interface ImportData {
|
|
|
42
43
|
registration_end?: string;
|
|
43
44
|
confirmation_email_subject?: string;
|
|
44
45
|
confirmation_email_template?: string;
|
|
46
|
+
confirmation_email_reply_to?: string;
|
|
47
|
+
invoice_email_subject?: string;
|
|
48
|
+
invoice_email_template?: string;
|
|
49
|
+
invoice_email_from?: string;
|
|
50
|
+
invoice_email_reply_to?: string;
|
|
45
51
|
[field: string]: unknown;
|
|
46
52
|
};
|
|
47
53
|
lodgings?: Lodgings;
|
|
@@ -51,6 +57,18 @@ export interface ImportData {
|
|
|
51
57
|
name: string,
|
|
52
58
|
invitation_email_subject: string,
|
|
53
59
|
invitation_email_template: string,
|
|
60
|
+
invitation_email_from?: string,
|
|
61
|
+
invitation_email_reply_to?: string,
|
|
62
|
+
[field: string]: unknown,
|
|
63
|
+
}[];
|
|
64
|
+
// Group emails, matched to the saved ones by name
|
|
65
|
+
email_templates?: { name: string, [field: string]: unknown }[];
|
|
66
|
+
// Matched to the saved ones by code, ignoring case
|
|
67
|
+
promo_codes?: {
|
|
68
|
+
code: string,
|
|
69
|
+
expiration_date?: string,
|
|
70
|
+
// registration type names
|
|
71
|
+
excluded_registration_types?: string[],
|
|
54
72
|
[field: string]: unknown,
|
|
55
73
|
}[];
|
|
56
74
|
}
|
|
@@ -60,6 +78,9 @@ export interface ImportResults {
|
|
|
60
78
|
event?: Saved;
|
|
61
79
|
lodging?: Lodgings;
|
|
62
80
|
reports?: Saved[];
|
|
81
|
+
registration_types?: Saved[];
|
|
82
|
+
email_templates?: Saved[];
|
|
83
|
+
promo_codes?: Saved[];
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
/** What has been saved by the time an event's overrides and sample registrations run. */
|